From 3ba5c435b5ac9f9cece9bc9c21d979a1741796b5 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 21 Sep 2026 11:52:05 +0800 Subject: [PATCH 1/2] Import standalone Agent API Core from Parsar --- .github/workflows/actionlint.yml | 38 + .github/workflows/agents-api.yml | 93 + .github/workflows/agents-executor.yml | 51 + .github/workflows/agents-harness.yml | 60 + .github/workflows/check.yml | 62 + .gitignore | 19 + AGENTS.md | 14 + CONTRIBUTING.md | 2542 +++++ LICENSE | 21 + Makefile | 86 + README.md | 54 + apps/parsar-daemon/.gitignore | 1 + apps/parsar-daemon/cmd/parsar-daemon/main.go | 19 + .../internal/agent/binpath/binpath.go | 66 + .../internal/agent/binpath/binpath_test.go | 39 + .../internal/agent/claudecode/ask.go | 479 + .../internal/agent/claudecode/ask_test.go | 535 + .../internal/agent/claudecode/export_test.go | 97 + .../claudecode/install_concurrency_test.go | 162 + .../internal/agent/claudecode/options.go | 346 + .../internal/agent/claudecode/options_test.go | 405 + .../internal/agent/claudecode/parser.go | 441 + .../claudecode/parser_partial_block_test.go | 56 + .../agent/claudecode/parser_result.go | 127 + .../agent/claudecode/parser_result_test.go | 172 + .../internal/agent/claudecode/parser_test.go | 399 + .../internal/agent/claudecode/permission.go | 100 + .../agent/claudecode/permission_test.go | 71 + .../internal/agent/claudecode/plugins.go | 437 + .../agent/claudecode/plugins_install.go | 153 + .../internal/agent/claudecode/plugins_test.go | 708 ++ .../internal/agent/claudecode/session.go | 716 ++ .../agent/claudecode/session_export_test.go | 38 + .../claudecode/session_knowledge_test.go | 30 + .../internal/agent/claudecode/session_test.go | 617 ++ .../internal/agent/claudecode/skills.go | 276 + .../internal/agent/claudecode/skills_test.go | 165 + .../internal/agent/claudecode/version.go | 32 + .../internal/agent/claudecode/version_test.go | 17 + .../internal/agent/claudesdk/bridge_output.go | 32 + .../internal/agent/claudesdk/cancellation.go | 42 + .../claudesdk/cancellation_live_linux_test.go | 161 + .../agent/claudesdk/cancellation_test.go | 204 + .../internal/agent/claudesdk/commands.go | 62 + .../agent/claudesdk/commands_session_test.go | 201 + .../internal/agent/claudesdk/commands_test.go | 143 + .../claudesdk/execution_controls_test.go | 77 + .../internal/agent/claudesdk/functions.go | 181 + .../agent/claudesdk/functions_test.go | 144 + .../agent/claudesdk/live_linux_test.go | 399 + .../internal/agent/claudesdk/local.go | 38 + .../internal/agent/claudesdk/local_test.go | 66 + .../internal/agent/claudesdk/mcp.go | 133 + .../agent/claudesdk/mcp_bearer_test.go | 85 + .../internal/agent/claudesdk/mcp_test.go | 117 + .../internal/agent/claudesdk/messages_test.go | 161 + .../internal/agent/claudesdk/options.go | 147 + .../internal/agent/claudesdk/options_test.go | 34 + .../internal/agent/claudesdk/preparation.go | 177 + .../claudesdk/preparation_fixture_test.go | 131 + .../agent/claudesdk/preparation_test.go | 321 + .../internal/agent/claudesdk/provider.go | 44 + .../internal/agent/claudesdk/readiness.go | 116 + .../agent/claudesdk/readiness_test.go | 151 + .../agent/claudesdk/restrictions_test.go | 55 + .../internal/agent/claudesdk/session.go | 307 + .../internal/agent/claudesdk/session_test.go | 176 + .../internal/agent/claudesdk/steering.go | 154 + .../internal/agent/claudesdk/steering_test.go | 209 + .../internal/agent/claudesdk/usage.go | 37 + .../internal/agent/claudesdk/usage_test.go | 165 + .../internal/agent/claudesdk/workspace.go | 208 + .../workspace_commands_live_linux_test.go | 52 + .../agent/claudesdk/workspace_directory.go | 249 + .../workspace_directory_live_linux_test.go | 103 + .../claudesdk/workspace_directory_test.go | 209 + .../agent/claudesdk/workspace_launch_test.go | 178 + .../claudesdk/workspace_live_linux_test.go | 295 + .../agent/claudesdk/workspace_read.go | 208 + .../workspace_read_live_linux_test.go | 58 + .../agent/claudesdk/workspace_read_test.go | 203 + .../agent/claudesdk/workspace_test.go | 149 + .../internal/agent/clirunner/process.go | 161 + .../clirunner/process_group_linux_test.go | 244 + .../agent/clirunner/process_group_other.go | 9 + .../agent/clirunner/process_group_unix.go | 126 + .../internal/agent/clirunner/process_test.go | 78 + .../internal/agent/codex/approval_policy.go | 85 + .../agent/codex/approval_policy_test.go | 142 + .../agent/codex/cancellation_outcome.go | 51 + .../internal/agent/codex/environment.go | 35 + .../internal/agent/codex/environment_local.go | 27 + .../agent/codex/environment_remote.go | 71 + .../agent/codex/environment_remote_test.go | 130 + .../codex/environment_remote_validation.go | 94 + .../environment_remote_validation_test.go | 132 + .../internal/agent/codex/environment_test.go | 43 + .../agent/codex/execution_controls.go | 26 + .../agent/codex/execution_controls_test.go | 57 + .../internal/agent/codex/export_test.go | 124 + .../internal/agent/codex/functions.go | 147 + .../internal/agent/codex/functions_test.go | 111 + .../internal/agent/codex/generation_config.go | 17 + .../internal/agent/codex/hosted_skills.go | 44 + .../agent/codex/hosted_skills_test.go | 36 + .../internal/agent/codex/mcp_config.go | 158 + .../internal/agent/codex/mcp_config_test.go | 134 + .../internal/agent/codex/mcp_http.go | 85 + .../internal/agent/codex/mcp_http_bearer.go | 26 + .../agent/codex/mcp_http_bearer_test.go | 136 + .../agent/codex/mcp_http_preflight.go | 104 + .../agent/codex/mcp_http_preflight_test.go | 218 + .../internal/agent/codex/mcp_http_test.go | 162 + .../internal/agent/codex/mcp_required_test.go | 90 + .../codex/model_catalog_command_other.go | 16 + .../agent/codex/model_catalog_command_unix.go | 21 + .../codex/model_catalog_command_unix_test.go | 56 + .../internal/agent/codex/model_verbosity.go | 111 + .../agent/codex/model_verbosity_test.go | 118 + .../internal/agent/codex/options.go | 506 + .../internal/agent/codex/options_test.go | 280 + .../agent/codex/permission_profile.go | 48 + .../agent/codex/permission_profile_test.go | 92 + .../internal/agent/codex/preparation.go | 177 + .../agent/codex/preparation_close_test.go | 45 + .../agent/codex/preparation_helpers_test.go | 217 + .../agent/codex/preparation_router_test.go | 129 + .../internal/agent/codex/prepared.go | 141 + .../agent/codex/prepared_cancel_test.go | 269 + .../internal/agent/codex/prepared_test.go | 240 + .../internal/agent/codex/private_harness.go | 112 + .../agent/codex/private_harness_test.go | 230 + .../internal/agent/codex/protocol.go | 466 + .../agent/codex/protocol_wire_test.go | 133 + .../internal/agent/codex/provider_config.go | 173 + .../agent/codex/provider_config_test.go | 241 + .../internal/agent/codex/recovery.go | 71 + .../internal/agent/codex/recovery_test.go | 197 + .../internal/agent/codex/resume.go | 37 + .../internal/agent/codex/resume_test.go | 74 + .../parsar-daemon/internal/agent/codex/rpc.go | 527 + .../internal/agent/codex/rpc_close.go | 52 + .../internal/agent/codex/rpc_close_test.go | 106 + .../internal/agent/codex/rpc_process_test.go | 110 + .../internal/agent/codex/rpc_request.go | 88 + .../internal/agent/codex/rpc_test.go | 102 + .../internal/agent/codex/rpc_write.go | 26 + .../internal/agent/codex/server_requests.go | 376 + .../agent/codex/server_requests_mcp.go | 38 + .../agent/codex/server_requests_mcp_test.go | 100 + .../agent/codex/server_requests_test.go | 349 + .../internal/agent/codex/session.go | 483 + .../internal/agent/codex/session_cancel.go | 26 + .../agent/codex/session_cancel_test.go | 154 + .../agent/codex/session_cancel_write_test.go | 185 + .../agent/codex/session_command_output.go | 21 + .../codex/session_command_output_test.go | 49 + .../internal/agent/codex/session_items.go | 234 + .../agent/codex/session_items_test.go | 194 + .../agent/codex/session_knowledge_test.go | 42 + .../internal/agent/codex/session_log_test.go | 256 + .../internal/agent/codex/session_messages.go | 80 + .../agent/codex/session_messages_test.go | 72 + .../agent/codex/session_notifications.go | 48 + .../codex/session_notifications_rpc_test.go | 166 + .../agent/codex/session_notifications_test.go | 183 + .../internal/agent/codex/session_output.go | 30 + .../internal/agent/codex/session_plan.go | 93 + .../agent/codex/session_policy_test.go | 80 + .../internal/agent/codex/session_run.go | 70 + .../internal/agent/codex/session_steering.go | 117 + .../codex/session_steering_lifecycle_test.go | 146 + .../codex/session_steering_receipt_test.go | 112 + .../agent/codex/session_steering_test.go | 118 + .../internal/agent/codex/session_thread.go | 85 + .../internal/agent/codex/session_tools.go | 42 + .../agent/codex/session_tools_test.go | 114 + .../agent/codex/session_turn_error_test.go | 73 + .../internal/agent/codex/session_usage.go | 102 + .../agent/codex/session_usage_test.go | 154 + .../internal/agent/codex/skills.go | 52 + .../internal/agent/codex/skills_test.go | 76 + .../internal/agent/codex/subagent_metadata.go | 69 + .../agent/codex/subagent_observations.go | 178 + .../agent/codex/subagent_observations_test.go | 197 + .../internal/agent/codex/subagents.go | 13 + .../internal/agent/codex/subagents_test.go | 18 + .../internal/agent/codex/tool_environment.go | 83 + .../agent/codex/tool_environment_test.go | 75 + .../internal/agent/codex/tool_observations.go | 125 + .../agent/codex/tool_observations_test.go | 39 + .../internal/agent/codex/verbosity_test.go | 33 + .../internal/agent/codex/version.go | 60 + .../internal/agent/codex/web_search_test.go | 33 + .../agent/codex/workspace_directory.go | 115 + .../agent/codex/workspace_directory_test.go | 98 + .../agent/codex/workspace_preparation.go | 87 + .../workspace_preparation_failure_test.go | 40 + .../agent/codex/workspace_preparation_test.go | 87 + .../internal/agent/codex/workspace_read.go | 193 + .../agent/codex/workspace_read_test.go | 291 + .../parsar-daemon/internal/agent/functions.go | 14 + .../internal/agent/installroot/lock.go | 69 + .../internal/agent/installroot/lock_test.go | 39 + .../internal/agent/interactions.go | 18 + .../internal/agent/mcode/events.go | 114 + .../internal/agent/mcode/execution.go | 62 + .../internal/agent/mcode/execution_test.go | 77 + .../agent/mcode/native_history_test.go | 66 + .../internal/agent/mcode/native_test.go | 192 + .../internal/agent/mcode/options.go | 230 + .../internal/agent/mcode/options_test.go | 117 + .../internal/agent/mcode/preparation.go | 131 + .../internal/agent/mcode/preparation_test.go | 123 + .../internal/agent/mcode/protocol.go | 66 + .../internal/agent/mcode/questions.go | 216 + .../internal/agent/mcode/questions_test.go | 55 + .../internal/agent/mcode/session.go | 378 + .../internal/agent/mcode/session_test.go | 327 + .../internal/agent/mcode/steering.go | 100 + .../internal/agent/mcode/steering_test.go | 126 + .../internal/agent/mcode/tool_observations.go | 55 + .../agent/mcode/tool_observations_test.go | 49 + .../internal/agent/mcode/version.go | 24 + .../internal/agent/mcode/version_test.go | 26 + .../internal/agent/mcode/workspace.go | 117 + .../agent/mcode/workspace_readiness.go | 38 + apps/parsar-daemon/internal/agent/mcp.go | 19 + .../internal/agent/opencode/export_test.go | 36 + .../internal/agent/opencode/options.go | 312 + .../internal/agent/opencode/options_test.go | 161 + .../internal/agent/opencode/parser.go | 296 + .../internal/agent/opencode/parser_test.go | 169 + .../agent/opencode/parser_tools_test.go | 96 + .../internal/agent/opencode/session.go | 217 + .../agent/opencode/session_model_test.go | 57 + .../internal/agent/opencode/session_test.go | 406 + .../internal/agent/opencode/skills.go | 134 + .../internal/agent/opencode/skills_test.go | 38 + .../internal/agent/opencode/version.go | 35 + .../internal/agent/opencode/version_test.go | 18 + .../internal/agent/pi/export_test.go | 36 + .../internal/agent/pi/options.go | 217 + .../internal/agent/pi/options_test.go | 240 + .../parsar-daemon/internal/agent/pi/parser.go | 337 + .../internal/agent/pi/parser_test.go | 259 + .../internal/agent/pi/provider_config.go | 211 + .../internal/agent/pi/provider_config_test.go | 308 + .../internal/agent/pi/session.go | 211 + .../agent/pi/session_provider_test.go | 75 + .../internal/agent/pi/session_skills_test.go | 86 + .../internal/agent/pi/session_test.go | 331 + .../parsar-daemon/internal/agent/pi/skills.go | 393 + .../agent/pi/skills_concurrency_test.go | 78 + .../internal/agent/pi/skills_install.go | 134 + .../internal/agent/pi/skills_test.go | 318 + .../internal/agent/pi/version.go | 36 + .../internal/agent/pi/version_test.go | 30 + .../internal/agent/preparation.go | 59 + apps/parsar-daemon/internal/agent/registry.go | 144 + .../internal/agent/registry_test.go | 138 + .../internal/agent/runtime_paths.go | 64 + .../internal/agent/runtime_paths_test.go | 32 + apps/parsar-daemon/internal/agent/steering.go | 27 + .../agent/versionprobe/testutil/testutil.go | 120 + .../agent/versionprobe/versionprobe.go | 52 + .../internal/agent/workspace_directory.go | 30 + .../internal/agent/workspace_read.go | 24 + .../internal/agent/workspace_write.go | 24 + apps/parsar-daemon/internal/auth/store.go | 123 + .../parsar-daemon/internal/auth/store_test.go | 180 + .../internal/authoring/bridge.go | 134 + .../internal/authoring/bridge_test.go | 106 + .../internal/cli/agent_discovery.go | 191 + .../internal/cli/agent_registration.go | 41 + apps/parsar-daemon/internal/cli/authoring.go | 70 + .../internal/cli/authoring_test.go | 98 + .../internal/cli/capability_downloads.go | 67 + .../internal/cli/capability_downloads_test.go | 94 + apps/parsar-daemon/internal/cli/claude_sdk.go | 125 + .../cli/claude_sdk_live_linux_test.go | 189 + .../internal/cli/claude_sdk_test.go | 146 + .../internal/cli/companion_path_test.go | 37 + apps/parsar-daemon/internal/cli/connect.go | 415 + .../internal/cli/connect_test.go | 273 + apps/parsar-daemon/internal/cli/logout.go | 46 + apps/parsar-daemon/internal/cli/logs.go | 63 + apps/parsar-daemon/internal/cli/mcode.go | 35 + .../internal/cli/mcode_execution_test.go | 26 + .../internal/cli/mcode_workspace.go | 76 + apps/parsar-daemon/internal/cli/mcp_test.go | 49 + apps/parsar-daemon/internal/cli/pair.go | 145 + apps/parsar-daemon/internal/cli/pair_test.go | 95 + apps/parsar-daemon/internal/cli/placement.go | 69 + .../internal/cli/preparation_test.go | 51 + apps/parsar-daemon/internal/cli/root.go | 93 + apps/parsar-daemon/internal/cli/root_test.go | 83 + .../internal/cli/skill_upload.go | 39 + .../internal/cli/skill_upload_test.go | 41 + apps/parsar-daemon/internal/cli/status.go | 69 + apps/parsar-daemon/internal/cli/stop.go | 56 + apps/parsar-daemon/internal/daemonize/fork.go | 125 + .../internal/daemonize/fork_test.go | 95 + .../internal/daemonize/helpers_test.go | 109 + .../internal/daemonize/logfile.go | 170 + .../internal/daemonize/logfile_test.go | 240 + .../internal/daemonize/pidfile.go | 123 + .../internal/daemonize/pidfile_test.go | 168 + .../internal/dispatch/cancellation.go | 81 + .../internal/dispatch/cancellation_test.go | 124 + .../internal/dispatch/capabilities.go | 12 + .../internal/dispatch/environment.go | 25 + .../internal/dispatch/environment_test.go | 72 + .../internal/dispatch/export_test.go | 38 + .../internal/dispatch/functions.go | 65 + .../dispatch/functions_native_test.go | 188 + .../internal/dispatch/functions_test.go | 161 + .../dispatch/interaction_decisions.go | 319 + .../internal/dispatch/local_directory.go | 20 + .../internal/dispatch/local_directory_test.go | 72 + .../internal/dispatch/mcp_http.go | 44 + .../internal/dispatch/mcp_http_test.go | 166 + .../dispatch/optional_interactions_test.go | 46 + .../parsar-daemon/internal/dispatch/output.go | 103 + .../internal/dispatch/preparation.go | 294 + .../internal/dispatch/preparation_cancel.go | 56 + .../dispatch/preparation_cancel_test.go | 339 + .../internal/dispatch/preparation_cleanup.go | 53 + .../dispatch/preparation_cleanup_test.go | 289 + .../internal/dispatch/preparation_start.go | 194 + .../internal/dispatch/preparation_test.go | 409 + .../internal/dispatch/prepared_handoff.go | 356 + .../prepared_handoff_mutation_test.go | 334 + .../dispatch/prepared_handoff_test.go | 358 + .../parsar-daemon/internal/dispatch/prompt.go | 134 + .../internal/dispatch/receipt_order_test.go | 174 + .../dispatch/receipt_shutdown_test.go | 78 + .../parsar-daemon/internal/dispatch/router.go | 315 + .../internal/dispatch/router_test.go | 691 ++ .../internal/dispatch/shutdown.go | 199 + .../internal/dispatch/steering.go | 196 + .../internal/dispatch/steering_lifetime.go | 44 + .../dispatch/steering_lifetime_test.go | 153 + .../internal/dispatch/steering_test.go | 266 + .../dispatch/workspace_directory_test.go | 82 + .../internal/dispatch/workspace_export.go | 134 + .../dispatch/workspace_export_test.go | 170 + .../workspace_preparation_failure_test.go | 81 + .../workspace_preparation_status_test.go | 91 + .../dispatch/workspace_preparation_test.go | 117 + .../internal/dispatch/workspace_read.go | 153 + .../internal/dispatch/workspace_read_test.go | 216 + .../internal/dispatch/workspace_write.go | 165 + .../internal/dispatch/workspace_write_test.go | 167 + .../internal/localworkspace/binding.go | 110 + .../internal/localworkspace/binding_test.go | 92 + .../internal/localworkspace/directory.go | 78 + .../localworkspace/directory_native_test.go | 53 + .../internal/localworkspace/export.go | 40 + .../internal/localworkspace/initialization.go | 52 + .../localworkspace/network_policy_test.go | 25 + .../internal/localworkspace/skills.go | 60 + .../internal/localworkspace/write.go | 103 + .../internal/localworkspace/write_binding.go | 45 + .../internal/localworkspace/write_test.go | 209 + apps/parsar-daemon/internal/paths/paths.go | 107 + .../internal/paths/paths_test.go | 130 + .../internal/transport/bootstrap.go | 140 + .../internal/transport/bootstrap_test.go | 161 + .../internal/transport/reconnect.go | 126 + .../internal/transport/reconnect_test.go | 165 + apps/parsar-daemon/internal/transport/ws.go | 369 + .../internal/transport/ws_test.go | 426 + .../parsar-daemon/testdata/onboarding/main.go | 125 + contracts/agents-api/README.md | 778 ++ contracts/agents-api/environment-files.md | 135 + contracts/agents-api/environment-templates.md | 446 + contracts/agents-api/environments.md | 530 + contracts/agents-api/harness-onboarding.md | 147 + contracts/agents-api/harness-selection.md | 91 + contracts/agents-api/harnesses.md | 134 + contracts/agents-api/mcode-workspace-v1.md | 158 + contracts/agents-api/model-execution.md | 53 + contracts/agents-api/openapi.yaml | 3977 ++++++++ contracts/agents-api/source-files.md | 86 + contracts/agents-api/upstream.json | 8 + contracts/agents-api/v1/agents.go | 85 + contracts/agents-api/v1/core_extension.go | 20 + contracts/agents-api/v1/credentials.go | 54 + contracts/agents-api/v1/environment_events.go | 10 + contracts/agents-api/v1/environment_files.go | 21 + .../agents-api/v1/environment_templates.go | 53 + contracts/agents-api/v1/environments.go | 32 + contracts/agents-api/v1/events.go | 26 + contracts/agents-api/v1/function_actions.go | 10 + contracts/agents-api/v1/function_tools.go | 12 + contracts/agents-api/v1/inputs.go | 29 + contracts/agents-api/v1/items.go | 87 + contracts/agents-api/v1/mcp_tools.go | 34 + contracts/agents-api/v1/model_execution.go | 60 + .../agents-api/v1/model_execution_test.go | 23 + contracts/agents-api/v1/required_actions.go | 47 + .../agents-api/v1/required_actions_test.go | 20 + contracts/agents-api/v1/session_artifacts.go | 23 + contracts/agents-api/v1/session_deletion.go | 7 + .../agents-api/v1/session_environment.go | 17 + contracts/agents-api/v1/sessions.go | 118 + contracts/agents-api/v1/source_files.go | 27 + contracts/agents-api/v1/turns.go | 24 + contracts/agents-api/v1/usage.go | 15 + contracts/agents-api/v1/vaults.go | 30 + contracts/agents-api/workspace-placement.md | 127 + go.mod | 68 + go.sum | 157 + go.work | 3 + internal/agentdaemon/device/credential.go | 24 + internal/agentdaemon/device/state.go | 112 + internal/agentdaemon/gateway/auth.go | 91 + internal/agentdaemon/gateway/auth_test.go | 96 + .../agentdaemon/gateway/functions_test.go | 18 + internal/agentdaemon/gateway/handler.go | 351 + .../gateway/mcp_bearer_fixture_linux_test.go | 151 + .../gateway/mcp_bearer_live_linux_test.go | 193 + .../gateway/mcp_bearer_process_linux_test.go | 296 + internal/agentdaemon/gateway/mcp_test.go | 90 + internal/agentdaemon/gateway/owner.go | 35 + internal/agentdaemon/gateway/owner_test.go | 75 + internal/agentdaemon/gateway/preparation.go | 86 + .../agentdaemon/gateway/preparation_test.go | 84 + internal/agentdaemon/gateway/registry.go | 342 + internal/agentdaemon/gateway/registry_test.go | 173 + internal/agentdaemon/gateway/routes.go | 25 + internal/agentdaemon/gateway/session.go | 646 ++ internal/agentdaemon/gateway/session_test.go | 479 + internal/agentdaemon/gateway/subscription.go | 126 + .../agentdaemon/gateway/subscription_test.go | 76 + .../gateway/workspace_directory_test.go | 94 + .../agentdaemon/gateway/workspace_export.go | 129 + .../gateway/workspace_export_test.go | 121 + .../agentdaemon/gateway/workspace_read.go | 122 + .../gateway/workspace_read_test.go | 147 + .../agentdaemon/gateway/workspace_write.go | 123 + .../gateway/workspace_write_test.go | 105 + .../agentdaemon/placement/controller_linux.go | 211 + .../placement/controller_linux_test.go | 411 + .../agentdaemon/placement/controller_other.go | 24 + .../agentdaemon/placement/docker_linux.go | 164 + internal/agentdaemon/placement/environment.go | 38 + .../placement/environment_linux_test.go | 198 + .../agentdaemon/placement/mounts_linux.go | 69 + .../placement/mounts_linux_test.go | 56 + .../agentdaemon/placement/observe_linux.go | 123 + internal/agentdaemon/placement/state_linux.go | 161 + internal/agentdaemon/placement/types.go | 41 + internal/agentdaemon/proto/authoring.go | 31 + internal/agentdaemon/proto/command_output.go | 11 + internal/agentdaemon/proto/envelope.go | 90 + internal/agentdaemon/proto/envelope_test.go | 103 + internal/agentdaemon/proto/environment.go | 38 + internal/agentdaemon/proto/functions.go | 58 + internal/agentdaemon/proto/functions_test.go | 43 + internal/agentdaemon/proto/inbound.go | 305 + internal/agentdaemon/proto/mcp.go | 13 + internal/agentdaemon/proto/mcp_test.go | 28 + internal/agentdaemon/proto/outbound.go | 175 + internal/agentdaemon/proto/preparation.go | 42 + internal/agentdaemon/proto/steering.go | 24 + internal/agentdaemon/proto/subagents.go | 14 + internal/agentdaemon/proto/token_usage.go | 21 + .../agentdaemon/proto/token_usage_test.go | 26 + .../agentdaemon/proto/tool_observations.go | 28 + internal/agentdaemon/proto/version.go | 40 + .../agentdaemon/proto/workspace_directory.go | 76 + .../agentdaemon/proto/workspace_export.go | 31 + internal/agentdaemon/proto/workspace_read.go | 30 + .../proto/workspace_read_preparation.go | 13 + internal/agentdaemon/proto/workspace_write.go | 63 + internal/agentskill/bundle.go | 166 + internal/agentskill/bundle_test.go | 71 + internal/obs/log/api.go | 40 + internal/obs/log/api_test.go | 63 + internal/obs/log/background.go | 37 + internal/obs/log/carrier.go | 88 + internal/obs/log/carrier_test.go | 87 + internal/obs/log/context.go | 64 + internal/obs/log/discard.go | 13 + internal/obs/log/handler.go | 51 + internal/obs/log/handler_test.go | 64 + internal/obs/log/http.go | 35 + internal/obs/log/http_test.go | 122 + internal/obs/log/init.go | 107 + internal/obs/log/trace.go | 86 + .../runtimecrypto/cmd/emit-fixture/main.go | 76 + internal/runtimecrypto/runtime_seal.go | 110 + internal/runtimecrypto/runtime_seal_test.go | 135 + .../runtimecrypto/runtime_seal_wire_test.go | 56 + internal/runtimecrypto/testdata/wire_v1.json | 8 + package.json | 13 + packages/agents-client/README.md | 56 + packages/agents-client/v1/client.go | 58 + packages/agents-client/v1/client_test.go | 85 + packages/agents-client/v1/service_test.go | 107 + packages/claude-sdk-adapter/package.json | 21 + packages/claude-sdk-adapter/src/adapter.ts | 165 + .../src/command_observer.ts | 111 + .../claude-sdk-adapter/src/function_bridge.ts | 97 + packages/claude-sdk-adapter/src/functions.ts | 30 + packages/claude-sdk-adapter/src/inputs.ts | 91 + packages/claude-sdk-adapter/src/main.ts | 71 + packages/claude-sdk-adapter/src/mcp.ts | 139 + .../claude-sdk-adapter/src/mcp_observer.ts | 73 + packages/claude-sdk-adapter/src/messages.ts | 71 + packages/claude-sdk-adapter/src/native.ts | 13 + packages/claude-sdk-adapter/src/recovery.ts | 11 + packages/claude-sdk-adapter/src/request.ts | 63 + .../claude-sdk-adapter/src/runtime_check.ts | 53 + packages/claude-sdk-adapter/src/usage.ts | 9 + packages/claude-sdk-adapter/src/workspace.ts | 185 + .../src/workspace_directories.ts | 90 + .../claude-sdk-adapter/src/workspace_reads.ts | 57 + .../src/workspace_skills.ts | 44 + .../tests/command_observer.test.mjs | 145 + .../tests/execution.test.mjs | 81 + .../tests/function_bridge.test.mjs | 67 + .../tests/functions.test.mjs | 95 + .../claude-sdk-adapter/tests/inputs.test.mjs | 65 + .../claude-sdk-adapter/tests/mcp.test.mjs | 121 + .../tests/mcp_bearer.test.mjs | 43 + .../tests/mcp_required.test.mjs | 83 + .../tests/messages.test.mjs | 100 + .../claude-sdk-adapter/tests/native.test.mjs | 29 + .../tests/preparation.test.mjs | 220 + .../tests/recovery.test.mjs | 25 + .../claude-sdk-adapter/tests/usage.test.mjs | 27 + .../tests/workspace.test.mjs | 232 + .../tests/workspace_directories.test.mjs | 95 + .../tests/workspace_execution.test.mjs | 107 + .../tests/workspace_reads.test.mjs | 34 + packages/claude-sdk-adapter/tsconfig.json | 12 + packages/codex-executor/Cargo.lock | 8719 +++++++++++++++++ packages/codex-executor/Cargo.toml | 49 + packages/codex-executor/README.md | 199 + packages/codex-executor/rust-toolchain.toml | 3 + packages/codex-executor/src/bin/directory.rs | 60 + packages/codex-executor/src/bin/export.rs | 20 + packages/codex-executor/src/bin/write.rs | 39 + packages/codex-executor/src/directory.rs | 68 + .../codex-executor/src/directory_tests.rs | 154 + packages/codex-executor/src/export.rs | 142 + packages/codex-executor/src/export_tests.rs | 166 + packages/codex-executor/src/main.rs | 80 + packages/codex-executor/src/options.rs | 128 + packages/codex-executor/src/options_tests.rs | 171 + packages/codex-executor/src/runtime.rs | 68 + packages/codex-executor/src/workspace_path.rs | 69 + packages/codex-executor/src/write_file.rs | 133 + .../codex-executor/src/write_file_tests.rs | 222 + packages/codex-harness/README.md | 221 + .../patches/artifact-target.patch | 13 + .../codex-harness/patches/bounded-read.patch | 551 ++ .../patches/manager-exposure.patch | 91 + packages/codex-harness/prepare.py | 134 + packages/codex-harness/prepare_test.py | 72 + packages/codex-harness/source.json | 29 + packages/codex-harness/src/files.rs | 426 + packages/codex-harness/src/files_directory.rs | 156 + .../src/files_directory_output.rs | 70 + .../src/files_directory_output_tests.rs | 119 + .../src/files_directory_tests.rs | 88 + .../codex-harness/src/files_process_output.rs | 51 + .../codex-harness/src/files_read_tests.rs | 101 + packages/codex-harness/src/files_tests.rs | 298 + packages/codex-harness/src/files_write.rs | 160 + .../src/files_write_process_tests.rs | 152 + .../codex-harness/src/files_write_tests.rs | 156 + packages/codex-harness/src/main.rs | 211 + packages/codex-harness/src/options.rs | 158 + packages/codex-harness/src/options_write.rs | 113 + packages/codex-harness/src/owner.rs | 81 + packages/codex-harness/src/read_profile.rs | 87 + packages/mcode-harness/README.md | 54 + packages/mcode-harness/bridge.mjs | 34 + packages/mcode-harness/build-sandbox.mjs | 18 + packages/mcode-harness/build.mjs | 31 + packages/mcode-harness/check.mjs | 14 + packages/mcode-harness/launch.mjs | 37 + packages/mcode-harness/native-pi-tools.ts | 5 + packages/mcode-harness/native.test.mjs | 36 + packages/mcode-harness/package-lock.json | 1841 ++++ packages/mcode-harness/package.json | 28 + packages/mcode-harness/sandbox-entry.ts | 1 + packages/mcode-harness/source.json | 5 + packages/mcode-harness/tool-executor.mjs | 65 + packages/mcode-harness/tool-executor.test.mjs | 64 + packages/mcode-harness/worker.ts | 48 + packages/tsconfig/base.json | 13 + pnpm-lock.yaml | 966 ++ pnpm-workspace.yaml | 2 + provenance/README.md | 57 + provenance/source.json | 1308 +++ provenance/verification.md | 66 + scripts/build-agents-api-image.sh | 19 + scripts/build-agents-api-release.sh | 141 + scripts/build-agents-api.sh | 43 + scripts/build-agents-executor.sh | 42 + scripts/build-agents-harness.sh | 66 + scripts/build-agents-runtime.sh | 46 + scripts/build-claude-runtime.sh | 34 + scripts/build-claude-sdk-runtime.sh | 43 + scripts/build-mcode-harness.sh | 49 + scripts/build-mcode-runtime.sh | 35 + scripts/check-agents-executor.sh | 21 + scripts/check-agents-harness.sh | 9 + scripts/check-claude-sdk-runtime.mjs | 29 + scripts/check-sqlc.py | 17 + scripts/verify-source-copy.py | 35 + services/agents-api/CONTAINER.md | 105 + services/agents-api/Dockerfile | 9 + services/agents-api/HOSTED-RELEASE.md | 186 + services/agents-api/README.md | 712 ++ services/agents-api/RELEASE.md | 167 + services/agents-api/cmd/device/main.go | 72 + .../agents-api/cmd/environment-key/main.go | 139 + .../cmd/environment-key/main_test.go | 159 + services/agents-api/cmd/migrate/main.go | 28 + .../cmd/server/credential_cipher.go | 26 + .../cmd/server/credential_cipher_test.go | 54 + .../cmd/server/environment_connection_test.go | 105 + .../cmd/server/execution_options.go | 60 + .../cmd/server/execution_options_test.go | 73 + services/agents-api/cmd/server/executor.go | 58 + .../agents-api/cmd/server/executor_test.go | 71 + services/agents-api/cmd/server/main.go | 183 + .../agents-api/cmd/server/managed_runtimes.go | 135 + .../cmd/server/managed_runtimes_test.go | 79 + services/agents-api/credentials.md | 221 + services/agents-api/deploy/claude/Dockerfile | 27 + services/agents-api/deploy/claude/README.md | 85 + services/agents-api/deploy/codex/Dockerfile | 30 + services/agents-api/deploy/codex/README.md | 195 + .../agents-api/deploy/codex/requirements.toml | 42 + .../agents-api/deploy/codex/seccomp.LICENSE | 202 + services/agents-api/deploy/codex/seccomp.json | 938 ++ services/agents-api/deploy/codex/tool-env.py | 27 + services/agents-api/deploy/e2b/README.md | 144 + .../agents-api/deploy/e2b/build-template.py | 81 + services/agents-api/deploy/e2b/init.py | 69 + .../agents-api/deploy/e2b/requirements.txt | 1 + services/agents-api/deploy/mcode/Dockerfile | 30 + services/agents-api/deploy/mcode/README.md | 160 + .../deploy/runtime/build-system-seed.py | 46 + .../agents-api/deploy/runtime/initialize.py | 237 + .../deploy/runtime/initialize_test.py | 119 + .../agents-api/deploy/runtime/tool-root.py | 116 + services/agents-api/internal/api/agents.go | 113 + .../agents-api/internal/api/agents_delete.go | 46 + .../agents-api/internal/api/agents_list.go | 45 + .../agents-api/internal/api/agents_update.go | 86 + services/agents-api/internal/api/auth.go | 110 + services/agents-api/internal/api/auth_test.go | 123 + .../internal/api/claude_admission_test.go | 80 + .../internal/api/claude_mcp_test.go | 54 + .../agents-api/internal/api/configuration.go | 37 + .../agents-api/internal/api/credentials.go | 113 + .../internal/api/credentials_delete.go | 48 + .../internal/api/credentials_delete_test.go | 82 + .../internal/api/credentials_list.go | 47 + .../internal/api/credentials_list_test.go | 68 + .../internal/api/credentials_test.go | 114 + .../internal/api/credentials_update.go | 51 + .../internal/api/credentials_update_test.go | 105 + .../internal/api/environment_creation_test.go | 229 + .../internal/api/environment_files.go | 91 + .../environment_files_completeness_test.go | 108 + .../internal/api/environment_files_create.go | 135 + .../api/environment_files_create_test.go | 135 + .../internal/api/environment_files_cursor.go | 62 + .../api/environment_files_deadline_test.go | 52 + .../internal/api/environment_files_query.go | 88 + .../internal/api/environment_files_test.go | 240 + .../internal/api/environment_input.go | 28 + .../internal/api/environment_input_test.go | 135 + .../internal/api/environment_request.go | 34 + .../internal/api/environment_setup.go | 90 + .../internal/api/environment_setup_test.go | 77 + .../internal/api/environment_skills.go | 76 + .../internal/api/environment_skills_test.go | 96 + .../internal/api/environment_templates.go | 217 + .../api/environment_templates_test.go | 92 + .../agents-api/internal/api/environments.go | 62 + .../internal/api/environments_test.go | 151 + services/agents-api/internal/api/errors.go | 58 + .../internal/api/execution_policy.go | 9 + .../internal/api/function_configuration.go | 69 + .../api/function_configuration_test.go | 65 + .../internal/api/function_inputs.go | 118 + .../internal/api/function_inputs_test.go | 80 + .../internal/api/function_state_test.go | 48 + services/agents-api/internal/api/handler.go | 337 + .../agents-api/internal/api/handler_test.go | 124 + services/agents-api/internal/api/harness.go | 31 + .../agents-api/internal/api/harness_test.go | 107 + .../internal/api/hosted_environment.go | 139 + .../internal/api/hosted_environment_test.go | 119 + .../agents-api/internal/api/initial_files.go | 75 + .../internal/api/initial_files_test.go | 43 + services/agents-api/internal/api/inputs.go | 138 + .../agents-api/internal/api/inputs_test.go | 72 + services/agents-api/internal/api/items.go | 33 + .../agents-api/internal/api/items_test.go | 51 + .../agents-api/internal/api/json_request.go | 25 + .../internal/api/mcp_configuration.go | 81 + .../internal/api/mcp_configuration_test.go | 129 + .../agents-api/internal/api/pagination.go | 58 + .../internal/api/pagination_test.go | 36 + .../internal/api/saved_configuration.go | 129 + .../agents-api/internal/api/saved_tools.go | 66 + .../agents-api/internal/api/session_agent.go | 90 + .../internal/api/session_artifacts.go | 141 + .../internal/api/session_artifacts_test.go | 108 + .../internal/api/session_creation_identity.go | 67 + .../internal/api/session_creation_stream.go | 60 + .../internal/api/session_credentials.go | 65 + .../internal/api/session_credentials_test.go | 80 + .../internal/api/session_deletion.go | 47 + .../api/session_environment_http_test.go | 123 + .../internal/api/session_environment_test.go | 122 + .../internal/api/session_initial_input.go | 32 + .../api/session_initial_input_test.go | 40 + .../internal/api/session_metadata.go | 91 + .../internal/api/session_request.go | 91 + .../internal/api/session_request_test.go | 68 + .../internal/api/session_response.go | 113 + .../internal/api/session_template.go | 71 + .../agents-api/internal/api/session_tools.go | 53 + .../agents-api/internal/api/source_files.go | 95 + .../internal/api/source_files_content.go | 64 + .../internal/api/source_files_list.go | 56 + .../internal/api/source_files_list_test.go | 75 + .../internal/api/source_files_test.go | 243 + .../internal/api/source_files_upload.go | 109 + services/agents-api/internal/api/stream.go | 155 + .../agents-api/internal/api/stream_test.go | 137 + .../internal/api/text_configuration.go | 25 + .../internal/api/text_configuration_test.go | 55 + services/agents-api/internal/api/turns.go | 108 + .../agents-api/internal/api/turns_test.go | 91 + services/agents-api/internal/api/usage.go | 14 + .../internal/api/vault_pagination.go | 39 + services/agents-api/internal/api/vaults.go | 117 + .../agents-api/internal/api/vaults_delete.go | 43 + .../internal/api/vaults_delete_test.go | 78 + .../agents-api/internal/api/vaults_list.go | 42 + .../internal/api/vaults_list_test.go | 72 + .../agents-api/internal/api/vaults_test.go | 155 + .../internal/credentialcrypto/cipher.go | 111 + .../internal/credentialcrypto/cipher_test.go | 149 + .../credentialcrypto/environment_file.go | 46 + .../credentialcrypto/environment_file_test.go | 42 + .../credentialcrypto/environment_setup.go | 46 + .../environment_setup_test.go | 31 + .../credentialcrypto/model_execution.go | 29 + .../agents-api/internal/db/queries/agents.sql | 31 + .../internal/db/queries/devices.sql | 58 + .../db/queries/environment_connections.sql | 25 + .../environment_executor_credentials.sql | 52 + .../db/queries/environment_file_writes.sql | 25 + .../db/queries/environment_input_activity.sql | 15 + .../db/queries/environment_input_expiry.sql | 10 + .../db/queries/environment_inputs.sql | 42 + .../internal/db/queries/environment_setup.sql | 10 + .../db/queries/environment_templates.sql | 40 + .../internal/db/queries/environments.sql | 12 + .../internal/db/queries/functions.sql | 34 + .../db/queries/initial_environment_files.sql | 36 + .../db/queries/local_environment_devices.sql | 8 + .../internal/db/queries/mcp_credentials.sql | 26 + .../internal/db/queries/project_scopes.sql | 7 + .../db/queries/runtime_allocations.sql | 50 + .../internal/db/queries/scheduling.sql | 35 + .../internal/db/queries/session_artifacts.sql | 48 + .../internal/db/queries/session_events.sql | 38 + .../internal/db/queries/session_items.sql | 40 + .../db/queries/session_model_execution.sql | 6 + .../internal/db/queries/sessions.sql | 38 + .../internal/db/queries/source_files.sql | 24 + .../db/queries/subagent_identities.sql | 26 + .../internal/db/queries/token_usage.sql | 12 + .../internal/db/queries/turn_events.sql | 23 + .../internal/db/queries/turn_reads.sql | 12 + .../agents-api/internal/db/queries/turns.sql | 54 + .../internal/db/queries/vault_credentials.sql | 44 + .../agents-api/internal/db/queries/vaults.sql | 24 + .../agents-api/internal/db/sqlc/agents.sql.go | 193 + services/agents-api/internal/db/sqlc/db.go | 32 + .../internal/db/sqlc/devices.sql.go | 220 + .../db/sqlc/environment_connections.sql.go | 112 + .../environment_executor_credentials.sql.go | 197 + .../db/sqlc/environment_file_writes.sql.go | 132 + .../db/sqlc/environment_input_activity.sql.go | 54 + .../db/sqlc/environment_input_expiry.sql.go | 49 + .../db/sqlc/environment_inputs.sql.go | 206 + .../internal/db/sqlc/environment_setup.sql.go | 74 + .../db/sqlc/environment_templates.sql.go | 311 + .../internal/db/sqlc/environments.sql.go | 88 + .../internal/db/sqlc/functions.sql.go | 231 + .../db/sqlc/initial_environment_files.sql.go | 192 + .../db/sqlc/local_environment_devices.sql.go | 43 + .../internal/db/sqlc/mcp_credentials.sql.go | 138 + .../agents-api/internal/db/sqlc/models.go | 272 + .../internal/db/sqlc/project_scopes.sql.go | 34 + .../db/sqlc/runtime_allocations.sql.go | 296 + .../internal/db/sqlc/scheduling.sql.go | 180 + .../internal/db/sqlc/session_artifacts.sql.go | 217 + .../internal/db/sqlc/session_events.sql.go | 173 + .../internal/db/sqlc/session_items.sql.go | 218 + .../db/sqlc/session_model_execution.sql.go | 43 + .../internal/db/sqlc/sessions.sql.go | 236 + .../internal/db/sqlc/source_files.sql.go | 153 + .../db/sqlc/subagent_identities.sql.go | 103 + .../internal/db/sqlc/token_usage.sql.go | 45 + .../internal/db/sqlc/turn_events.sql.go | 156 + .../internal/db/sqlc/turn_reads.sql.go | 75 + .../agents-api/internal/db/sqlc/turns.sql.go | 320 + .../internal/db/sqlc/vault_credentials.sql.go | 242 + .../agents-api/internal/db/sqlc/vaults.sql.go | 141 + services/agents-api/internal/engine/claude.go | 57 + .../agents-api/internal/engine/claude_mcp.go | 30 + services/agents-api/internal/engine/codex.go | 21 + services/agents-api/internal/engine/mcode.go | 23 + .../agents-api/internal/engine/profile.go | 54 + .../internal/engine/profile_test.go | 39 + .../internal/execution/artifacts.go | 43 + .../agents-api/internal/execution/delivery.go | 326 + .../execution/directory_preparation.go | 114 + .../execution/directory_preparation_test.go | 26 + .../internal/execution/dispatcher.go | 114 + .../internal/execution/engine_profile.go | 58 + .../internal/execution/engine_profile_test.go | 83 + .../internal/execution/environment.go | 25 + .../execution/environment_admission.go | 137 + .../execution/environment_connections.go | 13 + .../execution/environment_directory.go | 138 + .../execution/environment_file_write.go | 123 + .../execution/environment_placement.go | 119 + .../execution/environment_placement_test.go | 74 + .../internal/execution/environment_test.go | 30 + .../agents-api/internal/execution/finish.go | 39 + .../internal/execution/functions.go | 180 + .../internal/execution/functions_test.go | 53 + .../internal/execution/input_text.go | 73 + .../agents-api/internal/execution/journal.go | 128 + .../internal/execution/journal_test.go | 161 + .../internal/execution/mcode_profile_test.go | 25 + services/agents-api/internal/execution/mcp.go | 52 + .../internal/execution/mcp_credentials.go | 75 + .../execution/mcp_credentials_test.go | 58 + .../internal/execution/mcp_support.go | 59 + .../internal/execution/mcp_support_test.go | 142 + .../agents-api/internal/execution/mcp_test.go | 34 + .../internal/execution/model_execution.go | 31 + .../execution/model_execution_test.go | 23 + .../agents-api/internal/execution/policy.go | 10 + .../internal/execution/preparation.go | 111 + .../internal/execution/prepared_dispatch.go | 105 + .../internal/execution/recovery_test.go | 30 + .../agents-api/internal/execution/request.go | 72 + .../internal/execution/runtime_connections.go | 59 + .../execution/runtime_initialization.go | 169 + .../runtime_initialization_real_test.go | 96 + .../internal/execution/runtime_lifecycle.go | 304 + .../internal/execution/runtime_pending.go | 41 + .../execution/runtime_provider_selection.go | 8 + .../runtime_provider_selection_test.go | 13 + .../internal/execution/runtime_setup.go | 89 + .../agents-api/internal/execution/support.go | 123 + .../agents-api/internal/execution/worker.go | 273 + .../internal/execution/worker_device.go | 89 + .../internal/execution/worker_schedule.go | 96 + .../internal/executor/codex/config.go | 58 + .../internal/executor/codex/credentials.go | 84 + .../executor/codex/credentials_test.go | 59 + .../internal/executor/codex/harness.go | 185 + .../executor/codex/harness_credentials.go | 104 + .../codex/harness_credentials_test.go | 179 + .../internal/executor/codex/harness_test.go | 248 + .../internal/executor/codex/lifecycle.go | 118 + .../internal/executor/codex/lifecycle_test.go | 353 + .../internal/executor/codex/messages.go | 86 + .../internal/executor/codex/registry.go | 188 + .../internal/executor/codex/registry_test.go | 295 + .../internal/executor/codex/socket.go | 150 + .../agents-api/internal/identity/principal.go | 60 + .../agents-api/internal/identity/subject.go | 18 + .../internal/items/command_output.go | 37 + .../internal/items/command_output_test.go | 70 + services/agents-api/internal/items/inputs.go | 44 + .../agents-api/internal/items/messages.go | 132 + .../internal/items/messages_test.go | 94 + services/agents-api/internal/items/tools.go | 100 + .../agents-api/internal/items/tools_test.go | 92 + .../agents-api/internal/runtime/gateway.go | 44 + .../internal/sandbox/docker/bootstrap.go | 62 + .../internal/sandbox/docker/command.go | 91 + .../internal/sandbox/docker/provider.go | 233 + .../internal/sandbox/docker/provider_test.go | 198 + .../internal/sandbox/docker/recovery_test.go | 138 + .../internal/sandbox/e2b/bootstrap.go | 117 + .../internal/sandbox/e2b/command.go | 103 + .../internal/sandbox/e2b/command_input.go | 39 + .../sandbox/e2b/command_input_test.go | 84 + .../internal/sandbox/e2b/control.go | 130 + .../internal/sandbox/e2b/envdprocess/LICENSE | 201 + .../sandbox/e2b/envdprocess/README.md | 18 + .../sandbox/e2b/envdprocess/process.pb.go | 1969 ++++ .../sandbox/e2b/envdprocess/process.proto | 171 + .../internal/sandbox/e2b/provider.go | 130 + .../sandbox/e2b/provider_real_test.go | 183 + .../agents-api/internal/sandbox/provider.go | 58 + services/agents-api/internal/store/agents.go | 91 + .../internal/store/agents_delete.go | 31 + .../store/agents_delete_public_test.go | 51 + .../agents-api/internal/store/agents_list.go | 51 + .../internal/store/agents_list_test.go | 98 + .../agents-api/internal/store/agents_test.go | 107 + .../internal/store/agents_update.go | 96 + .../store/agents_update_public_test.go | 51 + .../internal/store/agents_update_test.go | 63 + .../internal/store/artifact_capture.go | 135 + .../internal/store/artifact_lifecycle.go | 53 + .../internal/store/claude_execution_test.go | 173 + .../internal/store/claude_mcp_test.go | 133 + .../internal/store/command_output_test.go | 151 + .../store/credential_status_migration_test.go | 86 + services/agents-api/internal/store/devices.go | 186 + .../agents-api/internal/store/devices_test.go | 189 + .../internal/store/dispatch_test.go | 378 + .../store/environment_admission_test.go | 234 + .../store/environment_claim_worker_test.go | 81 + .../environment_connection_events_test.go | 88 + .../environment_connection_migration_test.go | 89 + .../store/environment_connection_recovery.go | 51 + .../environment_connection_recovery_test.go | 79 + .../environment_connection_worker_test.go | 94 + .../internal/store/environment_connections.go | 133 + .../store/environment_connections_test.go | 221 + .../internal/store/environment_device_test.go | 106 + .../environment_directory_active_test.go | 48 + .../environment_directory_native_test.go | 73 + .../store/environment_directory_test.go | 228 + .../environment_executor_command_test.go | 78 + .../store/environment_executor_credentials.go | 145 + .../environment_executor_credentials_test.go | 179 + .../store/environment_expiry_dispatch_test.go | 90 + .../store/environment_expiry_worker_test.go | 123 + .../environment_file_write_migration_test.go | 90 + .../internal/store/environment_file_writes.go | 197 + .../store/environment_file_writes_test.go | 212 + .../store/environment_files_native_test.go | 192 + .../store/environment_initial_input_test.go | 248 + .../environment_initial_migration_test.go | 86 + .../store/environment_initial_public_test.go | 111 + .../store/environment_input_activity.go | 85 + .../store/environment_input_activity_test.go | 236 + .../store/environment_input_claim_test.go | 94 + .../store/environment_input_expiry.go | 44 + .../store/environment_input_expiry_test.go | 153 + .../store/environment_input_migration_test.go | 125 + .../environment_input_settlement_test.go | 259 + .../internal/store/environment_inputs.go | 284 + .../internal/store/environment_inputs_test.go | 272 + .../store/environment_retrieve_public_test.go | 115 + .../internal/store/environment_setup.go | 158 + .../internal/store/environment_setup_test.go | 88 + .../internal/store/environment_skills.go | 59 + .../internal/store/environment_skills_test.go | 83 + .../store/environment_steering_order_test.go | 102 + .../internal/store/environment_templates.go | 207 + .../store/environment_templates_test.go | 88 + .../internal/store/environment_work_test.go | 82 + .../store/environment_worker_helpers_test.go | 115 + .../internal/store/environment_worker_test.go | 160 + .../agents-api/internal/store/environments.go | 91 + .../store/environments_migration_test.go | 131 + .../internal/store/environments_test.go | 279 + .../internal/store/execution_events_test.go | 104 + .../internal/store/execution_lease.go | 119 + .../store/execution_lease_cleanup_test.go | 128 + .../internal/store/execution_lease_test.go | 207 + .../internal/store/execution_messages_test.go | 83 + .../internal/store/execution_tools_test.go | 86 + .../store/executor_credential_target.go | 68 + .../store/executor_launcher_helpers_test.go | 174 + .../internal/store/executor_launcher_test.go | 157 + .../executor_principals_migration_test.go | 128 + .../store/executor_principals_test.go | 167 + .../executor_registration_public_test.go | 295 + .../agents-api/internal/store/export_test.go | 24 + .../internal/store/function_calls.go | 114 + .../internal/store/function_calls_test.go | 269 + .../store/function_execution_native_test.go | 85 + .../internal/store/function_execution_test.go | 208 + .../store/function_input_execution_test.go | 59 + .../internal/store/function_inputs.go | 64 + .../store/function_inputs_public_test.go | 134 + .../internal/store/function_inputs_test.go | 219 + .../store/function_item_events_test.go | 124 + .../internal/store/function_model_test.go | 121 + .../store/function_public_native_test.go | 96 + .../internal/store/function_results.go | 88 + .../internal/store/function_state.go | 68 + .../store/function_state_public_test.go | 91 + .../internal/store/function_state_test.go | 178 + .../store/function_stream_native_test.go | 57 + .../internal/store/function_worker_test.go | 154 + .../store/harness_authorization_test.go | 125 + .../internal/store/harness_onboarding_test.go | 277 + .../internal/store/initial_files.go | 221 + .../internal/store/initial_files_http_test.go | 71 + .../internal/store/initial_files_test.go | 96 + .../internal/store/input_batches_test.go | 188 + .../agents-api/internal/store/item_events.go | 86 + .../store/item_order_migration_test.go | 161 + .../internal/store/item_order_test.go | 147 + .../internal/store/item_projection.go | 122 + .../agents-api/internal/store/item_reads.go | 60 + .../internal/store/item_reads_test.go | 215 + .../agents-api/internal/store/json_object.go | 28 + .../store/local_artifact_export_test.go | 81 + .../store/local_environment_devices.go | 63 + .../store/local_environment_devices_test.go | 109 + .../local_environment_file_write_test.go | 127 + .../store/local_environment_worker_test.go | 161 + .../store/mcode_public_native_test.go | 202 + .../internal/store/mcp_credentials.go | 137 + .../internal/store/mcp_credentials_test.go | 124 + .../internal/store/native_daemon_test.go | 84 + .../native_daemon_workspace_directory_test.go | 61 + .../native_daemon_workspace_read_test.go | 62 + ...native_environment_adapter_helpers_test.go | 311 + .../store/native_environment_adapter_test.go | 295 + .../internal/store/native_environment_test.go | 145 + .../store/native_executor_directory_test.go | 108 + .../store/native_harness_artifact_test.go | 257 + .../store/native_harness_directory_test.go | 106 + .../store/native_harness_read_test.go | 133 + .../store/native_harness_write_test.go | 123 + .../internal/store/native_placement_test.go | 116 + .../store/native_preparation_helpers_test.go | 106 + .../store/native_public_execution_test.go | 58 + .../store/native_raw_files_cancel_test.go | 172 + .../internal/store/native_recovery_test.go | 37 + .../internal/store/native_relay_test.go | 248 + .../store/native_shared_files_test.go | 477 + .../native_workspace_preparation_test.go | 86 + .../internal/store/no_environment_test.go | 29 + .../store/prepared_dispatch_failure_test.go | 187 + .../store/prepared_dispatch_native_test.go | 275 + .../prepared_dispatch_public_helpers_test.go | 148 + .../internal/store/prepared_dispatch_test.go | 182 + .../internal/store/project_scopes.go | 41 + .../internal/store/project_scopes_test.go | 84 + .../internal/store/public_execution_test.go | 155 + .../store/public_harness_profile_test.go | 204 + .../store/remote_mcp_credentials_test.go | 88 + .../internal/store/remote_mcp_test.go | 201 + .../store/runtime_allocation_state.go | 128 + .../internal/store/runtime_allocations.go | 170 + .../store/runtime_allocations_test.go | 189 + .../internal/store/runtime_connection_test.go | 123 + .../store/runtime_environment_terminal.go | 26 + .../runtime_environment_terminal_test.go | 129 + .../internal/store/runtime_initialization.go | 36 + .../store/runtime_initialization_test.go | 156 + .../store/runtime_input_admission_test.go | 48 + .../internal/store/runtime_lifecycle_test.go | 243 + .../internal/store/runtime_pending_test.go | 91 + .../agents-api/internal/store/scheduling.go | 124 + .../store/self_hosted_cancel_public_test.go | 196 + .../self_hosted_functions_public_test.go | 192 + .../store/self_hosted_initial_public_test.go | 236 + .../self_hosted_public_cancel_native_test.go | 175 + .../store/self_hosted_public_fixture_test.go | 198 + ...elf_hosted_public_functions_native_test.go | 127 + .../store/self_hosted_public_helpers_test.go | 149 + .../store/self_hosted_public_native_test.go | 113 + ...self_hosted_public_steering_native_test.go | 196 + .../store/self_hosted_steering_public_test.go | 176 + .../store/session_agent_filter_public_test.go | 51 + .../store/session_agent_filter_test.go | 98 + .../internal/store/session_artifacts.go | 159 + .../internal/store/session_artifacts_test.go | 261 + .../store/session_configuration_test.go | 115 + .../store/session_creation_identity.go | 85 + .../store/session_creation_identity_test.go | 109 + .../internal/store/session_creation_stream.go | 19 + .../store/session_creation_stream_test.go | 125 + .../internal/store/session_creator.go | 19 + .../internal/store/session_creator_test.go | 161 + .../internal/store/session_deletion.go | 63 + .../store/session_deletion_execution_test.go | 93 + .../store/session_deletion_public_test.go | 51 + .../internal/store/session_deletion_test.go | 123 + .../store/session_environment_snapshot.go | 21 + .../session_environment_snapshot_test.go | 63 + .../internal/store/session_events.go | 135 + .../internal/store/session_events_test.go | 204 + .../internal/store/session_initial_input.go | 98 + .../store/session_initial_input_test.go | 129 + .../store/session_initial_public_test.go | 56 + .../internal/store/session_metadata.go | 49 + .../internal/store/session_metadata_test.go | 135 + .../internal/store/session_model_execution.go | 51 + .../session_model_execution_http_test.go | 65 + .../store/session_model_execution_test.go | 64 + .../session_reference_retry_public_test.go | 85 + .../internal/store/session_transaction.go | 57 + .../agents-api/internal/store/sessions.go | 271 + .../internal/store/sessions_test.go | 190 + .../internal/store/source_file_writer.go | 47 + .../agents-api/internal/store/source_files.go | 228 + .../internal/store/source_files_list_test.go | 128 + .../internal/store/source_files_test.go | 191 + .../internal/store/steering_receipts_test.go | 78 + .../internal/store/subagent_dispatch_test.go | 64 + .../internal/store/subagent_identities.go | 64 + .../store/subagent_identities_test.go | 162 + .../agents-api/internal/store/token_usage.go | 79 + .../store/token_usage_integration_test.go | 134 + .../internal/store/token_usage_test.go | 41 + .../internal/store/turn_completion.go | 77 + .../agents-api/internal/store/turn_events.go | 126 + .../internal/store/turn_events_test.go | 116 + .../agents-api/internal/store/turn_inputs.go | 217 + .../internal/store/turn_inputs_test.go | 215 + .../agents-api/internal/store/turn_reads.go | 48 + .../internal/store/turn_reads_test.go | 70 + services/agents-api/internal/store/turns.go | 160 + .../agents-api/internal/store/turns_test.go | 166 + .../internal/store/vault_credentials.go | 101 + .../store/vault_credentials_delete.go | 34 + .../store/vault_credentials_delete_test.go | 158 + .../internal/store/vault_credentials_list.go | 58 + .../store/vault_credentials_list_test.go | 146 + .../internal/store/vault_credentials_test.go | 145 + .../store/vault_credentials_update.go | 60 + .../store/vault_credentials_update_test.go | 189 + .../store/vault_status_migration_test.go | 83 + services/agents-api/internal/store/vaults.go | 96 + .../internal/store/vaults_delete.go | 30 + .../internal/store/vaults_delete_test.go | 205 + .../agents-api/internal/store/vaults_list.go | 59 + .../internal/store/vaults_list_test.go | 122 + .../agents-api/internal/store/vaults_test.go | 87 + .../internal/store/worker_lease_loss_test.go | 94 + .../agents-api/migrations/000001_sessions.sql | 15 + .../000002_session_configuration.sql | 6 + .../agents-api/migrations/000003_turns.sql | 35 + .../migrations/000004_input_batches.sql | 12 + .../agents-api/migrations/000005_devices.sql | 20 + .../migrations/000006_native_sessions.sql | 5 + .../migrations/000007_turn_events.sql | 19 + .../migrations/000008_session_items.sql | 26 + .../migrations/000009_execution_queue.sql | 6 + .../migrations/000010_token_usage.sql | 5 + .../migrations/000011_item_order.sql | 23 + .../migrations/000012_session_events.sql | 13 + .../migrations/000013_function_calls.sql | 21 + .../migrations/000014_function_inputs.sql | 13 + .../000015_retire_item_backfill.sql | 16 + .../agents-api/migrations/000016_agents.sql | 13 + .../000017_session_creation_identity.sql | 6 + .../000018_session_agent_filter.sql | 6 + .../migrations/000019_session_deletion.sql | 13 + .../migrations/000020_environments.sql | 20 + ...00021_environment_executor_credentials.sql | 10 + .../000022_environment_input_reservations.sql | 29 + .../000023_environment_input_expiry_index.sql | 6 + .../000024_execution_project_scopes.sql | 10 + .../migrations/000025_session_creators.sql | 15 + .../migrations/000026_executor_principals.sql | 43 + .../000027_environment_connections.sql | 18 + .../000028_environment_input_activity.sql | 6 + .../000029_environment_initial_input.sql | 15 + .../agents-api/migrations/000030_vaults.sql | 11 + .../migrations/000031_vault_credentials.sql | 14 + .../migrations/000032_vault_status.sql | 16 + .../migrations/000033_credential_status.sql | 17 + .../migrations/000034_subagent_identities.sql | 19 + .../000035_local_environment_devices.sql | 14 + .../000036_environment_file_writes.sql | 25 + .../migrations/000037_source_files.sql | 21 + .../migrations/000038_runtime_allocations.sql | 27 + .../000039_environment_input_failure.sql | 10 + .../migrations/000040_session_artifacts.sql | 28 + .../migrations/000041_source_files_list.sql | 5 + .../000042_environment_templates.sql | 13 + .../000043_environment_initial_files.sql | 20 + .../migrations/000044_environment_setup.sql | 13 + .../migrations/000045_environment_skills.sql | 7 + .../000046_session_model_execution.sql | 8 + services/agents-api/migrations/migrations.go | 29 + services/agents-api/sqlc.yaml | 12 + services/agents-api/tests/container_server.py | 27 + .../agents-api/tests/e2b_native_isolation.py | 65 + .../agents-api/tests/fixtures/credentials.go | 48 + services/agents-api/tests/fixtures/items.go | 22 + services/agents-api/tests/fixtures/main.go | 87 + services/agents-api/tests/fixtures/vaults.go | 45 + services/agents-api/tests/native/README.md | 309 + .../tests/native/directory/README.md | 35 + .../tests/native/directory/probe.rs | 190 + .../tests/native/environment_model_probe.py | 337 + .../tests/native/raw_files/.gitattributes | 1 + .../tests/native/raw_files/README.md | 116 + .../native/raw_files/client-dependency.patch | 20 + .../tests/native/raw_files/source.json | 63 + .../tests/native/raw_files_probe.rs | 16 + .../tests/native/raw_manager/.gitattributes | 1 + .../tests/native/raw_manager/README.md | 81 + .../tests/native/raw_manager/owner.rs | 159 + .../tests/native/raw_manager/prepare.py | 106 + .../tests/native/raw_manager/probe.rs | 246 + .../tests/native/raw_manager/source.json | 26 + .../agents-api/tests/native/relay_probe.rs | 294 + .../tests/native/retirement/README.md | 168 + .../tests/native/retirement/gate.rs | 88 + .../native/retirement/operator_retirement.py | 78 + .../tests/native/retirement/placement.py | 206 + .../tests/native/retirement/placement_test.rs | 59 + .../tests/native/retirement/processor_test.rs | 208 + .../native/retirement/qualification.patch | 55 + .../tests/native/retirement/source.json | 78 + .../tests/native/shared_files/cancellation.rs | 394 + .../native/shared_files/configuration.rs | 100 + .../tests/native/shared_files/files.rs | 198 + .../tests/native/shared_files/observations.rs | 315 + .../tests/native/shared_files/probe.rs | 324 + .../tests/native/shared_files/raw_runtime.rs | 294 + .../tests/native/shared_files/runtime.rs | 182 + .../tests/native/shared_files_probe.rs | 16 + .../agents-api/tests/native/write/README.md | 31 + .../agents-api/tests/native/write/probe.py | 115 + .../agents-api/tests/official_agent_delete.py | 76 + .../agents-api/tests/official_agent_list.py | 52 + .../tests/official_agent_reference_retry.py | 92 + .../tests/official_agent_references.py | 97 + .../agents-api/tests/official_agent_update.py | 102 + services/agents-api/tests/official_agents.py | 117 + services/agents-api/tests/official_auth.py | 55 + services/agents-api/tests/official_client.py | 322 + .../tests/official_credential_delete.py | 77 + .../tests/official_credential_list.py | 184 + .../tests/official_credential_rotation.py | 123 + .../agents-api/tests/official_credentials.py | 161 + services/agents-api/tests/official_e2b_v1.py | 532 + .../tests/official_environment_activity.py | 252 + .../tests/official_environment_events.py | 44 + .../tests/official_environment_files.py | 95 + .../official_environment_files_create.py | 84 + .../official_environment_files_native.py | 119 + .../official_environment_initial_failure.py | 88 + .../official_environment_initial_files.py | 48 + .../tests/official_environment_retrieve.py | 107 + .../tests/official_environment_setup.py | 181 + .../tests/official_environment_skills.py | 66 + .../tests/official_environment_templates.py | 106 + .../agents-api/tests/official_execution.py | 175 + .../tests/official_function_inputs.py | 49 + .../tests/official_function_state.py | 66 + .../tests/official_function_stream.py | 78 + .../agents-api/tests/official_functions.py | 81 + .../tests/official_hosted_functions_native.py | 109 + services/agents-api/tests/official_items.py | 40 + .../agents-api/tests/official_mcode_native.py | 95 + services/agents-api/tests/official_mcp.py | 67 + .../tests/official_mcp_credentials.py | 167 + .../agents-api/tests/official_self_hosted.py | 369 + .../tests/official_self_hosted_cancel.py | 134 + .../official_self_hosted_cancel_native.py | 256 + .../tests/official_self_hosted_creation.py | 40 + .../tests/official_self_hosted_functions.py | 170 + .../official_self_hosted_functions_native.py | 318 + .../tests/official_self_hosted_initial.py | 194 + .../tests/official_self_hosted_steering.py | 144 + .../official_self_hosted_steering_native.py | 275 + .../tests/official_session_agent_filter.py | 73 + .../tests/official_session_artifacts.py | 75 + .../tests/official_session_creation_stream.py | 91 + .../tests/official_session_creators.py | 128 + .../tests/official_session_delete.py | 90 + .../tests/official_session_initial_input.py | 66 + .../tests/official_session_metadata.py | 92 + .../tests/official_session_requests.py | 50 + .../tests/official_source_file_list.py | 97 + .../agents-api/tests/official_source_files.py | 80 + .../agents-api/tests/official_vault_delete.py | 104 + .../agents-api/tests/official_vault_list.py | 143 + services/agents-api/tests/official_vaults.py | 121 + services/agents-api/tests/requirements.txt | 2 + 1300 files changed, 169110 insertions(+) create mode 100644 .github/workflows/actionlint.yml create mode 100644 .github/workflows/agents-api.yml create mode 100644 .github/workflows/agents-executor.yml create mode 100644 .github/workflows/agents-harness.yml create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 apps/parsar-daemon/.gitignore create mode 100644 apps/parsar-daemon/cmd/parsar-daemon/main.go create mode 100644 apps/parsar-daemon/internal/agent/binpath/binpath.go create mode 100644 apps/parsar-daemon/internal/agent/binpath/binpath_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/ask.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/ask_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/export_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/options.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/parser.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/parser_result.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/parser_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/permission.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/permission_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/plugins.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/plugins_install.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/plugins_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/session.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/session_export_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/skills.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/version.go create mode 100644 apps/parsar-daemon/internal/agent/claudecode/version_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/cancellation.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/commands.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/commands_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/functions.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/functions_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/local.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/local_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/mcp.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/messages_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/options.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/preparation.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/provider.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/readiness.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/session.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/steering.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/steering_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/usage.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/usage_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_other.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/approval_policy.go create mode 100644 apps/parsar-daemon/internal/agent/codex/approval_policy_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_local.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_remote.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_remote_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/environment_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/execution_controls.go create mode 100644 apps/parsar-daemon/internal/agent/codex/execution_controls_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/export_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/functions.go create mode 100644 apps/parsar-daemon/internal/agent/codex/functions_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/generation_config.go create mode 100644 apps/parsar-daemon/internal/agent/codex/hosted_skills.go create mode 100644 apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_config.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_config_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http_bearer.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_http_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/mcp_required_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go create mode 100644 apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go create mode 100644 apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/model_verbosity.go create mode 100644 apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/options.go create mode 100644 apps/parsar-daemon/internal/agent/codex/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/permission_profile.go create mode 100644 apps/parsar-daemon/internal/agent/codex/permission_profile_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/preparation.go create mode 100644 apps/parsar-daemon/internal/agent/codex/preparation_close_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/preparation_router_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/prepared.go create mode 100644 apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/prepared_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/private_harness.go create mode 100644 apps/parsar-daemon/internal/agent/codex/private_harness_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/protocol.go create mode 100644 apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/provider_config.go create mode 100644 apps/parsar-daemon/internal/agent/codex/provider_config_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/recovery.go create mode 100644 apps/parsar-daemon/internal/agent/codex/recovery_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/resume.go create mode 100644 apps/parsar-daemon/internal/agent/codex/resume_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_close.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_close_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_process_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_request.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_write.go create mode 100644 apps/parsar-daemon/internal/agent/codex/server_requests.go create mode 100644 apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go create mode 100644 apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/server_requests_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_cancel.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_cancel_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_command_output.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_command_output_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_items.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_items_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_log_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_messages.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_messages_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_notifications.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_notifications_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_output.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_plan.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_policy_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_run.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_steering.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_steering_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_thread.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_tools.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_tools_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_usage.go create mode 100644 apps/parsar-daemon/internal/agent/codex/session_usage_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/skills.go create mode 100644 apps/parsar-daemon/internal/agent/codex/skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/subagent_metadata.go create mode 100644 apps/parsar-daemon/internal/agent/codex/subagent_observations.go create mode 100644 apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/subagents.go create mode 100644 apps/parsar-daemon/internal/agent/codex/subagents_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment.go create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_observations.go create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_observations_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/verbosity_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/version.go create mode 100644 apps/parsar-daemon/internal/agent/codex/web_search_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_directory.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_preparation.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_read.go create mode 100644 apps/parsar-daemon/internal/agent/codex/workspace_read_test.go create mode 100644 apps/parsar-daemon/internal/agent/functions.go create mode 100644 apps/parsar-daemon/internal/agent/installroot/lock.go create mode 100644 apps/parsar-daemon/internal/agent/installroot/lock_test.go create mode 100644 apps/parsar-daemon/internal/agent/interactions.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/events.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/execution.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/execution_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/native_history_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/native_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/options.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/preparation.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/preparation_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/protocol.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/questions.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/questions_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/session.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/steering.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/steering_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/tool_observations.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/version.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/version_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/workspace.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go create mode 100644 apps/parsar-daemon/internal/agent/mcp.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/export_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/options.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/parser.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/parser_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/session.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/session_model_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/skills.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/version.go create mode 100644 apps/parsar-daemon/internal/agent/opencode/version_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/export_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/options.go create mode 100644 apps/parsar-daemon/internal/agent/pi/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/parser.go create mode 100644 apps/parsar-daemon/internal/agent/pi/parser_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/provider_config.go create mode 100644 apps/parsar-daemon/internal/agent/pi/provider_config_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/session.go create mode 100644 apps/parsar-daemon/internal/agent/pi/session_provider_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/session_skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/skills.go create mode 100644 apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/skills_install.go create mode 100644 apps/parsar-daemon/internal/agent/pi/skills_test.go create mode 100644 apps/parsar-daemon/internal/agent/pi/version.go create mode 100644 apps/parsar-daemon/internal/agent/pi/version_test.go create mode 100644 apps/parsar-daemon/internal/agent/preparation.go create mode 100644 apps/parsar-daemon/internal/agent/registry.go create mode 100644 apps/parsar-daemon/internal/agent/registry_test.go create mode 100644 apps/parsar-daemon/internal/agent/runtime_paths.go create mode 100644 apps/parsar-daemon/internal/agent/runtime_paths_test.go create mode 100644 apps/parsar-daemon/internal/agent/steering.go create mode 100644 apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go create mode 100644 apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go create mode 100644 apps/parsar-daemon/internal/agent/workspace_directory.go create mode 100644 apps/parsar-daemon/internal/agent/workspace_read.go create mode 100644 apps/parsar-daemon/internal/agent/workspace_write.go create mode 100644 apps/parsar-daemon/internal/auth/store.go create mode 100644 apps/parsar-daemon/internal/auth/store_test.go create mode 100644 apps/parsar-daemon/internal/authoring/bridge.go create mode 100644 apps/parsar-daemon/internal/authoring/bridge_test.go create mode 100644 apps/parsar-daemon/internal/cli/agent_discovery.go create mode 100644 apps/parsar-daemon/internal/cli/agent_registration.go create mode 100644 apps/parsar-daemon/internal/cli/authoring.go create mode 100644 apps/parsar-daemon/internal/cli/authoring_test.go create mode 100644 apps/parsar-daemon/internal/cli/capability_downloads.go create mode 100644 apps/parsar-daemon/internal/cli/capability_downloads_test.go create mode 100644 apps/parsar-daemon/internal/cli/claude_sdk.go create mode 100644 apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/cli/claude_sdk_test.go create mode 100644 apps/parsar-daemon/internal/cli/companion_path_test.go create mode 100644 apps/parsar-daemon/internal/cli/connect.go create mode 100644 apps/parsar-daemon/internal/cli/connect_test.go create mode 100644 apps/parsar-daemon/internal/cli/logout.go create mode 100644 apps/parsar-daemon/internal/cli/logs.go create mode 100644 apps/parsar-daemon/internal/cli/mcode.go create mode 100644 apps/parsar-daemon/internal/cli/mcode_execution_test.go create mode 100644 apps/parsar-daemon/internal/cli/mcode_workspace.go create mode 100644 apps/parsar-daemon/internal/cli/mcp_test.go create mode 100644 apps/parsar-daemon/internal/cli/pair.go create mode 100644 apps/parsar-daemon/internal/cli/pair_test.go create mode 100644 apps/parsar-daemon/internal/cli/placement.go create mode 100644 apps/parsar-daemon/internal/cli/preparation_test.go create mode 100644 apps/parsar-daemon/internal/cli/root.go create mode 100644 apps/parsar-daemon/internal/cli/root_test.go create mode 100644 apps/parsar-daemon/internal/cli/skill_upload.go create mode 100644 apps/parsar-daemon/internal/cli/skill_upload_test.go create mode 100644 apps/parsar-daemon/internal/cli/status.go create mode 100644 apps/parsar-daemon/internal/cli/stop.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork_test.go create mode 100644 apps/parsar-daemon/internal/daemonize/helpers_test.go create mode 100644 apps/parsar-daemon/internal/daemonize/logfile.go create mode 100644 apps/parsar-daemon/internal/daemonize/logfile_test.go create mode 100644 apps/parsar-daemon/internal/daemonize/pidfile.go create mode 100644 apps/parsar-daemon/internal/daemonize/pidfile_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/cancellation.go create mode 100644 apps/parsar-daemon/internal/dispatch/cancellation_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/capabilities.go create mode 100644 apps/parsar-daemon/internal/dispatch/environment.go create mode 100644 apps/parsar-daemon/internal/dispatch/environment_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/export_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/functions.go create mode 100644 apps/parsar-daemon/internal/dispatch/functions_native_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/functions_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/interaction_decisions.go create mode 100644 apps/parsar-daemon/internal/dispatch/local_directory.go create mode 100644 apps/parsar-daemon/internal/dispatch/local_directory_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/mcp_http.go create mode 100644 apps/parsar-daemon/internal/dispatch/mcp_http_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/optional_interactions_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/output.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_cancel.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_cleanup.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_start.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/prepared_handoff.go create mode 100644 apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/prompt.go create mode 100644 apps/parsar-daemon/internal/dispatch/receipt_order_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/router.go create mode 100644 apps/parsar-daemon/internal/dispatch/router_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/shutdown.go create mode 100644 apps/parsar-daemon/internal/dispatch/steering.go create mode 100644 apps/parsar-daemon/internal/dispatch/steering_lifetime.go create mode 100644 apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/steering_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_directory_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_export.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_export_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_read.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_read_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_write.go create mode 100644 apps/parsar-daemon/internal/dispatch/workspace_write_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/binding.go create mode 100644 apps/parsar-daemon/internal/localworkspace/binding_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/directory.go create mode 100644 apps/parsar-daemon/internal/localworkspace/directory_native_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/export.go create mode 100644 apps/parsar-daemon/internal/localworkspace/initialization.go create mode 100644 apps/parsar-daemon/internal/localworkspace/network_policy_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/skills.go create mode 100644 apps/parsar-daemon/internal/localworkspace/write.go create mode 100644 apps/parsar-daemon/internal/localworkspace/write_binding.go create mode 100644 apps/parsar-daemon/internal/localworkspace/write_test.go create mode 100644 apps/parsar-daemon/internal/paths/paths.go create mode 100644 apps/parsar-daemon/internal/paths/paths_test.go create mode 100644 apps/parsar-daemon/internal/transport/bootstrap.go create mode 100644 apps/parsar-daemon/internal/transport/bootstrap_test.go create mode 100644 apps/parsar-daemon/internal/transport/reconnect.go create mode 100644 apps/parsar-daemon/internal/transport/reconnect_test.go create mode 100644 apps/parsar-daemon/internal/transport/ws.go create mode 100644 apps/parsar-daemon/internal/transport/ws_test.go create mode 100644 apps/parsar-daemon/testdata/onboarding/main.go create mode 100644 contracts/agents-api/README.md create mode 100644 contracts/agents-api/environment-files.md create mode 100644 contracts/agents-api/environment-templates.md create mode 100644 contracts/agents-api/environments.md create mode 100644 contracts/agents-api/harness-onboarding.md create mode 100644 contracts/agents-api/harness-selection.md create mode 100644 contracts/agents-api/harnesses.md create mode 100644 contracts/agents-api/mcode-workspace-v1.md create mode 100644 contracts/agents-api/model-execution.md create mode 100644 contracts/agents-api/openapi.yaml create mode 100644 contracts/agents-api/source-files.md create mode 100644 contracts/agents-api/upstream.json create mode 100644 contracts/agents-api/v1/agents.go create mode 100644 contracts/agents-api/v1/core_extension.go create mode 100644 contracts/agents-api/v1/credentials.go create mode 100644 contracts/agents-api/v1/environment_events.go create mode 100644 contracts/agents-api/v1/environment_files.go create mode 100644 contracts/agents-api/v1/environment_templates.go create mode 100644 contracts/agents-api/v1/environments.go create mode 100644 contracts/agents-api/v1/events.go create mode 100644 contracts/agents-api/v1/function_actions.go create mode 100644 contracts/agents-api/v1/function_tools.go create mode 100644 contracts/agents-api/v1/inputs.go create mode 100644 contracts/agents-api/v1/items.go create mode 100644 contracts/agents-api/v1/mcp_tools.go create mode 100644 contracts/agents-api/v1/model_execution.go create mode 100644 contracts/agents-api/v1/model_execution_test.go create mode 100644 contracts/agents-api/v1/required_actions.go create mode 100644 contracts/agents-api/v1/required_actions_test.go create mode 100644 contracts/agents-api/v1/session_artifacts.go create mode 100644 contracts/agents-api/v1/session_deletion.go create mode 100644 contracts/agents-api/v1/session_environment.go create mode 100644 contracts/agents-api/v1/sessions.go create mode 100644 contracts/agents-api/v1/source_files.go create mode 100644 contracts/agents-api/v1/turns.go create mode 100644 contracts/agents-api/v1/usage.go create mode 100644 contracts/agents-api/v1/vaults.go create mode 100644 contracts/agents-api/workspace-placement.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 go.work create mode 100644 internal/agentdaemon/device/credential.go create mode 100644 internal/agentdaemon/device/state.go create mode 100644 internal/agentdaemon/gateway/auth.go create mode 100644 internal/agentdaemon/gateway/auth_test.go create mode 100644 internal/agentdaemon/gateway/functions_test.go create mode 100644 internal/agentdaemon/gateway/handler.go create mode 100644 internal/agentdaemon/gateway/mcp_bearer_fixture_linux_test.go create mode 100644 internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go create mode 100644 internal/agentdaemon/gateway/mcp_bearer_process_linux_test.go create mode 100644 internal/agentdaemon/gateway/mcp_test.go create mode 100644 internal/agentdaemon/gateway/owner.go create mode 100644 internal/agentdaemon/gateway/owner_test.go create mode 100644 internal/agentdaemon/gateway/preparation.go create mode 100644 internal/agentdaemon/gateway/preparation_test.go create mode 100644 internal/agentdaemon/gateway/registry.go create mode 100644 internal/agentdaemon/gateway/registry_test.go create mode 100644 internal/agentdaemon/gateway/routes.go create mode 100644 internal/agentdaemon/gateway/session.go create mode 100644 internal/agentdaemon/gateway/session_test.go create mode 100644 internal/agentdaemon/gateway/subscription.go create mode 100644 internal/agentdaemon/gateway/subscription_test.go create mode 100644 internal/agentdaemon/gateway/workspace_directory_test.go create mode 100644 internal/agentdaemon/gateway/workspace_export.go create mode 100644 internal/agentdaemon/gateway/workspace_export_test.go create mode 100644 internal/agentdaemon/gateway/workspace_read.go create mode 100644 internal/agentdaemon/gateway/workspace_read_test.go create mode 100644 internal/agentdaemon/gateway/workspace_write.go create mode 100644 internal/agentdaemon/gateway/workspace_write_test.go create mode 100644 internal/agentdaemon/placement/controller_linux.go create mode 100644 internal/agentdaemon/placement/controller_linux_test.go create mode 100644 internal/agentdaemon/placement/controller_other.go create mode 100644 internal/agentdaemon/placement/docker_linux.go create mode 100644 internal/agentdaemon/placement/environment.go create mode 100644 internal/agentdaemon/placement/environment_linux_test.go create mode 100644 internal/agentdaemon/placement/mounts_linux.go create mode 100644 internal/agentdaemon/placement/mounts_linux_test.go create mode 100644 internal/agentdaemon/placement/observe_linux.go create mode 100644 internal/agentdaemon/placement/state_linux.go create mode 100644 internal/agentdaemon/placement/types.go create mode 100644 internal/agentdaemon/proto/authoring.go create mode 100644 internal/agentdaemon/proto/command_output.go create mode 100644 internal/agentdaemon/proto/envelope.go create mode 100644 internal/agentdaemon/proto/envelope_test.go create mode 100644 internal/agentdaemon/proto/environment.go create mode 100644 internal/agentdaemon/proto/functions.go create mode 100644 internal/agentdaemon/proto/functions_test.go create mode 100644 internal/agentdaemon/proto/inbound.go create mode 100644 internal/agentdaemon/proto/mcp.go create mode 100644 internal/agentdaemon/proto/mcp_test.go create mode 100644 internal/agentdaemon/proto/outbound.go create mode 100644 internal/agentdaemon/proto/preparation.go create mode 100644 internal/agentdaemon/proto/steering.go create mode 100644 internal/agentdaemon/proto/subagents.go create mode 100644 internal/agentdaemon/proto/token_usage.go create mode 100644 internal/agentdaemon/proto/token_usage_test.go create mode 100644 internal/agentdaemon/proto/tool_observations.go create mode 100644 internal/agentdaemon/proto/version.go create mode 100644 internal/agentdaemon/proto/workspace_directory.go create mode 100644 internal/agentdaemon/proto/workspace_export.go create mode 100644 internal/agentdaemon/proto/workspace_read.go create mode 100644 internal/agentdaemon/proto/workspace_read_preparation.go create mode 100644 internal/agentdaemon/proto/workspace_write.go create mode 100644 internal/agentskill/bundle.go create mode 100644 internal/agentskill/bundle_test.go create mode 100644 internal/obs/log/api.go create mode 100644 internal/obs/log/api_test.go create mode 100644 internal/obs/log/background.go create mode 100644 internal/obs/log/carrier.go create mode 100644 internal/obs/log/carrier_test.go create mode 100644 internal/obs/log/context.go create mode 100644 internal/obs/log/discard.go create mode 100644 internal/obs/log/handler.go create mode 100644 internal/obs/log/handler_test.go create mode 100644 internal/obs/log/http.go create mode 100644 internal/obs/log/http_test.go create mode 100644 internal/obs/log/init.go create mode 100644 internal/obs/log/trace.go create mode 100644 internal/runtimecrypto/cmd/emit-fixture/main.go create mode 100644 internal/runtimecrypto/runtime_seal.go create mode 100644 internal/runtimecrypto/runtime_seal_test.go create mode 100644 internal/runtimecrypto/runtime_seal_wire_test.go create mode 100644 internal/runtimecrypto/testdata/wire_v1.json create mode 100644 package.json create mode 100644 packages/agents-client/README.md create mode 100644 packages/agents-client/v1/client.go create mode 100644 packages/agents-client/v1/client_test.go create mode 100644 packages/agents-client/v1/service_test.go create mode 100644 packages/claude-sdk-adapter/package.json create mode 100644 packages/claude-sdk-adapter/src/adapter.ts create mode 100644 packages/claude-sdk-adapter/src/command_observer.ts create mode 100644 packages/claude-sdk-adapter/src/function_bridge.ts create mode 100644 packages/claude-sdk-adapter/src/functions.ts create mode 100644 packages/claude-sdk-adapter/src/inputs.ts create mode 100644 packages/claude-sdk-adapter/src/main.ts create mode 100644 packages/claude-sdk-adapter/src/mcp.ts create mode 100644 packages/claude-sdk-adapter/src/mcp_observer.ts create mode 100644 packages/claude-sdk-adapter/src/messages.ts create mode 100644 packages/claude-sdk-adapter/src/native.ts create mode 100644 packages/claude-sdk-adapter/src/recovery.ts create mode 100644 packages/claude-sdk-adapter/src/request.ts create mode 100644 packages/claude-sdk-adapter/src/runtime_check.ts create mode 100644 packages/claude-sdk-adapter/src/usage.ts create mode 100644 packages/claude-sdk-adapter/src/workspace.ts create mode 100644 packages/claude-sdk-adapter/src/workspace_directories.ts create mode 100644 packages/claude-sdk-adapter/src/workspace_reads.ts create mode 100644 packages/claude-sdk-adapter/src/workspace_skills.ts create mode 100644 packages/claude-sdk-adapter/tests/command_observer.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/execution.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/function_bridge.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/functions.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/inputs.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/mcp.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/mcp_required.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/messages.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/native.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/preparation.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/recovery.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/usage.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/workspace.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/workspace_directories.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/workspace_execution.test.mjs create mode 100644 packages/claude-sdk-adapter/tests/workspace_reads.test.mjs create mode 100644 packages/claude-sdk-adapter/tsconfig.json create mode 100644 packages/codex-executor/Cargo.lock create mode 100644 packages/codex-executor/Cargo.toml create mode 100644 packages/codex-executor/README.md create mode 100644 packages/codex-executor/rust-toolchain.toml create mode 100644 packages/codex-executor/src/bin/directory.rs create mode 100644 packages/codex-executor/src/bin/export.rs create mode 100644 packages/codex-executor/src/bin/write.rs create mode 100644 packages/codex-executor/src/directory.rs create mode 100644 packages/codex-executor/src/directory_tests.rs create mode 100644 packages/codex-executor/src/export.rs create mode 100644 packages/codex-executor/src/export_tests.rs create mode 100644 packages/codex-executor/src/main.rs create mode 100644 packages/codex-executor/src/options.rs create mode 100644 packages/codex-executor/src/options_tests.rs create mode 100644 packages/codex-executor/src/runtime.rs create mode 100644 packages/codex-executor/src/workspace_path.rs create mode 100644 packages/codex-executor/src/write_file.rs create mode 100644 packages/codex-executor/src/write_file_tests.rs create mode 100644 packages/codex-harness/README.md create mode 100644 packages/codex-harness/patches/artifact-target.patch create mode 100644 packages/codex-harness/patches/bounded-read.patch create mode 100644 packages/codex-harness/patches/manager-exposure.patch create mode 100644 packages/codex-harness/prepare.py create mode 100644 packages/codex-harness/prepare_test.py create mode 100644 packages/codex-harness/source.json create mode 100644 packages/codex-harness/src/files.rs create mode 100644 packages/codex-harness/src/files_directory.rs create mode 100644 packages/codex-harness/src/files_directory_output.rs create mode 100644 packages/codex-harness/src/files_directory_output_tests.rs create mode 100644 packages/codex-harness/src/files_directory_tests.rs create mode 100644 packages/codex-harness/src/files_process_output.rs create mode 100644 packages/codex-harness/src/files_read_tests.rs create mode 100644 packages/codex-harness/src/files_tests.rs create mode 100644 packages/codex-harness/src/files_write.rs create mode 100644 packages/codex-harness/src/files_write_process_tests.rs create mode 100644 packages/codex-harness/src/files_write_tests.rs create mode 100644 packages/codex-harness/src/main.rs create mode 100644 packages/codex-harness/src/options.rs create mode 100644 packages/codex-harness/src/options_write.rs create mode 100644 packages/codex-harness/src/owner.rs create mode 100644 packages/codex-harness/src/read_profile.rs create mode 100644 packages/mcode-harness/README.md create mode 100644 packages/mcode-harness/bridge.mjs create mode 100644 packages/mcode-harness/build-sandbox.mjs create mode 100644 packages/mcode-harness/build.mjs create mode 100644 packages/mcode-harness/check.mjs create mode 100644 packages/mcode-harness/launch.mjs create mode 100644 packages/mcode-harness/native-pi-tools.ts create mode 100644 packages/mcode-harness/native.test.mjs create mode 100644 packages/mcode-harness/package-lock.json create mode 100644 packages/mcode-harness/package.json create mode 100644 packages/mcode-harness/sandbox-entry.ts create mode 100644 packages/mcode-harness/source.json create mode 100644 packages/mcode-harness/tool-executor.mjs create mode 100644 packages/mcode-harness/tool-executor.test.mjs create mode 100644 packages/mcode-harness/worker.ts create mode 100644 packages/tsconfig/base.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 provenance/README.md create mode 100644 provenance/source.json create mode 100644 provenance/verification.md create mode 100755 scripts/build-agents-api-image.sh create mode 100755 scripts/build-agents-api-release.sh create mode 100755 scripts/build-agents-api.sh create mode 100755 scripts/build-agents-executor.sh create mode 100755 scripts/build-agents-harness.sh create mode 100755 scripts/build-agents-runtime.sh create mode 100755 scripts/build-claude-runtime.sh create mode 100755 scripts/build-claude-sdk-runtime.sh create mode 100644 scripts/build-mcode-harness.sh create mode 100644 scripts/build-mcode-runtime.sh create mode 100755 scripts/check-agents-executor.sh create mode 100755 scripts/check-agents-harness.sh create mode 100644 scripts/check-claude-sdk-runtime.mjs create mode 100644 scripts/check-sqlc.py create mode 100644 scripts/verify-source-copy.py create mode 100644 services/agents-api/CONTAINER.md create mode 100644 services/agents-api/Dockerfile create mode 100644 services/agents-api/HOSTED-RELEASE.md create mode 100644 services/agents-api/README.md create mode 100644 services/agents-api/RELEASE.md create mode 100644 services/agents-api/cmd/device/main.go create mode 100644 services/agents-api/cmd/environment-key/main.go create mode 100644 services/agents-api/cmd/environment-key/main_test.go create mode 100644 services/agents-api/cmd/migrate/main.go create mode 100644 services/agents-api/cmd/server/credential_cipher.go create mode 100644 services/agents-api/cmd/server/credential_cipher_test.go create mode 100644 services/agents-api/cmd/server/environment_connection_test.go create mode 100644 services/agents-api/cmd/server/execution_options.go create mode 100644 services/agents-api/cmd/server/execution_options_test.go create mode 100644 services/agents-api/cmd/server/executor.go create mode 100644 services/agents-api/cmd/server/executor_test.go create mode 100644 services/agents-api/cmd/server/main.go create mode 100644 services/agents-api/cmd/server/managed_runtimes.go create mode 100644 services/agents-api/cmd/server/managed_runtimes_test.go create mode 100644 services/agents-api/credentials.md create mode 100644 services/agents-api/deploy/claude/Dockerfile create mode 100644 services/agents-api/deploy/claude/README.md create mode 100644 services/agents-api/deploy/codex/Dockerfile create mode 100644 services/agents-api/deploy/codex/README.md create mode 100644 services/agents-api/deploy/codex/requirements.toml create mode 100644 services/agents-api/deploy/codex/seccomp.LICENSE create mode 100644 services/agents-api/deploy/codex/seccomp.json create mode 100644 services/agents-api/deploy/codex/tool-env.py create mode 100644 services/agents-api/deploy/e2b/README.md create mode 100644 services/agents-api/deploy/e2b/build-template.py create mode 100644 services/agents-api/deploy/e2b/init.py create mode 100644 services/agents-api/deploy/e2b/requirements.txt create mode 100644 services/agents-api/deploy/mcode/Dockerfile create mode 100644 services/agents-api/deploy/mcode/README.md create mode 100644 services/agents-api/deploy/runtime/build-system-seed.py create mode 100644 services/agents-api/deploy/runtime/initialize.py create mode 100644 services/agents-api/deploy/runtime/initialize_test.py create mode 100644 services/agents-api/deploy/runtime/tool-root.py create mode 100644 services/agents-api/internal/api/agents.go create mode 100644 services/agents-api/internal/api/agents_delete.go create mode 100644 services/agents-api/internal/api/agents_list.go create mode 100644 services/agents-api/internal/api/agents_update.go create mode 100644 services/agents-api/internal/api/auth.go create mode 100644 services/agents-api/internal/api/auth_test.go create mode 100644 services/agents-api/internal/api/claude_admission_test.go create mode 100644 services/agents-api/internal/api/claude_mcp_test.go create mode 100644 services/agents-api/internal/api/configuration.go create mode 100644 services/agents-api/internal/api/credentials.go create mode 100644 services/agents-api/internal/api/credentials_delete.go create mode 100644 services/agents-api/internal/api/credentials_delete_test.go create mode 100644 services/agents-api/internal/api/credentials_list.go create mode 100644 services/agents-api/internal/api/credentials_list_test.go create mode 100644 services/agents-api/internal/api/credentials_test.go create mode 100644 services/agents-api/internal/api/credentials_update.go create mode 100644 services/agents-api/internal/api/credentials_update_test.go create mode 100644 services/agents-api/internal/api/environment_creation_test.go create mode 100644 services/agents-api/internal/api/environment_files.go create mode 100644 services/agents-api/internal/api/environment_files_completeness_test.go create mode 100644 services/agents-api/internal/api/environment_files_create.go create mode 100644 services/agents-api/internal/api/environment_files_create_test.go create mode 100644 services/agents-api/internal/api/environment_files_cursor.go create mode 100644 services/agents-api/internal/api/environment_files_deadline_test.go create mode 100644 services/agents-api/internal/api/environment_files_query.go create mode 100644 services/agents-api/internal/api/environment_files_test.go create mode 100644 services/agents-api/internal/api/environment_input.go create mode 100644 services/agents-api/internal/api/environment_input_test.go create mode 100644 services/agents-api/internal/api/environment_request.go create mode 100644 services/agents-api/internal/api/environment_setup.go create mode 100644 services/agents-api/internal/api/environment_setup_test.go create mode 100644 services/agents-api/internal/api/environment_skills.go create mode 100644 services/agents-api/internal/api/environment_skills_test.go create mode 100644 services/agents-api/internal/api/environment_templates.go create mode 100644 services/agents-api/internal/api/environment_templates_test.go create mode 100644 services/agents-api/internal/api/environments.go create mode 100644 services/agents-api/internal/api/environments_test.go create mode 100644 services/agents-api/internal/api/errors.go create mode 100644 services/agents-api/internal/api/execution_policy.go create mode 100644 services/agents-api/internal/api/function_configuration.go create mode 100644 services/agents-api/internal/api/function_configuration_test.go create mode 100644 services/agents-api/internal/api/function_inputs.go create mode 100644 services/agents-api/internal/api/function_inputs_test.go create mode 100644 services/agents-api/internal/api/function_state_test.go create mode 100644 services/agents-api/internal/api/handler.go create mode 100644 services/agents-api/internal/api/handler_test.go create mode 100644 services/agents-api/internal/api/harness.go create mode 100644 services/agents-api/internal/api/harness_test.go create mode 100644 services/agents-api/internal/api/hosted_environment.go create mode 100644 services/agents-api/internal/api/hosted_environment_test.go create mode 100644 services/agents-api/internal/api/initial_files.go create mode 100644 services/agents-api/internal/api/initial_files_test.go create mode 100644 services/agents-api/internal/api/inputs.go create mode 100644 services/agents-api/internal/api/inputs_test.go create mode 100644 services/agents-api/internal/api/items.go create mode 100644 services/agents-api/internal/api/items_test.go create mode 100644 services/agents-api/internal/api/json_request.go create mode 100644 services/agents-api/internal/api/mcp_configuration.go create mode 100644 services/agents-api/internal/api/mcp_configuration_test.go create mode 100644 services/agents-api/internal/api/pagination.go create mode 100644 services/agents-api/internal/api/pagination_test.go create mode 100644 services/agents-api/internal/api/saved_configuration.go create mode 100644 services/agents-api/internal/api/saved_tools.go create mode 100644 services/agents-api/internal/api/session_agent.go create mode 100644 services/agents-api/internal/api/session_artifacts.go create mode 100644 services/agents-api/internal/api/session_artifacts_test.go create mode 100644 services/agents-api/internal/api/session_creation_identity.go create mode 100644 services/agents-api/internal/api/session_creation_stream.go create mode 100644 services/agents-api/internal/api/session_credentials.go create mode 100644 services/agents-api/internal/api/session_credentials_test.go create mode 100644 services/agents-api/internal/api/session_deletion.go create mode 100644 services/agents-api/internal/api/session_environment_http_test.go create mode 100644 services/agents-api/internal/api/session_environment_test.go create mode 100644 services/agents-api/internal/api/session_initial_input.go create mode 100644 services/agents-api/internal/api/session_initial_input_test.go create mode 100644 services/agents-api/internal/api/session_metadata.go create mode 100644 services/agents-api/internal/api/session_request.go create mode 100644 services/agents-api/internal/api/session_request_test.go create mode 100644 services/agents-api/internal/api/session_response.go create mode 100644 services/agents-api/internal/api/session_template.go create mode 100644 services/agents-api/internal/api/session_tools.go create mode 100644 services/agents-api/internal/api/source_files.go create mode 100644 services/agents-api/internal/api/source_files_content.go create mode 100644 services/agents-api/internal/api/source_files_list.go create mode 100644 services/agents-api/internal/api/source_files_list_test.go create mode 100644 services/agents-api/internal/api/source_files_test.go create mode 100644 services/agents-api/internal/api/source_files_upload.go create mode 100644 services/agents-api/internal/api/stream.go create mode 100644 services/agents-api/internal/api/stream_test.go create mode 100644 services/agents-api/internal/api/text_configuration.go create mode 100644 services/agents-api/internal/api/text_configuration_test.go create mode 100644 services/agents-api/internal/api/turns.go create mode 100644 services/agents-api/internal/api/turns_test.go create mode 100644 services/agents-api/internal/api/usage.go create mode 100644 services/agents-api/internal/api/vault_pagination.go create mode 100644 services/agents-api/internal/api/vaults.go create mode 100644 services/agents-api/internal/api/vaults_delete.go create mode 100644 services/agents-api/internal/api/vaults_delete_test.go create mode 100644 services/agents-api/internal/api/vaults_list.go create mode 100644 services/agents-api/internal/api/vaults_list_test.go create mode 100644 services/agents-api/internal/api/vaults_test.go create mode 100644 services/agents-api/internal/credentialcrypto/cipher.go create mode 100644 services/agents-api/internal/credentialcrypto/cipher_test.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_file.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_file_test.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_setup.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_setup_test.go create mode 100644 services/agents-api/internal/credentialcrypto/model_execution.go create mode 100644 services/agents-api/internal/db/queries/agents.sql create mode 100644 services/agents-api/internal/db/queries/devices.sql create mode 100644 services/agents-api/internal/db/queries/environment_connections.sql create mode 100644 services/agents-api/internal/db/queries/environment_executor_credentials.sql create mode 100644 services/agents-api/internal/db/queries/environment_file_writes.sql create mode 100644 services/agents-api/internal/db/queries/environment_input_activity.sql create mode 100644 services/agents-api/internal/db/queries/environment_input_expiry.sql create mode 100644 services/agents-api/internal/db/queries/environment_inputs.sql create mode 100644 services/agents-api/internal/db/queries/environment_setup.sql create mode 100644 services/agents-api/internal/db/queries/environment_templates.sql create mode 100644 services/agents-api/internal/db/queries/environments.sql create mode 100644 services/agents-api/internal/db/queries/functions.sql create mode 100644 services/agents-api/internal/db/queries/initial_environment_files.sql create mode 100644 services/agents-api/internal/db/queries/local_environment_devices.sql create mode 100644 services/agents-api/internal/db/queries/mcp_credentials.sql create mode 100644 services/agents-api/internal/db/queries/project_scopes.sql create mode 100644 services/agents-api/internal/db/queries/runtime_allocations.sql create mode 100644 services/agents-api/internal/db/queries/scheduling.sql create mode 100644 services/agents-api/internal/db/queries/session_artifacts.sql create mode 100644 services/agents-api/internal/db/queries/session_events.sql create mode 100644 services/agents-api/internal/db/queries/session_items.sql create mode 100644 services/agents-api/internal/db/queries/session_model_execution.sql create mode 100644 services/agents-api/internal/db/queries/sessions.sql create mode 100644 services/agents-api/internal/db/queries/source_files.sql create mode 100644 services/agents-api/internal/db/queries/subagent_identities.sql create mode 100644 services/agents-api/internal/db/queries/token_usage.sql create mode 100644 services/agents-api/internal/db/queries/turn_events.sql create mode 100644 services/agents-api/internal/db/queries/turn_reads.sql create mode 100644 services/agents-api/internal/db/queries/turns.sql create mode 100644 services/agents-api/internal/db/queries/vault_credentials.sql create mode 100644 services/agents-api/internal/db/queries/vaults.sql create mode 100644 services/agents-api/internal/db/sqlc/agents.sql.go create mode 100644 services/agents-api/internal/db/sqlc/db.go create mode 100644 services/agents-api/internal/db/sqlc/devices.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_connections.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_file_writes.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_input_activity.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_inputs.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_setup.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environment_templates.sql.go create mode 100644 services/agents-api/internal/db/sqlc/environments.sql.go create mode 100644 services/agents-api/internal/db/sqlc/functions.sql.go create mode 100644 services/agents-api/internal/db/sqlc/initial_environment_files.sql.go create mode 100644 services/agents-api/internal/db/sqlc/local_environment_devices.sql.go create mode 100644 services/agents-api/internal/db/sqlc/mcp_credentials.sql.go create mode 100644 services/agents-api/internal/db/sqlc/models.go create mode 100644 services/agents-api/internal/db/sqlc/project_scopes.sql.go create mode 100644 services/agents-api/internal/db/sqlc/runtime_allocations.sql.go create mode 100644 services/agents-api/internal/db/sqlc/scheduling.sql.go create mode 100644 services/agents-api/internal/db/sqlc/session_artifacts.sql.go create mode 100644 services/agents-api/internal/db/sqlc/session_events.sql.go create mode 100644 services/agents-api/internal/db/sqlc/session_items.sql.go create mode 100644 services/agents-api/internal/db/sqlc/session_model_execution.sql.go create mode 100644 services/agents-api/internal/db/sqlc/sessions.sql.go create mode 100644 services/agents-api/internal/db/sqlc/source_files.sql.go create mode 100644 services/agents-api/internal/db/sqlc/subagent_identities.sql.go create mode 100644 services/agents-api/internal/db/sqlc/token_usage.sql.go create mode 100644 services/agents-api/internal/db/sqlc/turn_events.sql.go create mode 100644 services/agents-api/internal/db/sqlc/turn_reads.sql.go create mode 100644 services/agents-api/internal/db/sqlc/turns.sql.go create mode 100644 services/agents-api/internal/db/sqlc/vault_credentials.sql.go create mode 100644 services/agents-api/internal/db/sqlc/vaults.sql.go create mode 100644 services/agents-api/internal/engine/claude.go create mode 100644 services/agents-api/internal/engine/claude_mcp.go create mode 100644 services/agents-api/internal/engine/codex.go create mode 100644 services/agents-api/internal/engine/mcode.go create mode 100644 services/agents-api/internal/engine/profile.go create mode 100644 services/agents-api/internal/engine/profile_test.go create mode 100644 services/agents-api/internal/execution/artifacts.go create mode 100644 services/agents-api/internal/execution/delivery.go create mode 100644 services/agents-api/internal/execution/directory_preparation.go create mode 100644 services/agents-api/internal/execution/directory_preparation_test.go create mode 100644 services/agents-api/internal/execution/dispatcher.go create mode 100644 services/agents-api/internal/execution/engine_profile.go create mode 100644 services/agents-api/internal/execution/engine_profile_test.go create mode 100644 services/agents-api/internal/execution/environment.go create mode 100644 services/agents-api/internal/execution/environment_admission.go create mode 100644 services/agents-api/internal/execution/environment_connections.go create mode 100644 services/agents-api/internal/execution/environment_directory.go create mode 100644 services/agents-api/internal/execution/environment_file_write.go create mode 100644 services/agents-api/internal/execution/environment_placement.go create mode 100644 services/agents-api/internal/execution/environment_placement_test.go create mode 100644 services/agents-api/internal/execution/environment_test.go create mode 100644 services/agents-api/internal/execution/finish.go create mode 100644 services/agents-api/internal/execution/functions.go create mode 100644 services/agents-api/internal/execution/functions_test.go create mode 100644 services/agents-api/internal/execution/input_text.go create mode 100644 services/agents-api/internal/execution/journal.go create mode 100644 services/agents-api/internal/execution/journal_test.go create mode 100644 services/agents-api/internal/execution/mcode_profile_test.go create mode 100644 services/agents-api/internal/execution/mcp.go create mode 100644 services/agents-api/internal/execution/mcp_credentials.go create mode 100644 services/agents-api/internal/execution/mcp_credentials_test.go create mode 100644 services/agents-api/internal/execution/mcp_support.go create mode 100644 services/agents-api/internal/execution/mcp_support_test.go create mode 100644 services/agents-api/internal/execution/mcp_test.go create mode 100644 services/agents-api/internal/execution/model_execution.go create mode 100644 services/agents-api/internal/execution/model_execution_test.go create mode 100644 services/agents-api/internal/execution/policy.go create mode 100644 services/agents-api/internal/execution/preparation.go create mode 100644 services/agents-api/internal/execution/prepared_dispatch.go create mode 100644 services/agents-api/internal/execution/recovery_test.go create mode 100644 services/agents-api/internal/execution/request.go create mode 100644 services/agents-api/internal/execution/runtime_connections.go create mode 100644 services/agents-api/internal/execution/runtime_initialization.go create mode 100644 services/agents-api/internal/execution/runtime_initialization_real_test.go create mode 100644 services/agents-api/internal/execution/runtime_lifecycle.go create mode 100644 services/agents-api/internal/execution/runtime_pending.go create mode 100644 services/agents-api/internal/execution/runtime_provider_selection.go create mode 100644 services/agents-api/internal/execution/runtime_provider_selection_test.go create mode 100644 services/agents-api/internal/execution/runtime_setup.go create mode 100644 services/agents-api/internal/execution/support.go create mode 100644 services/agents-api/internal/execution/worker.go create mode 100644 services/agents-api/internal/execution/worker_device.go create mode 100644 services/agents-api/internal/execution/worker_schedule.go create mode 100644 services/agents-api/internal/executor/codex/config.go create mode 100644 services/agents-api/internal/executor/codex/credentials.go create mode 100644 services/agents-api/internal/executor/codex/credentials_test.go create mode 100644 services/agents-api/internal/executor/codex/harness.go create mode 100644 services/agents-api/internal/executor/codex/harness_credentials.go create mode 100644 services/agents-api/internal/executor/codex/harness_credentials_test.go create mode 100644 services/agents-api/internal/executor/codex/harness_test.go create mode 100644 services/agents-api/internal/executor/codex/lifecycle.go create mode 100644 services/agents-api/internal/executor/codex/lifecycle_test.go create mode 100644 services/agents-api/internal/executor/codex/messages.go create mode 100644 services/agents-api/internal/executor/codex/registry.go create mode 100644 services/agents-api/internal/executor/codex/registry_test.go create mode 100644 services/agents-api/internal/executor/codex/socket.go create mode 100644 services/agents-api/internal/identity/principal.go create mode 100644 services/agents-api/internal/identity/subject.go create mode 100644 services/agents-api/internal/items/command_output.go create mode 100644 services/agents-api/internal/items/command_output_test.go create mode 100644 services/agents-api/internal/items/inputs.go create mode 100644 services/agents-api/internal/items/messages.go create mode 100644 services/agents-api/internal/items/messages_test.go create mode 100644 services/agents-api/internal/items/tools.go create mode 100644 services/agents-api/internal/items/tools_test.go create mode 100644 services/agents-api/internal/runtime/gateway.go create mode 100644 services/agents-api/internal/sandbox/docker/bootstrap.go create mode 100644 services/agents-api/internal/sandbox/docker/command.go create mode 100644 services/agents-api/internal/sandbox/docker/provider.go create mode 100644 services/agents-api/internal/sandbox/docker/provider_test.go create mode 100644 services/agents-api/internal/sandbox/docker/recovery_test.go create mode 100644 services/agents-api/internal/sandbox/e2b/bootstrap.go create mode 100644 services/agents-api/internal/sandbox/e2b/command.go create mode 100644 services/agents-api/internal/sandbox/e2b/command_input.go create mode 100644 services/agents-api/internal/sandbox/e2b/command_input_test.go create mode 100644 services/agents-api/internal/sandbox/e2b/control.go create mode 100644 services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE create mode 100644 services/agents-api/internal/sandbox/e2b/envdprocess/README.md create mode 100644 services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go create mode 100644 services/agents-api/internal/sandbox/e2b/envdprocess/process.proto create mode 100644 services/agents-api/internal/sandbox/e2b/provider.go create mode 100644 services/agents-api/internal/sandbox/e2b/provider_real_test.go create mode 100644 services/agents-api/internal/sandbox/provider.go create mode 100644 services/agents-api/internal/store/agents.go create mode 100644 services/agents-api/internal/store/agents_delete.go create mode 100644 services/agents-api/internal/store/agents_delete_public_test.go create mode 100644 services/agents-api/internal/store/agents_list.go create mode 100644 services/agents-api/internal/store/agents_list_test.go create mode 100644 services/agents-api/internal/store/agents_test.go create mode 100644 services/agents-api/internal/store/agents_update.go create mode 100644 services/agents-api/internal/store/agents_update_public_test.go create mode 100644 services/agents-api/internal/store/agents_update_test.go create mode 100644 services/agents-api/internal/store/artifact_capture.go create mode 100644 services/agents-api/internal/store/artifact_lifecycle.go create mode 100644 services/agents-api/internal/store/claude_execution_test.go create mode 100644 services/agents-api/internal/store/claude_mcp_test.go create mode 100644 services/agents-api/internal/store/command_output_test.go create mode 100644 services/agents-api/internal/store/credential_status_migration_test.go create mode 100644 services/agents-api/internal/store/devices.go create mode 100644 services/agents-api/internal/store/devices_test.go create mode 100644 services/agents-api/internal/store/dispatch_test.go create mode 100644 services/agents-api/internal/store/environment_admission_test.go create mode 100644 services/agents-api/internal/store/environment_claim_worker_test.go create mode 100644 services/agents-api/internal/store/environment_connection_events_test.go create mode 100644 services/agents-api/internal/store/environment_connection_migration_test.go create mode 100644 services/agents-api/internal/store/environment_connection_recovery.go create mode 100644 services/agents-api/internal/store/environment_connection_recovery_test.go create mode 100644 services/agents-api/internal/store/environment_connection_worker_test.go create mode 100644 services/agents-api/internal/store/environment_connections.go create mode 100644 services/agents-api/internal/store/environment_connections_test.go create mode 100644 services/agents-api/internal/store/environment_device_test.go create mode 100644 services/agents-api/internal/store/environment_directory_active_test.go create mode 100644 services/agents-api/internal/store/environment_directory_native_test.go create mode 100644 services/agents-api/internal/store/environment_directory_test.go create mode 100644 services/agents-api/internal/store/environment_executor_command_test.go create mode 100644 services/agents-api/internal/store/environment_executor_credentials.go create mode 100644 services/agents-api/internal/store/environment_executor_credentials_test.go create mode 100644 services/agents-api/internal/store/environment_expiry_dispatch_test.go create mode 100644 services/agents-api/internal/store/environment_expiry_worker_test.go create mode 100644 services/agents-api/internal/store/environment_file_write_migration_test.go create mode 100644 services/agents-api/internal/store/environment_file_writes.go create mode 100644 services/agents-api/internal/store/environment_file_writes_test.go create mode 100644 services/agents-api/internal/store/environment_files_native_test.go create mode 100644 services/agents-api/internal/store/environment_initial_input_test.go create mode 100644 services/agents-api/internal/store/environment_initial_migration_test.go create mode 100644 services/agents-api/internal/store/environment_initial_public_test.go create mode 100644 services/agents-api/internal/store/environment_input_activity.go create mode 100644 services/agents-api/internal/store/environment_input_activity_test.go create mode 100644 services/agents-api/internal/store/environment_input_claim_test.go create mode 100644 services/agents-api/internal/store/environment_input_expiry.go create mode 100644 services/agents-api/internal/store/environment_input_expiry_test.go create mode 100644 services/agents-api/internal/store/environment_input_migration_test.go create mode 100644 services/agents-api/internal/store/environment_input_settlement_test.go create mode 100644 services/agents-api/internal/store/environment_inputs.go create mode 100644 services/agents-api/internal/store/environment_inputs_test.go create mode 100644 services/agents-api/internal/store/environment_retrieve_public_test.go create mode 100644 services/agents-api/internal/store/environment_setup.go create mode 100644 services/agents-api/internal/store/environment_setup_test.go create mode 100644 services/agents-api/internal/store/environment_skills.go create mode 100644 services/agents-api/internal/store/environment_skills_test.go create mode 100644 services/agents-api/internal/store/environment_steering_order_test.go create mode 100644 services/agents-api/internal/store/environment_templates.go create mode 100644 services/agents-api/internal/store/environment_templates_test.go create mode 100644 services/agents-api/internal/store/environment_work_test.go create mode 100644 services/agents-api/internal/store/environment_worker_helpers_test.go create mode 100644 services/agents-api/internal/store/environment_worker_test.go create mode 100644 services/agents-api/internal/store/environments.go create mode 100644 services/agents-api/internal/store/environments_migration_test.go create mode 100644 services/agents-api/internal/store/environments_test.go create mode 100644 services/agents-api/internal/store/execution_events_test.go create mode 100644 services/agents-api/internal/store/execution_lease.go create mode 100644 services/agents-api/internal/store/execution_lease_cleanup_test.go create mode 100644 services/agents-api/internal/store/execution_lease_test.go create mode 100644 services/agents-api/internal/store/execution_messages_test.go create mode 100644 services/agents-api/internal/store/execution_tools_test.go create mode 100644 services/agents-api/internal/store/executor_credential_target.go create mode 100644 services/agents-api/internal/store/executor_launcher_helpers_test.go create mode 100644 services/agents-api/internal/store/executor_launcher_test.go create mode 100644 services/agents-api/internal/store/executor_principals_migration_test.go create mode 100644 services/agents-api/internal/store/executor_principals_test.go create mode 100644 services/agents-api/internal/store/executor_registration_public_test.go create mode 100644 services/agents-api/internal/store/export_test.go create mode 100644 services/agents-api/internal/store/function_calls.go create mode 100644 services/agents-api/internal/store/function_calls_test.go create mode 100644 services/agents-api/internal/store/function_execution_native_test.go create mode 100644 services/agents-api/internal/store/function_execution_test.go create mode 100644 services/agents-api/internal/store/function_input_execution_test.go create mode 100644 services/agents-api/internal/store/function_inputs.go create mode 100644 services/agents-api/internal/store/function_inputs_public_test.go create mode 100644 services/agents-api/internal/store/function_inputs_test.go create mode 100644 services/agents-api/internal/store/function_item_events_test.go create mode 100644 services/agents-api/internal/store/function_model_test.go create mode 100644 services/agents-api/internal/store/function_public_native_test.go create mode 100644 services/agents-api/internal/store/function_results.go create mode 100644 services/agents-api/internal/store/function_state.go create mode 100644 services/agents-api/internal/store/function_state_public_test.go create mode 100644 services/agents-api/internal/store/function_state_test.go create mode 100644 services/agents-api/internal/store/function_stream_native_test.go create mode 100644 services/agents-api/internal/store/function_worker_test.go create mode 100644 services/agents-api/internal/store/harness_authorization_test.go create mode 100644 services/agents-api/internal/store/harness_onboarding_test.go create mode 100644 services/agents-api/internal/store/initial_files.go create mode 100644 services/agents-api/internal/store/initial_files_http_test.go create mode 100644 services/agents-api/internal/store/initial_files_test.go create mode 100644 services/agents-api/internal/store/input_batches_test.go create mode 100644 services/agents-api/internal/store/item_events.go create mode 100644 services/agents-api/internal/store/item_order_migration_test.go create mode 100644 services/agents-api/internal/store/item_order_test.go create mode 100644 services/agents-api/internal/store/item_projection.go create mode 100644 services/agents-api/internal/store/item_reads.go create mode 100644 services/agents-api/internal/store/item_reads_test.go create mode 100644 services/agents-api/internal/store/json_object.go create mode 100644 services/agents-api/internal/store/local_artifact_export_test.go create mode 100644 services/agents-api/internal/store/local_environment_devices.go create mode 100644 services/agents-api/internal/store/local_environment_devices_test.go create mode 100644 services/agents-api/internal/store/local_environment_file_write_test.go create mode 100644 services/agents-api/internal/store/local_environment_worker_test.go create mode 100644 services/agents-api/internal/store/mcode_public_native_test.go create mode 100644 services/agents-api/internal/store/mcp_credentials.go create mode 100644 services/agents-api/internal/store/mcp_credentials_test.go create mode 100644 services/agents-api/internal/store/native_daemon_test.go create mode 100644 services/agents-api/internal/store/native_daemon_workspace_directory_test.go create mode 100644 services/agents-api/internal/store/native_daemon_workspace_read_test.go create mode 100644 services/agents-api/internal/store/native_environment_adapter_helpers_test.go create mode 100644 services/agents-api/internal/store/native_environment_adapter_test.go create mode 100644 services/agents-api/internal/store/native_environment_test.go create mode 100644 services/agents-api/internal/store/native_executor_directory_test.go create mode 100644 services/agents-api/internal/store/native_harness_artifact_test.go create mode 100644 services/agents-api/internal/store/native_harness_directory_test.go create mode 100644 services/agents-api/internal/store/native_harness_read_test.go create mode 100644 services/agents-api/internal/store/native_harness_write_test.go create mode 100644 services/agents-api/internal/store/native_placement_test.go create mode 100644 services/agents-api/internal/store/native_preparation_helpers_test.go create mode 100644 services/agents-api/internal/store/native_public_execution_test.go create mode 100644 services/agents-api/internal/store/native_raw_files_cancel_test.go create mode 100644 services/agents-api/internal/store/native_recovery_test.go create mode 100644 services/agents-api/internal/store/native_relay_test.go create mode 100644 services/agents-api/internal/store/native_shared_files_test.go create mode 100644 services/agents-api/internal/store/native_workspace_preparation_test.go create mode 100644 services/agents-api/internal/store/no_environment_test.go create mode 100644 services/agents-api/internal/store/prepared_dispatch_failure_test.go create mode 100644 services/agents-api/internal/store/prepared_dispatch_native_test.go create mode 100644 services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go create mode 100644 services/agents-api/internal/store/prepared_dispatch_test.go create mode 100644 services/agents-api/internal/store/project_scopes.go create mode 100644 services/agents-api/internal/store/project_scopes_test.go create mode 100644 services/agents-api/internal/store/public_execution_test.go create mode 100644 services/agents-api/internal/store/public_harness_profile_test.go create mode 100644 services/agents-api/internal/store/remote_mcp_credentials_test.go create mode 100644 services/agents-api/internal/store/remote_mcp_test.go create mode 100644 services/agents-api/internal/store/runtime_allocation_state.go create mode 100644 services/agents-api/internal/store/runtime_allocations.go create mode 100644 services/agents-api/internal/store/runtime_allocations_test.go create mode 100644 services/agents-api/internal/store/runtime_connection_test.go create mode 100644 services/agents-api/internal/store/runtime_environment_terminal.go create mode 100644 services/agents-api/internal/store/runtime_environment_terminal_test.go create mode 100644 services/agents-api/internal/store/runtime_initialization.go create mode 100644 services/agents-api/internal/store/runtime_initialization_test.go create mode 100644 services/agents-api/internal/store/runtime_input_admission_test.go create mode 100644 services/agents-api/internal/store/runtime_lifecycle_test.go create mode 100644 services/agents-api/internal/store/runtime_pending_test.go create mode 100644 services/agents-api/internal/store/scheduling.go create mode 100644 services/agents-api/internal/store/self_hosted_cancel_public_test.go create mode 100644 services/agents-api/internal/store/self_hosted_functions_public_test.go create mode 100644 services/agents-api/internal/store/self_hosted_initial_public_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_cancel_native_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_fixture_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_functions_native_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_helpers_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_native_test.go create mode 100644 services/agents-api/internal/store/self_hosted_public_steering_native_test.go create mode 100644 services/agents-api/internal/store/self_hosted_steering_public_test.go create mode 100644 services/agents-api/internal/store/session_agent_filter_public_test.go create mode 100644 services/agents-api/internal/store/session_agent_filter_test.go create mode 100644 services/agents-api/internal/store/session_artifacts.go create mode 100644 services/agents-api/internal/store/session_artifacts_test.go create mode 100644 services/agents-api/internal/store/session_configuration_test.go create mode 100644 services/agents-api/internal/store/session_creation_identity.go create mode 100644 services/agents-api/internal/store/session_creation_identity_test.go create mode 100644 services/agents-api/internal/store/session_creation_stream.go create mode 100644 services/agents-api/internal/store/session_creation_stream_test.go create mode 100644 services/agents-api/internal/store/session_creator.go create mode 100644 services/agents-api/internal/store/session_creator_test.go create mode 100644 services/agents-api/internal/store/session_deletion.go create mode 100644 services/agents-api/internal/store/session_deletion_execution_test.go create mode 100644 services/agents-api/internal/store/session_deletion_public_test.go create mode 100644 services/agents-api/internal/store/session_deletion_test.go create mode 100644 services/agents-api/internal/store/session_environment_snapshot.go create mode 100644 services/agents-api/internal/store/session_environment_snapshot_test.go create mode 100644 services/agents-api/internal/store/session_events.go create mode 100644 services/agents-api/internal/store/session_events_test.go create mode 100644 services/agents-api/internal/store/session_initial_input.go create mode 100644 services/agents-api/internal/store/session_initial_input_test.go create mode 100644 services/agents-api/internal/store/session_initial_public_test.go create mode 100644 services/agents-api/internal/store/session_metadata.go create mode 100644 services/agents-api/internal/store/session_metadata_test.go create mode 100644 services/agents-api/internal/store/session_model_execution.go create mode 100644 services/agents-api/internal/store/session_model_execution_http_test.go create mode 100644 services/agents-api/internal/store/session_model_execution_test.go create mode 100644 services/agents-api/internal/store/session_reference_retry_public_test.go create mode 100644 services/agents-api/internal/store/session_transaction.go create mode 100644 services/agents-api/internal/store/sessions.go create mode 100644 services/agents-api/internal/store/sessions_test.go create mode 100644 services/agents-api/internal/store/source_file_writer.go create mode 100644 services/agents-api/internal/store/source_files.go create mode 100644 services/agents-api/internal/store/source_files_list_test.go create mode 100644 services/agents-api/internal/store/source_files_test.go create mode 100644 services/agents-api/internal/store/steering_receipts_test.go create mode 100644 services/agents-api/internal/store/subagent_dispatch_test.go create mode 100644 services/agents-api/internal/store/subagent_identities.go create mode 100644 services/agents-api/internal/store/subagent_identities_test.go create mode 100644 services/agents-api/internal/store/token_usage.go create mode 100644 services/agents-api/internal/store/token_usage_integration_test.go create mode 100644 services/agents-api/internal/store/token_usage_test.go create mode 100644 services/agents-api/internal/store/turn_completion.go create mode 100644 services/agents-api/internal/store/turn_events.go create mode 100644 services/agents-api/internal/store/turn_events_test.go create mode 100644 services/agents-api/internal/store/turn_inputs.go create mode 100644 services/agents-api/internal/store/turn_inputs_test.go create mode 100644 services/agents-api/internal/store/turn_reads.go create mode 100644 services/agents-api/internal/store/turn_reads_test.go create mode 100644 services/agents-api/internal/store/turns.go create mode 100644 services/agents-api/internal/store/turns_test.go create mode 100644 services/agents-api/internal/store/vault_credentials.go create mode 100644 services/agents-api/internal/store/vault_credentials_delete.go create mode 100644 services/agents-api/internal/store/vault_credentials_delete_test.go create mode 100644 services/agents-api/internal/store/vault_credentials_list.go create mode 100644 services/agents-api/internal/store/vault_credentials_list_test.go create mode 100644 services/agents-api/internal/store/vault_credentials_test.go create mode 100644 services/agents-api/internal/store/vault_credentials_update.go create mode 100644 services/agents-api/internal/store/vault_credentials_update_test.go create mode 100644 services/agents-api/internal/store/vault_status_migration_test.go create mode 100644 services/agents-api/internal/store/vaults.go create mode 100644 services/agents-api/internal/store/vaults_delete.go create mode 100644 services/agents-api/internal/store/vaults_delete_test.go create mode 100644 services/agents-api/internal/store/vaults_list.go create mode 100644 services/agents-api/internal/store/vaults_list_test.go create mode 100644 services/agents-api/internal/store/vaults_test.go create mode 100644 services/agents-api/internal/store/worker_lease_loss_test.go create mode 100644 services/agents-api/migrations/000001_sessions.sql create mode 100644 services/agents-api/migrations/000002_session_configuration.sql create mode 100644 services/agents-api/migrations/000003_turns.sql create mode 100644 services/agents-api/migrations/000004_input_batches.sql create mode 100644 services/agents-api/migrations/000005_devices.sql create mode 100644 services/agents-api/migrations/000006_native_sessions.sql create mode 100644 services/agents-api/migrations/000007_turn_events.sql create mode 100644 services/agents-api/migrations/000008_session_items.sql create mode 100644 services/agents-api/migrations/000009_execution_queue.sql create mode 100644 services/agents-api/migrations/000010_token_usage.sql create mode 100644 services/agents-api/migrations/000011_item_order.sql create mode 100644 services/agents-api/migrations/000012_session_events.sql create mode 100644 services/agents-api/migrations/000013_function_calls.sql create mode 100644 services/agents-api/migrations/000014_function_inputs.sql create mode 100644 services/agents-api/migrations/000015_retire_item_backfill.sql create mode 100644 services/agents-api/migrations/000016_agents.sql create mode 100644 services/agents-api/migrations/000017_session_creation_identity.sql create mode 100644 services/agents-api/migrations/000018_session_agent_filter.sql create mode 100644 services/agents-api/migrations/000019_session_deletion.sql create mode 100644 services/agents-api/migrations/000020_environments.sql create mode 100644 services/agents-api/migrations/000021_environment_executor_credentials.sql create mode 100644 services/agents-api/migrations/000022_environment_input_reservations.sql create mode 100644 services/agents-api/migrations/000023_environment_input_expiry_index.sql create mode 100644 services/agents-api/migrations/000024_execution_project_scopes.sql create mode 100644 services/agents-api/migrations/000025_session_creators.sql create mode 100644 services/agents-api/migrations/000026_executor_principals.sql create mode 100644 services/agents-api/migrations/000027_environment_connections.sql create mode 100644 services/agents-api/migrations/000028_environment_input_activity.sql create mode 100644 services/agents-api/migrations/000029_environment_initial_input.sql create mode 100644 services/agents-api/migrations/000030_vaults.sql create mode 100644 services/agents-api/migrations/000031_vault_credentials.sql create mode 100644 services/agents-api/migrations/000032_vault_status.sql create mode 100644 services/agents-api/migrations/000033_credential_status.sql create mode 100644 services/agents-api/migrations/000034_subagent_identities.sql create mode 100644 services/agents-api/migrations/000035_local_environment_devices.sql create mode 100644 services/agents-api/migrations/000036_environment_file_writes.sql create mode 100644 services/agents-api/migrations/000037_source_files.sql create mode 100644 services/agents-api/migrations/000038_runtime_allocations.sql create mode 100644 services/agents-api/migrations/000039_environment_input_failure.sql create mode 100644 services/agents-api/migrations/000040_session_artifacts.sql create mode 100644 services/agents-api/migrations/000041_source_files_list.sql create mode 100644 services/agents-api/migrations/000042_environment_templates.sql create mode 100644 services/agents-api/migrations/000043_environment_initial_files.sql create mode 100644 services/agents-api/migrations/000044_environment_setup.sql create mode 100644 services/agents-api/migrations/000045_environment_skills.sql create mode 100644 services/agents-api/migrations/000046_session_model_execution.sql create mode 100644 services/agents-api/migrations/migrations.go create mode 100644 services/agents-api/sqlc.yaml create mode 100755 services/agents-api/tests/container_server.py create mode 100644 services/agents-api/tests/e2b_native_isolation.py create mode 100644 services/agents-api/tests/fixtures/credentials.go create mode 100644 services/agents-api/tests/fixtures/items.go create mode 100644 services/agents-api/tests/fixtures/main.go create mode 100644 services/agents-api/tests/fixtures/vaults.go create mode 100644 services/agents-api/tests/native/README.md create mode 100644 services/agents-api/tests/native/directory/README.md create mode 100644 services/agents-api/tests/native/directory/probe.rs create mode 100644 services/agents-api/tests/native/environment_model_probe.py create mode 100644 services/agents-api/tests/native/raw_files/.gitattributes create mode 100644 services/agents-api/tests/native/raw_files/README.md create mode 100644 services/agents-api/tests/native/raw_files/client-dependency.patch create mode 100644 services/agents-api/tests/native/raw_files/source.json create mode 100644 services/agents-api/tests/native/raw_files_probe.rs create mode 100644 services/agents-api/tests/native/raw_manager/.gitattributes create mode 100644 services/agents-api/tests/native/raw_manager/README.md create mode 100644 services/agents-api/tests/native/raw_manager/owner.rs create mode 100644 services/agents-api/tests/native/raw_manager/prepare.py create mode 100644 services/agents-api/tests/native/raw_manager/probe.rs create mode 100644 services/agents-api/tests/native/raw_manager/source.json create mode 100644 services/agents-api/tests/native/relay_probe.rs create mode 100644 services/agents-api/tests/native/retirement/README.md create mode 100644 services/agents-api/tests/native/retirement/gate.rs create mode 100644 services/agents-api/tests/native/retirement/operator_retirement.py create mode 100644 services/agents-api/tests/native/retirement/placement.py create mode 100644 services/agents-api/tests/native/retirement/placement_test.rs create mode 100644 services/agents-api/tests/native/retirement/processor_test.rs create mode 100644 services/agents-api/tests/native/retirement/qualification.patch create mode 100644 services/agents-api/tests/native/retirement/source.json create mode 100644 services/agents-api/tests/native/shared_files/cancellation.rs create mode 100644 services/agents-api/tests/native/shared_files/configuration.rs create mode 100644 services/agents-api/tests/native/shared_files/files.rs create mode 100644 services/agents-api/tests/native/shared_files/observations.rs create mode 100644 services/agents-api/tests/native/shared_files/probe.rs create mode 100644 services/agents-api/tests/native/shared_files/raw_runtime.rs create mode 100644 services/agents-api/tests/native/shared_files/runtime.rs create mode 100644 services/agents-api/tests/native/shared_files_probe.rs create mode 100644 services/agents-api/tests/native/write/README.md create mode 100644 services/agents-api/tests/native/write/probe.py create mode 100644 services/agents-api/tests/official_agent_delete.py create mode 100644 services/agents-api/tests/official_agent_list.py create mode 100644 services/agents-api/tests/official_agent_reference_retry.py create mode 100644 services/agents-api/tests/official_agent_references.py create mode 100644 services/agents-api/tests/official_agent_update.py create mode 100644 services/agents-api/tests/official_agents.py create mode 100644 services/agents-api/tests/official_auth.py create mode 100644 services/agents-api/tests/official_client.py create mode 100644 services/agents-api/tests/official_credential_delete.py create mode 100644 services/agents-api/tests/official_credential_list.py create mode 100644 services/agents-api/tests/official_credential_rotation.py create mode 100644 services/agents-api/tests/official_credentials.py create mode 100644 services/agents-api/tests/official_e2b_v1.py create mode 100644 services/agents-api/tests/official_environment_activity.py create mode 100644 services/agents-api/tests/official_environment_events.py create mode 100644 services/agents-api/tests/official_environment_files.py create mode 100644 services/agents-api/tests/official_environment_files_create.py create mode 100644 services/agents-api/tests/official_environment_files_native.py create mode 100644 services/agents-api/tests/official_environment_initial_failure.py create mode 100644 services/agents-api/tests/official_environment_initial_files.py create mode 100644 services/agents-api/tests/official_environment_retrieve.py create mode 100644 services/agents-api/tests/official_environment_setup.py create mode 100644 services/agents-api/tests/official_environment_skills.py create mode 100644 services/agents-api/tests/official_environment_templates.py create mode 100644 services/agents-api/tests/official_execution.py create mode 100644 services/agents-api/tests/official_function_inputs.py create mode 100644 services/agents-api/tests/official_function_state.py create mode 100644 services/agents-api/tests/official_function_stream.py create mode 100644 services/agents-api/tests/official_functions.py create mode 100644 services/agents-api/tests/official_hosted_functions_native.py create mode 100644 services/agents-api/tests/official_items.py create mode 100644 services/agents-api/tests/official_mcode_native.py create mode 100644 services/agents-api/tests/official_mcp.py create mode 100644 services/agents-api/tests/official_mcp_credentials.py create mode 100644 services/agents-api/tests/official_self_hosted.py create mode 100644 services/agents-api/tests/official_self_hosted_cancel.py create mode 100644 services/agents-api/tests/official_self_hosted_cancel_native.py create mode 100644 services/agents-api/tests/official_self_hosted_creation.py create mode 100644 services/agents-api/tests/official_self_hosted_functions.py create mode 100644 services/agents-api/tests/official_self_hosted_functions_native.py create mode 100644 services/agents-api/tests/official_self_hosted_initial.py create mode 100644 services/agents-api/tests/official_self_hosted_steering.py create mode 100644 services/agents-api/tests/official_self_hosted_steering_native.py create mode 100644 services/agents-api/tests/official_session_agent_filter.py create mode 100644 services/agents-api/tests/official_session_artifacts.py create mode 100644 services/agents-api/tests/official_session_creation_stream.py create mode 100644 services/agents-api/tests/official_session_creators.py create mode 100644 services/agents-api/tests/official_session_delete.py create mode 100644 services/agents-api/tests/official_session_initial_input.py create mode 100644 services/agents-api/tests/official_session_metadata.py create mode 100644 services/agents-api/tests/official_session_requests.py create mode 100644 services/agents-api/tests/official_source_file_list.py create mode 100644 services/agents-api/tests/official_source_files.py create mode 100644 services/agents-api/tests/official_vault_delete.py create mode 100644 services/agents-api/tests/official_vault_list.py create mode 100644 services/agents-api/tests/official_vaults.py create mode 100644 services/agents-api/tests/requirements.txt diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 000000000..016f708bb --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,38 @@ + +# The CI of the CI. Every workflow YAML in this directory is itself +# code — a malformed `if` expression, a typo in `needs:`, or a matrix +# value that does not exist will pass `yaml.safe_load` but blow up at +# runtime, sometimes hours after merge. actionlint catches that class +# of mistake locally-equivalent on every PR that touches workflows. +# +# Version pinned (no `main`, no `@latest`): actionlint occasionally +# tightens rules between releases, so a silent upgrade could turn a +# green PR red overnight. Bump in a dedicated PR. +name: actionlint + +on: + push: + branches: [main] + paths: + - '.github/workflows/**' + pull_request: + paths: + - '.github/workflows/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Download actionlint + run: bash <(curl -sSf https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.7.12 + - name: Run actionlint + run: ./actionlint -color diff --git a/.github/workflows/agents-api.yml b/.github/workflows/agents-api.yml new file mode 100644 index 000000000..090aa5f53 --- /dev/null +++ b/.github/workflows/agents-api.yml @@ -0,0 +1,93 @@ +name: agents-api + +on: + push: + branches: [main] + paths: + - 'services/agents-api/**' + - 'contracts/agents-api/**' + - 'packages/agents-client/**' + - 'internal/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/build-agents-api.sh' + - 'scripts/build-agents-api-image.sh' + - '.github/workflows/agents-api.yml' + pull_request: + paths: + - 'services/agents-api/**' + - 'contracts/agents-api/**' + - 'packages/agents-client/**' + - 'internal/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/build-agents-api.sh' + - 'scripts/build-agents-api-image.sh' + - '.github/workflows/agents-api.yml' + +permissions: + contents: read + +concurrency: + group: agents-api-${{ github.ref }} + cancel-in-progress: true + +jobs: + sessions: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: parsar_agents_api_ci_tests + POSTGRES_USER: agents_api + POSTGRES_PASSWORD: agents_api_test_only + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U agents_api -d parsar_agents_api_ci_tests" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + - name: Verify dedicated execution persistence + env: + PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_ci_tests?sslmode=disable + run: | + AGENTS_API_BUILD_DIR="$RUNNER_TEMP/agents-api-build" make check-agents-api + AGENTS_API_DATABASE_URL="$PARSAR_AGENTS_API_TEST_DATABASE_URL" "$RUNNER_TEMP/agents-api-build/agents-api-migrate" + "$RUNNER_TEMP/agents-api-build/agents-api-device" --help + "$RUNNER_TEMP/agents-api-build/agents-api-environment-key" --help + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install pinned official client + run: | + python - <<'PY' + import json, subprocess, sys + pin = json.load(open('contracts/agents-api/upstream.json')) + subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', 'services/agents-api/tests/requirements.txt', + 'openai @ git+https://github.com/openai/openai-python@' + pin['commit']], check=True) + PY + - name: Verify official-client HTTP compatibility + env: + PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_ci_tests?sslmode=disable + AGENTS_API_SERVER_BIN: ${{ runner.temp }}/agents-api-build/agents-api + PARSAR_OFFICIAL_SDK_PYTHON: python + run: | + python services/agents-api/tests/official_client.py + go test ./services/agents-api/internal/store -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient|TestSelfHostedFunctionsOfficialClient|TestSelfHostedSteeringOfficialClient)$' -count=1 + - name: Verify standalone container distribution + env: + PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_ci_tests?sslmode=disable + AGENTS_API_IMAGE: agents-api:ci + PARSAR_OFFICIAL_SDK_PYTHON: python + run: make check-agents-api-container diff --git a/.github/workflows/agents-executor.yml b/.github/workflows/agents-executor.yml new file mode 100644 index 000000000..3d0ca6505 --- /dev/null +++ b/.github/workflows/agents-executor.yml @@ -0,0 +1,51 @@ +name: agents-executor + +on: + push: + branches: [main] + paths: + - 'packages/codex-executor/**' + - 'scripts/*agents-executor.sh' + - '.github/workflows/agents-executor.yml' + - 'Makefile' + pull_request: + paths: + - 'packages/codex-executor/**' + - 'scripts/*agents-executor.sh' + - '.github/workflows/agents-executor.yml' + - 'Makefile' + +permissions: + contents: read + +concurrency: + group: agents-executor-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-build: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + CARGO_HOME: /home/runner/.parsar/cache/executor-cargo + CARGO_TARGET_DIR: /home/runner/.parsar/cache/executor-target + CARGO_BUILD_JOBS: 4 + steps: + - uses: actions/checkout@v7 + - name: Install native build prerequisites + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + - uses: actions/cache@v6 + with: + path: | + ~/.parsar/cache/executor-cargo + ~/.parsar/cache/executor-target + key: agents-executor-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-executor/Cargo.lock') }} + - name: Check native launcher and independent release build + run: | + make check-agents-executor + make build-agents-executor + ~/.parsar/build/agents-executor/agents-api-codex-executor --version + ~/.parsar/build/agents-executor/agents-api-codex-executor --help diff --git a/.github/workflows/agents-harness.yml b/.github/workflows/agents-harness.yml new file mode 100644 index 000000000..7678f433a --- /dev/null +++ b/.github/workflows/agents-harness.yml @@ -0,0 +1,60 @@ +name: agents-harness + +on: + push: + branches: [main] + paths: + - 'packages/codex-harness/**' + - 'scripts/*agents-harness.sh' + - 'services/agents-api/tests/native/*/source.json' + - '.github/workflows/agents-harness.yml' + - 'Makefile' + pull_request: + paths: + - 'packages/codex-harness/**' + - 'scripts/*agents-harness.sh' + - 'services/agents-api/tests/native/*/source.json' + - '.github/workflows/agents-harness.yml' + - 'Makefile' + +permissions: + contents: read + +concurrency: + group: agents-harness-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-build: + runs-on: ubuntu-22.04 + timeout-minutes: 60 + env: + CARGO_HOME: /home/runner/.parsar/cache/agents-harness-cargo + CARGO_TARGET_DIR: /home/runner/.parsar/cache/agents-harness-target + AGENTS_HARNESS_NATIVE_SOURCE: /home/runner/.parsar/references/codex-native + CARGO_BUILD_JOBS: 4 + CARGO_PROFILE_DEV_DEBUG: 0 + steps: + - uses: actions/checkout@v7 + - name: Check packaging + run: make check-agents-harness + - name: Install native build prerequisites + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + - name: Fetch the pinned upstream source + run: | + revision="$(python3 -c 'import json; print(json.load(open("packages/codex-harness/source.json"))["revision"])')" + git init "$AGENTS_HARNESS_NATIVE_SOURCE" + git -C "$AGENTS_HARNESS_NATIVE_SOURCE" fetch --depth 1 https://github.com/openai/codex "$revision" + - uses: actions/cache@v6 + with: + path: | + ~/.parsar/cache/agents-harness-cargo + ~/.parsar/cache/agents-harness-target + key: agents-harness-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-harness/source.json') }} + - name: Check native harness and independent release build + run: | + make check-agents-harness-native + make build-agents-harness diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 000000000..c806fa626 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,62 @@ +name: core-check + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: core-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-22.04 + timeout-minutes: 40 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: parsar_agents_api_core_ci_tests + POSTGRES_USER: agents_api + POSTGRES_PASSWORD: core_test_only + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U agents_api -d parsar_agents_api_core_ci_tests" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + CARGO_HOME: /home/runner/.parsar/cache/executor-cargo + CARGO_TARGET_DIR: /home/runner/.parsar/cache/executor-target + CARGO_BUILD_JOBS: 4 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install pinned check prerequisites + run: | + npm install --global pnpm@10.30.3 + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + - uses: actions/cache@v6 + with: + path: | + ~/.parsar/cache/executor-cargo + ~/.parsar/cache/executor-target + key: core-executor-${{ runner.os }}-${{ hashFiles('packages/codex-executor/Cargo.lock') }} + - name: Check standalone repository + env: + PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_core_ci_tests?sslmode=disable + run: make check + - name: Build execution daemon + run: make build-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..da6269529 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.DS_Store +node_modules/ +dist/ +build/ +coverage/ +*.log +.env +.env.* +!.env.example +.worktrees/ +go.work.sum +__pycache__/ +*.pyc +/.parsar/ +/config/ +/logs/ +/state/ +/cache/ +/target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..441f5c7ca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Parsar Core development + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. Work in an isolated +Git worktree and submit a PR. Keep product business code in the Parsar repository. + +Preserve the pinned public Agent API and documented extensions. Do not add product +database dependencies or bypass Core execution ownership. Update architecture and +generated contracts with changes. Run `make check` before reporting completion. + +After implementation and verification, request an independent blind review with +only requirements, acceptance criteria, boundaries, repository path and baseline. +Fix in-scope blockers and review again. Never use `codex exec` for this review. + +Documentation and code comments are English; user-facing copy may be bilingual. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..3e0b5e81f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,2542 @@ +# Contributing to Parsar Core + +## Repository boundary + +This repository is the standalone execution substrate copied from Parsar at the +revision in `provenance/source.json`. Keep the API, its migrations, protocol, +execution daemon, runtime adapters, shared execution packages and build/test tools +here. Product users, workspaces, model catalogs, business assets, web UI, product +API and product migrations remain in Parsar. Do not import `server/`, `apps/web/`, +`apps/parsar/`, product CLI/plugin packages or their deployment stack. + +Preserve copied runtime and protocol behavior. Existing Go import paths, binary +names and runtime environment variables remain unchanged for this copy; they do +not require fetching the original repository. The source snapshot and per-file +hashes provide an audit trail; future Core development need not preserve those +hashes. Do not automatically sync or delete the original repository's Core. + +## Workflow and quality + +Develop in an isolated worktree on a feature branch and submit a PR. Do not edit +or commit implementation directly on main. An empty repository bootstrap commit +is only the comparison base for the first import PR. After validation, conduct an +independent blind review using only requirements, acceptance criteria, boundaries, +repository path and comparison baseline. Fix in-scope blockers before delivery. +Do not use `codex exec` as a substitute reviewer. + +Keep runtime state, test artifacts and build output under `~/.parsar/`. Require +absolute user-supplied working directories. Keep credentials out of source and +logs. Update this guide when architecture, ownership or generated contracts change. +Comments and documentation are English. Reuse existing helpers and error mapping; +split oversized components before extending them. Use `internal/obs/log` for logs. + +## Required checks + +Run `make check` before completion. The standalone gate includes all daemon/shared +Go tests, Core contract/client/service tests, a real dedicated PostgreSQL test +database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK +tests and packaging, MiniMax companion checks, Rust executor tests/format/Clippy, +and Codex Harness packaging checks. It intentionally has no product Web/server/ +installer gates. The full gate fails when the database variable is missing. + +Use Go from `go.mod`, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 with rustfmt +and Clippy, and Linux OpenSSL development libraries. `make sqlc-generate` owns only +`services/agents-api/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed +migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; +there is no product swaggo contract in this repository. Preserve its pinned types, +coverage ledgers and official SDK/raw HTTP tests when changing API behavior. + +Core changes must retain the independent build and official-client workflow. +Changes to native Harness sources require `make check-agents-harness-native`, +`make build-agents-harness` and applicable live provider acceptance. Real execution +checks require real models; do not count omitted prerequisites or mocked responses +as live acceptance. Never expand this extraction into unrelated behavioral fixes. + +## Architecture boundaries + +The following execution rules are retained from the source contributor guide. +References to the product describe the external client boundary, not components +included in this repository. + +### Product and execution service separation + +Agents API is the primary infrastructure deliverable. Parsar is an ordinary client +and example application; its feature backlog must not dictate the execution +service's public protocol or internal model. Agents API must build, deploy and run +without the Parsar product service, frontend or database. An optional Compose +deployment may install both services with one PostgreSQL instance, but separate +databases, credentials and migrations. The product uses Core exclusively; it has no native daemon or HTTP Agent fallback. + +#### Design and compatibility requirements + +- The complete pinned `openai/openai-python` `beta/agents` protocol is the target, + including its referenced resources and types. Match paths, methods, headers, + field presence, nullability, discriminators, defaults, status transitions, + pagination, errors and streaming behavior. Engine limitations are implementation + gaps to solve, not grounds for narrowing or redefining the upstream contract. +- Pin upstream source and SDK versions in `contracts/agents-api/upstream.json`. + Use official SDKs for clients and reuse upstream types or schemas where suitable. + SDK deserialization alone is not server validation or proof of compatibility: + test raw HTTP payloads and observable workflows as well. Synthetic data and mock + model responses may support controlled tests; live execution acceptance must + call a real model API through the service, daemon and harness. A real daemon + with a synthetic model does not constitute live model validation. Keep provider + credentials in private test configuration, outside source, logs and task records. + Record unspecified or unverified behavior explicitly; never invent official + semantics. Track partial + coverage in `contracts/agents-api/README.md` until the complete target is verified. + Reconcile current coverage summaries with merged routes and recorded acceptance; + distinguish accepted profiles, partial implementation, missing operations and + unverified semantics. Retain historical evidence with its original scope. Handler + counts are not compatibility percentages, and an active provider probe is not + deployment qualification. +- No legacy Agents API compatibility requirement takes precedence over this + design. Replace an unsuitable implementation instead of growing compatibility + branches. Preserve reusable, verified infrastructure rather than rewriting it + merely for new names or directories. Replacements may retire obsolete private + interfaces and history backfills in bounded PRs; this does not authorize deleting + product data or changing unrelated product behavior. +- Keep engine-specific types, process management and protocol translation inside + execution adapters. The public API and persistence/application core must not + interpret Parsar product payloads or depend on one engine's native item types. + Prefer maintained upstream SDKs and native execution protocols over a second + hand-written model/tool loop or a general-purpose compatibility framework. +- Verify an independent official-client workflow before a Parsar integration. + Parsar uses the same public contract as any other client, with no privileged + endpoint or direct execution-table access. An OpenAI endpoint is a possible + client target only where the requested capabilities and credentials support it. +- Maintain tasks, priorities and evidence in the Feishu board. Register issues + discovered during a task without switching work or automatically selecting them + next. Only a direct acceptance blocker justifies a minimal in-scope fix. After + each bounded task passes checks/review and merges, mark it done, reread the full + board and choose the next task by value, dependencies, risk and effort. Agent API + protocol and atomic execution work takes priority over product integration, UI + work and business Team orchestration. Prioritize a sound architecture skeleton + and correct principal workflows with real API validation. Record and defer + low-frequency corner cases when risk and ROI permit; do not let minor details + delay the main work. Required checks and material correctness guarantees apply. + +- Parsar owns users, workspaces, business authorization, Agent/Team definitions, + capabilities, product conversations, IM/sharing, approval decisions and billing. +- Agents API owns protocol saved Agents, execution sessions/turns, effective + configuration snapshots, dispatch/cancel, environments, vaults, raw usage, + pending interactions, protocol subagents and durable events. Protocol saved + Agents/vaults are execution resources, not Parsar marketplace or business roles. + Neither service reads the other's tables. Parsar uses a versioned client contract. +- A product conversation may map to several execution sessions. An execution + session is distinct from a live daemon socket, process or sandbox. Native engine + session identifiers belong to the execution service. +- Establish single-Agent execution, approval, cancellation, idempotent submission, + persisted recovery queries before Team orchestration. The upstream SSE stream + is live-only; recover through Session/Turn/Items reads. Any additional product + cursor replay must be documented as an extension, not upstream semantics. + Team definitions, management and orchestration belong to Parsar. Agents API + establishes single-Agent execution first; business Team loops are deferred. + This does not exclude upstream `multi_agent` configuration or subagent resources + from protocol coverage. Future business Team orchestration directly depends on + `openai/openai-agents-python` in Parsar. +- Daemon Skill/SP authoring remains a product operation: forward through a scoped + product callback with the original requester and workspace checks. A runtime + credential alone must not grant business write permissions. + +#### Environment ownership and placement + +Environment identity, tenant/Session association, configuration and lifecycle belong +in Agents API, independently of provider compute, authenticated device identity, +daemon sockets and native harness Sessions. Create an Environment association in +the same transaction as its Session and creation identity when this resource is +implemented. Keep mutable connection/registration state out of immutable +configuration; replacement ownership must fence stale observations. + +The current MVP covers Codex, Claude Code and MiniMax Code through the shared +single-Agent path: Session +creation, environment preparation, native execution, files/artifacts, cancellation, +reconnection/recovery queries, and standalone deployment acceptance. Select each +bounded task from the complete board; nonblocking local improvements stay queued. +Authentication, tenant/credential isolation, state consistency and data loss remain +material acceptance requirements. Optional feature equality is not required. After +the three profiles pass merged-main validation, publish the results, limitations +and backlog, then stop development until new user direction. Additional harness +implementations and protocol Subagent execution remain queued without changing the +complete pinned protocol target. + +The current hosted architecture is V1: Core runs independently; each Environment +sandbox contains its daemon, selected native harness, local tools and workspace. +Execution and Files use the same authorized workspace through the existing +Core/Runtime contract. Native tool calls stay local. +CLI discovery uses a bounded 15-second version probe per installed harness; +missing binaries fail immediately. A version result is availability, not Environment +readiness, and does not change initialization or connection ownership. Process placement and native +transport remain adapter responsibilities, without a second model/tool loop. +The former separated Runtime/harness and workspace executor topology is a distant +future V2 option, to revisit only after V1 is stable and concrete needs justify it. +Do not extend that topology for hosted delivery, maintain two current hosted routes, +or introduce dormant V2 compatibility scaffolding. + +When an execution Session has a previously started Turn but no recorded native +Session ID, Core requires existing-history recovery through a verified Runtime +capability. Read that condition before claiming the next Turn. A supplied native ID +remains authoritative. The Codex adapter may recover only a unique, nonarchived +root in the exact Session-private native home and expected working directory, using +native listing and exact-ID resume. Missing, incomplete or ambiguous history must +fail without starting a fresh root. A recorded start can precede native work; that +uncertain case also fails conservatively. Recovery does not replay interrupted +inputs, erase prior outcomes or promise transparent continuation of running tools. +Keep Device identity and Environment scope in `ExecutionDevice`; native Session +identity and prior API Turn state belong to `SessionExecutionBinding`. + +Platform-managed and user-managed deployment reuse this same Runtime. For platform +management, SandboxProvider creates and reclaims it. For user management, the user +starts the Runtime and its daemon authenticates and initiates the Core connection; +Core must verify tenant ownership and the exact Environment binding. These are +management responsibilities, not separate execution architectures. User-managed +Runtime does not automatically mean the official `self_hosted` discriminator; +that mapping needs separate protocol definition and acceptance. User-managed +installation and enrollment remain later board work, outside the current Docker +co-location security qualification. + +Preserve the accepted official `self_hosted` interoperability path and its native +executor connection flow. Codex registry/Noise is specific to that path, not the +V1 hosted backbone or a universal protocol for all engines. A private daemon URL +or an undocumented daemon installation requirement cannot replace `remote_url`. +Keep harness cwd separate from the executor workspace where that accepted remote +path still requires it. + +Public Environment Templates belong to Core and its execution database, independently +of provider image/build templates. Resolve a tenant-owned reference once at Session +creation, freeze the effective ordinary hosted configuration and reuse inline +initialization. Do not pass template IDs into Provider or Runtime. Omitted network +inherits; overrides may only narrow policy. Preserve unresolved caller intent for +creation retries and recover committed results before reading mutable templates. +Updates and deletion cannot rewrite existing Session snapshots. Initial files use +one Core-owned installer for template and inline configurations. Keep confidential +bytes encrypted under the execution-service key and resource-bound AEAD, separately +from ordinary configuration and public metadata. Templates retain source references; +Session creation freezes tenant-authorized source bytes in the same commit, independent +of later source/template deletion. Public resource reads must not require decryption +or load encrypted file bodies. Record original creation intent before resolution. + +Template parsing, persistence and resolution must not select a harness or Provider, +or depend on native tool names and private harness paths. The shared initializer +uses the packaged Runtime contract for trusted commands, workspace/staging paths, +confidential input and completion receipts. Each Provider supplies that same +Runtime and carries initialization commands through RunCommand; each adapter owns +native tool configuration. A new harness or Provider must not require template +business-logic changes. Reuse qualified shared helpers even when their executable +names have historical engine prefixes; renaming is not a boundary fix. Select real +regressions by the changed shared, Provider and adapter boundaries, rather than +repeating every deployment combination for each configuration field. + +The allocation lifecycle owns pending/running/complete initialization. Authentication +may connect the daemon during initialization; execution bindings, native preparation, +live Files and connected publication wait for completion. Keep Provider bootstrap +settlement distinct. Advance at most one bounded initialization operation per full +maintenance scan, +using process-local progress and the existing lifecycle gate. A recovered or uncertain +running installation fails and uses existing cleanup, without replaying writes. +Completed environments never reinstall initial files on reconnect or native recovery. +Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted +initializers may run with Runtime authority. User setup and package install hooks +run in the common packaged sandbox, without daemon credentials or native history. +Files and inline Skills precede system, npm/Python packages and ordered setup commands. Initialization has +provisioning network access; requested network restrictions apply to native tools +after setup. Confidential env and setup snapshots are encrypted independently of +ordinary metadata. Adapters apply tool env only after isolation, never to the +credential-bearing daemon/native harness launcher. +Reuse the packaged atomic file writer and anchored parent creation across all profiles. + +System packages use one Runtime-owned tool root, separate from trusted daemon and +harness executables. Build its immutable seed from the base image before adding +Runtime/harness code or secrets; include the matching package database and base +tool symlink targets. The shared installer extracts independent inodes and runs +apt/dpkg inside an unprivileged namespace. Package scripts cannot access Runtime +credentials, native history or outer processes. Later setup and native tools enter +the installed root read-only, retaining the authorized workspace and adapter-owned +scratch. `/workspace` and `/environment/workspace` refer to the same authorized +workspace inside that root, preserving native working directories. Native adapters +own entry and existing process cancellation; Core never +selects an engine or Provider for package initialization. No live filesystem +snapshot, second lifecycle owner or package-manager framework is introduced. +Core preserves the system-package requirement in the common execution binding; +a missing installation receipt fails preparation instead of falling back to base +tools. This requirement does not add execution prerequisites to Files reads. + +Inline Skill ZIPs use the same confidential initialization snapshot and installer. +Core validates portable manifests and bounded regular-file archives, returns only +safe Skill metadata, and freezes content before native preparation. The Runtime +owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but +not modify this tree. The common execution descriptor carries Skill metadata, +never native plugin configuration or template identities. Adapters register native +Skill roots without changing the execution loop or enabling unrestricted tools. +Native activation extensions remain adapter-owned and must fail explicitly when +unqualified. Skills API references, generic Plugins and capability-directory +imports remain separate work; an adapter-owned Claude plugin envelope does not +implement public Plugins. + +Name, enabled/disabled network, initial files, inline Skills and env/setup/system/npm/Python are +implemented independently of remaining installation fields. Reject unsupported +inputs rather than persisting them for silent +omission; expand inline and template initialization together in separately qualified +batches. Resource reads need only tenant authorization, not a live Runtime. +See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). + +SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. +Use maintained provider SDKs and thin adapters, Docker first and E2B after the MVP. +Provider initialization creates the sandbox and starts its daemon/harness; +RunCommand is for initialization only. Daily execution and Files use Runtime and +native or bounded local capabilities. Docker's lack of a native renewable lease +does not remove service-owned hosted expiry and cleanup requirements. + +The E2B Provider uses an explicit `templateID:build_UUID` and the same qualified +colocated Runtime. Its root-private bootstrap input and final atomic receipt live +on persistent disk, never template `/run`. Running compute alone does not establish +completed initialization. Inspect exact installation/tenant/Environment/allocation +metadata and the matching Session/device receipt; never replay uncertain Create or +bootstrap. Credentials stay out of provider metadata, template environment and +command arguments. Use the existing one-hour disconnect grace with an E2B lease of +at least two hours. Expiry, pause or lost state cannot silently recreate/resume a +VM. Before launching daemon, trusted root bootstrap must correct E2B's writable +program/boot paths and disable its unused passwordless privileged account; qualify +these protections after provider finalization, not just in the source image. +The [E2B operator guide](services/agents-api/deploy/e2b/README.md) owns packaging, +configuration and real-cloud acceptance. The pinned official envd process schema +and generated Go messages live together under `internal/sandbox/e2b/envdprocess`; +regenerate with the documented tools when that source changes. Do not hand-write +Connect framing or add SDK subprocesses to the static Core. Provider envd file and +command access is initialization-only; public Files and execution remain on Runtime. + +The independent Docker Provider consumes an immutable Runtime image and retains +one caller-owned allocation reference through partial creation and cleanup. Persist +that reference before Create and serialize its lifecycle; resolve a lost response +with observed state, without rewriting bootstrap credentials or replaying startup. +Provider state is compute state, not public Environment readiness. Named Runtime +volumes need explicit owned cleanup after container removal. A second mount of the +same workspace volume subdirectory provides the public `/workspace` path to native +tools; trusted staging and atomic rename retain the original parent mount. Do not +copy files or widen private-path reads to preserve an alias. Initialization command +timeouts can leave processes alive and require allocation cleanup before reuse. +The [managed Runtime build and operator configuration](services/agents-api/deploy/codex/README.md#managed-runtime-image-and-docker-adapter) +defines the explicit opt-in for basic hosted admission. Building an image alone +does not qualify its isolation or enable public creation. + +Managed Runtime allocation, dedicated daemon credential hash and exact Session +binding commit atomically before Provider.Create, using the existing execution +lease and Session lock. Only the fresh allocation receipt permits Create; retries +and Core restart observe that same reference without replay or credential rotation. +The operator's stable provider key identifies one backend/installation; retain its +adapter for cleanup, and use a different key when changing the target. Never treat +absence on another backend as successful reclamation. +Disabling the default provider stops new hosted admission/bootstrap; it must not +block existing Session cancellation, tool results or input retry outcomes. +Input HTTP response budgets follow the persisted Environment type, covering the +admission wait for both hosted and self-hosted Sessions independently of operator +creation switches or remote executor configuration. + +With an explicitly configured default managed provider, the same Worker scans +committed pending hosted Environments that have no allocation. This includes idle +Session creation and recovery after commit-before-bootstrap interruption; an +existing allocation never enters that startup path. Keep the scan bounded and +serialized by the existing lifecycle owner. Hosted provisioning requires no caller +connection action. An initial reservation without a Turn leaves its Session idle, +as allowed by the pinned contract; do not emit an in-progress event before a Turn +starts or treat a daemon connection as native readiness. + +The same serialized scan publishes authenticated connection observations using +the existing durable generations after verifying the exact Session/device binding +and settled bootstrap. Socket loss remains observable during a provider outage; +Core restart fences old observations. Do not create a separate connection owner. + +Terminal managed cleanup atomically revokes authority, persists Environment failure +or expiry, settles pending input and requests cancellation before external cleanup. +Preserve original input deadlines and retry outcomes. Temporary provider outages, +unknown Create results and stopped compute do not prove permanent failure. The +pinned stream has no Environment expired event; do not invent one. Exact hosted +failure codes and ordering remain explicitly unverified. + +Allocation state is private compute ownership, separate from public Environment +connection/native readiness. Adapters qualify bootstrap completion; Core does not +infer it from an engine or provider name. Connected, observed compute receives +service keepalives between Turns. Keepalives cannot revive a one-hour lapse or a +cleanup request. Idle alone never requests shutdown. A stopped/missing container +does not authorize discarding retained workspace or history. Session deletion or +expiry requests cleanup, revokes the scoped device and cancels pending work before +Provider.Kill; the existing Worker serializes these lifecycle operations and drains +them before releasing its execution lease. + +Keep the allocation after public Session deletion. Mark it released only after +owned compute/volume cleanup and evidence that its original Create has settled. +An unknown creation retains cleanup ownership even after an absence observation; +continue bounded scans for late resources without issuing another Create. This +conservative internal lifecycle does not define user-managed enrollment or prove +complete upstream expiry/error semantics. + +Qualify the actual Docker/native sandbox before default cutover: real model +execution, file access, owned cancellation, restart with retained native history +and files, and rejection when required history is missing. Generated code and file +tools must not read daemon/model credentials, foreign Session history or another +tenant's workspace. Same-container placement, matching UID, mode bits, directory +bindings and capability flags do not prove isolation. Retain failed probes and +unverified limits; private functionality is not public hosted acceptance. + +Before migration, archive existing edits and validation evidence. Reuse verified +authorization, resource/lifecycle ownership and safe filesystem primitives as +needed by V1. Stop work on separated-only enrollment, mirrored manifests and relay +mechanisms. Remove superseded unused code, configuration, tests, scripts and +task-owned temporary resources as each replacement is accepted. Preserve necessary +regressions, still-used official capabilities, product data and others' work. + +The opt-in Codex deployment selector `PARSAR_CODEX_PERMISSION_PROFILE` chooses a +native named profile at harness startup and on both new/resumed threads, omitting +the legacy sandbox override. It is operator configuration, never a prompt option, +and rejects remote, none and temporary read preparations. Native managed +requirements own allowed profiles and deny-read enforcement. Keep the selector +unset for existing deployments. Managed native shells disable shell snapshots, +whose private files are inaccessible to tool execution; retain normal native shell +startup without granting tools access to harness state. +The [co-location qualification inputs](services/agents-api/deploy/codex/README.md) +record the pinned native/Docker prerequisites and limits; this switch alone does +not admit hosted Environments or authorize a workspace. + +A managed Runtime's enabled/disabled network policy is immutable deployment input, +transferred through the provider-neutral bootstrap and checked against execution +preparation. The native adapter selects the corresponding managed profile; Core +and Docker do not select native profile names. New policy-aware peers advertise +`local_environment_network_policy`; enabled execution requires that capability. +The older explicit-disabled internal peer path remains supported without widening +its policy. Read-only workspace access does not require execution network policy. +A declaration alone does not qualify an image or admit public hosted creation. + +A dedicated local Runtime uses one Environment-scoped device credential and an +immutable binding to that Environment's Session. It is excluded from general +device selection; another Session cannot claim it, including within the same +tenant. Deleting its Session invalidates credential lookup and heartbeat renewal. +Provision a new scoped device atomically rather than widening an existing shared +device credential. Revocation does not authorize silent placement replacement. + +The private local Environment reference contains its identity and, for policy-aware +execution, its immutable network policy. Trusted Runtime deployment configuration +freezes the Environment, Session and workspace root; +requests cannot supply a replacement root. Local and remote references are mutually +exclusive. Use the same preparation/start lifecycle for native execution and the +existing bounded workspace controls for directory access. Local idle directory +reads use the existing filesystem helper directly, with no model credentials or +temporary harness. These private capabilities do not admit public hosted requests, +establish Provider lifecycle, or define the official `self_hosted` mapping. +Core rechecks the persisted Environment/device binding for preparation and active +reads; capability discovery cannot select or authorize a general device for this +placement. Local work uses the existing pending-input reservation and Worker +ownership without a remote connection resolver. The basic hosted profile supports `network.access: enabled` or `disabled`; the +actual image must qualify both native profiles before public deployment. Omitted +network settings mean enabled upstream and must not be silently treated as disabled. + +Core and Runtime use common preparation, start, input-receipt, cancellation, +release and recovery semantics for Codex, Claude Code and MiniMax Code. Retain each +harness's native implementation behind its adapter. Core acts on verified capabilities and runtime +conditions; a capability declaration alone never grants public feature admission. +Extend existing interfaces during related functional work without introducing a +second framework or a broad rewrite. Codex, Claude Code and MiniMax Code have +qualified dedicated Docker and E2B V1 profiles. Each harness has equal standing; +qualify each image/template with the common full-loop acceptance before deploying. +Additional engines and hosted remote-executor separation remain separate work. +Later engines must satisfy the same applicable acceptance contract while keeping +their suitable native deployment layout. + +Workspace reads may request the private `workspace_read_only` preparation profile +through the existing preparation factory and verified `workspace_read_preparation` +capability. It accepts only the bound Environment and resource identity; execution +options, model/MCP credentials, native Session continuation and model/tool input are excluded. +The Codex adapter creates temporary local state, reuses its native connection and +directory transport, and rejects Start. Its child inherits only process/transport +essentials. The private native read mode excludes system, managed, user and project +execution configuration and plugin startup while preserving native security +requirements. Ordinary execution keeps its stable state and configuration. +Reject the legacy mixed managed-config profile for reads rather than discarding +its enforced constraints together with execution settings. +For this read profile, `released` is published only after local Close succeeds; +cleanup errors retain ownership and report `cleanup_unconfirmed`. A failed factory +must return its resource with the error if cleanup remains unconfirmed; wrappers +must preserve both values. Successful cleanup retries publish confirmed release, +and stale status snapshots cannot publish success. Failed terminal status delivery +does not retry cleanup; ownership remains until an explicit release or shutdown retry. +A release request, +HTTP disconnect or remote socket closure alone is not cleanup confirmation. This +profile does not establish remote mutation quiescence or public Files admission. + +Core directory reads reuse the Worker's Session scheduling reservation for idle +preparation and target the exact Run for active execution. Device selection uses +operation-specific capabilities; reading files never resolves model/MCP options +or creates a Turn. HTTP cancellation ends observation, not an admitted native read. +Keep the idle reservation through the bounded read and release attempt. Return +directory data only after confirmed Close; incomplete reads or uncertain cleanup +return unavailable without data. Release the Worker's scheduling reservation before +delivering the result so the caller can immediately request the next page. +Revoke the scoped read transport credential on +completion or failure. Runtime retains uncertain cleanup ownership and capacity; +this does not require a second durable Core owner registry or establish remote +write retirement. Public Files.list delegates workspace access to this reader; +the API owns tenant authorization, path validation and protocol pagination. Keep +partial directory coverage and unverified defaults explicit in the Files contract. + +Source Files belong to the execution project and have an independent lifecycle +from copied workspace files. Store immutable source metadata and PostgreSQL large +objects in the execution database with the pinned pgx driver. Upload validation, +metadata insertion and bytes commit atomically; deletion removes metadata and +unlinks the object in one transaction. Keep OIDs private and authorize every +metadata/content/delete lookup by tenant before opening a body. Stream bounded +chunks; never hold an entire general Files upload in memory or use filenames as +filesystem paths. A read-only repeatable-read transaction preserves an admitted +source snapshot across concurrent deletion. Resolve that snapshot before entering +the existing Environment write path; deleting a source does not undo a completed +workspace copy. Bound request/transaction lifetimes, roll back incomplete bodies, +and never automatically retry ambiguous commits. Backups must include PostgreSQL +large objects; live deletion does not erase WAL or historical backups. Schema +rollback must not orphan existing source objects. Do not reuse product capability +tables or introduce a second destination writer for file_id. + +Session Artifacts are immutable published output copies, separate from live +workspace files and general source Files. A private output exporter must reuse +the authorized workspace path boundary and stream bounded bytes. Require complete +capture and confirmed helper/transport success before publication; valid archive +syntax alone is insufficient. Never extract an output archive into Core's +filesystem or hold the global execution lease through a large transfer. Keep +publication ordered with Turn completion, and authorize stored reads independently +of Environment availability so published outputs can survive its expiration. +Capture bytes into private PostgreSQL large objects without a Session admission +lock; after confirmed export, lock and recheck the live Turn before staging metadata. +Before capture, seal native input under that lock using the private capture marker. +Later messages reuse the existing Environment input reservation and await the next +Turn; the public Turn stays in progress until publication settles. Directory reads +during capture use an independent authorized read-only preparation, not the released +native Run; they do not request model credentials or mutate the workspace. Cancellation +retains the existing Turn/reservation semantics. Do not introduce a second queue. +Publish metadata in the same transaction as Turn completion. Failed/cancelled Turns +discard private objects, and Session deletion removes both private and published +copies. Reuse the source-file snapshot reader pattern and common content response; +artifact deletion does not alter workspace files. Hosted execution requires the +Runtime's bounded output-export capability and exact read-only preparation binding; +capability advertisement alone does not qualify an operator's deployment. +Exporter component checks do not establish public Artifact compatibility. + +Local inline file delivery uses the same authenticated daemon connection and exact +Environment/Session binding. The optional startup-owned `PARSAR_RUNTIME_WRITE_HELPER` +and `PARSAR_RUNTIME_STAGING` enable only the bounded installer primitive; they do +not grant public feature admission. Require a canonical executable outside the +Environment parent, canonical sibling workspace/staging directories on one mount, +and verified native tool denial of staging and its ancestors. Native credentials +and history remain outside that parent. Mode bits and path checks alone do not +qualify this layout. The read-only deployment needs neither writer setting. + +Transfer a complete bounded body in acknowledged 64 KiB frames before invoking +the existing installer, verify the declared digest, and run no model for upload. +Keep the existing private 50 MiB bound distinct from upstream protocol limits. +The dedicated Runtime excludes execution while receiving or applying a write; +malformed, incomplete or expired transfers cannot reach the installer. Exact +commit/rejection receipts release the mutation owner. Missing or ambiguous +receipts retain uncertainty; observer cancellation and local process exit cannot +prove non-mutation. Before public admission, Core must durably reserve the write +under the Session lock and prevent successor mutation across restart until exact +settlement. Do not replay the request or introduce general replacement machinery. +Read-only operations retain their own authority and bounded ownership requirements. + +Keep prerequisites specific to the public operation being implemented. Native +harnesses execute; adapters translate protocols and fill demonstrated capability +gaps; Core owns public semantics, authorization and resources. Before adding a +mechanism, identify the current operation it enables and why existing native +capabilities or interfaces do not suffice. Durable metadata queries need no live +runtime. Live file reads require an authorized, isolated view of the exact workspace +and bounded operation ownership, but not a complete file-write, environment +replacement or placement-retirement implementation. Apply mutation fencing and +retirement guarantees where an operation can write, replace or retire that owner. +Read-only access still requires tenant/resource checks, path isolation and safe +failure when the authorized workspace cannot be reached; it never grants public +admission merely because an adapter advertises a capability. + +Use distinct authorization for callers, devices and environment connections. A +co-located harness must not expose broader application credentials or other tenants' +secrets to generated code. Directory bindings and process identities do not provide +filesystem isolation. Preserve or demonstrably restore native history across +compute replacement; never silently move a bound Session or replay unknown work. +Self-hosted compute/files remain caller-owned, with explicit cleanup separate from +Session deletion. The full Environment implementation remains pending; follow the +[pinned contract and acceptance sequence](contracts/agents-api/environments.md) +and the [two-engine placement prerequisites](contracts/agents-api/workspace-placement.md). +For co-location, qualify both deployment isolation and native tool restrictions. +Bash sandbox settings alone do not establish file-tool or whole-harness isolation. +Never enable an environment profile before those boundaries are verified. + +The internal Store creates one Environment with an environment-bearing Session in +its creation transaction. The Session upsert selects the retry winner; retries +never create or repair associations. Environment identity/state live in their own +table. Tenant ownership and immutable configuration come from the owning Session, +without duplicated JSON, tenant columns or generated IDs in the creation hash. +Environment reads join that Session and exclude deleted Sessions; deletion retains +ownership for later settlement/cleanup. Existing `none` and legacy missing +configuration create no Environment, and historical internal snapshots are not +backfilled. Creation and recorded-intent retry snapshots load the Environment with +the Session row/cursor in the same transaction, without borrowing subsequent +activity or Turn state. Initial state is `pending`; authenticated connection observations follow +the lifecycle rules below. +Public creation supports a `self_hosted` Session on the Codex profile when +execution and a validated executor origin are configured. Require an absolute +POSIX workspace directory without NUL, CR, LF or backslash for the current adapter; +omitted/null capability directories use the empty default. +Supported non-deferred function tools use the existing validation and native +callback bridge. Nonempty capability directories and other engine placements +remain rejected implementation gaps. Session output uses the owned +Environment association; file operations and populated installation metadata remain separate. + +Environment retrieval uses the existing tenant-scoped join to a live owning Session +and its durable connection status, independently of execution or registry setup. +It preserves project-shared read access and exposes only the pinned resource fields. +The current closed self-hosted configuration has no API-managed file, plugin or skill +installations, so those required arrays are empty. They are not a filesystem listing +or a claim about native discovery. Reuse the strict Environment configuration parser +and reject unsupported installation fields/capabilities or resource states instead +of treating unknown inventory as empty. No read initiates native work or changes +connection state; connection does not establish readiness or process quiescence. + + +The private Environment input reservation stores one canonical message batch before +Turn admission, with a five-minute deadline from the database clock. It requires +an Environment-bearing Session without active work. Reservation and direct input +paths share the Session lock and retry identity; pending or settled keys cannot +bypass the reservation through direct admission. A pending reservation blocks new +direct batches, including cancellation, while successful earlier retries remain +readable. Promotion commits the original inputs, history, reservation settlement +and execution claim (`queued` to `in_progress`) together; expiration and targeted +cancellation retain the terminal identity. Session deletion +cancels pending input in the same transaction. A terminal reservation retry must not +affect a later reservation or Turn. Evaluate deadlines after acquiring the Session +lock, and return terminal storage outcomes without rolling their transaction back. + +Initial messages for a newly created Environment-bearing Session use that same +reservation in the creation transaction, including its connection-action event. +The creation winner alone inserts it; the original pre-work snapshot and stream +cursor remain unchanged. A durable initial/later flag defaults historical rows to +later input without inferring origin. Initial expiry projects a failed Session and +safe error before any Turn exists; later expiry retains idle semantics. Failure +events capture the settled activity and Usage atomically. Late connections and +creation retries cannot reset or replay expired input, and newer work supersedes +old activity without changing its event snapshots. The Environment itself is not +failed by an input deadline. This Store rule covers actual self-hosted and internal +hosted associations; it does not enable hosted providers. None/absent Environment initial input retains +immediate Turn admission. Cancellation/deletion keep their existing semantics. + +Ordinary and streamed public self-hosted creation accept initial text through this +transaction after configuration and new-work lease checks. They return the owned +Environment ID and executor URL while offline, without waiting for admission. +The creation stream sends its original pre-work `created` snapshot before the +committed connection action. A disconnected observer leaves committed input intact; +only the existing Worker prepares, promotes and starts it. Saved-Agent retries with recorded intent +recover before fresh execution admission or source resolution; inline retries keep +their existing resolved-snapshot validation. +Later live subscribers observe only future events and recover history through queries. + +The public self-hosted input profile accepts message-only batches. Under the same +Session lock, recover the original reservation or direct receipt before choosing +current active input or idle reservation. Active messages use existing ordered input +receipts without a new Turn, preparation or reservation; idle messages retain the +readiness and promotion path, including already-connected environments. Pending +reservations keep their gate and deadline. An unlocked activity read or retry after +a conflict must never choose a different admission path. Mixed inputs remain gaps; +these restrictions do not narrow the pinned protocol target. Cancellation-only batches +use the existing direct admission after configuration and execution-ownership checks. +Only the Session-locked transaction chooses the active Turn or an idle receipt; +matching retries retain that target even during later work. Cancellation cannot +create a Turn or bypass preparation. A new cancellation still conflicts with a +pending reservation; it does not cancel pre-Turn input. Its 204 response confirms +durable admission, not native completion or process exit. Homogeneous function-result +batches also use direct admission after those same checks: their explicit Turn/call +identity selects an existing pending call, never new work. Reuse function validation, +Session-locked whole-batch receipts, preserved output/error fields and native +application acknowledgements. Matching retries remain bound to their original calls +after completion or during later work; new results cannot bypass a pending reservation. +Definitions remain fixed through preparation and cold native continuation. These +callbacks are not installed Environment metadata. Mixed result/cancel publication +and exact hosted action-removal timing remain separate gaps. +Promotion requires the current leased execution writer and the caller's +retained native preparation; never hold a database lock during external preparation. Only +the first successful non-replay receipts authorize Start on that same preparation. +An admitted retry returns the original receipts without reclaiming execution; a +read or uncertain commit never authorizes another Start. A crash after promotion +but before Start uses existing claimed-Turn reconciliation (`execution_interrupted`), +including unbound or deleted Sessions, rather than ordinary queued dispatch. Deletion +after claim requests cancellation under existing active-Turn semantics. +The Worker expires at most 32 due reservations on each existing tick, after +checking ownership and before checking devices or execution slots. The sweep +requires the leased Store and uses its connection with the existing transaction +timeout; it never falls back to a pooled writer. A partial deadline index and +Session row locks with SKIP LOCKED let unrelated work proceed around contention. +The candidate cutoff is statement time; settlement rechecks the database clock +after acquiring the Session lock. This bounds mutations and transaction time, not +the number of examined locked rows. Restart resumes expiry on normal ticks without +a separate scheduler or backlog-draining loop. No failed Turn may stand in for a +pre-Turn connection failure. + +Session activity before a Turn is derived from the latest relevant reservation and +authenticated connection state. Offline input requests `environment_connection`; +connection arrival clears that action to `idle`, while the prepared Worker still +owns native readiness and admission. An idle offline Environment alone requests no +connection. Reservation/connection changes commit immutable Session activity and +usage snapshots in the same transaction; SSE must not substitute a later Turn or +action set. A newer or active Turn owns subsequent activity. Settled non-initial +reservations clear their action to `idle` until newer work exists, even after an +earlier failed Turn. This local settlement policy does not establish hosted expiry +errors or initial-input asynchronous failure semantics; those remain unverified. + +Session GET/list/metadata responses and live SSE share the safe `self_hosted` +output projection. Its `remote_url` comes only from the executor registry's +validated configured origin, never request headers or a daemon address. Include +the owned Environment ID, workspace and capability directories without exposing +private configuration. The standalone Environment resource remains separate. +Acceptance must pass that exact URL and ID to the +caller-started executor and observe real remote execution through the existing +Worker, daemon and harness, with fixed SDK and raw HTTP/SSE checks. + +A self-hosted input HTTP request returns 204 only after durable admission. Its +wait uses bounded pooled operations, outside transactions and execution lease +ownership; it cannot prepare or start native work. Only that route extends its +response write deadline to six minutes for the original five-minute database +admission deadline plus response grace. Request/observer disconnect stops waiting, +not the durable reservation or execution; retries keep the original identity and +deadline. The Worker remains the readiness, promotion and Start owner. Local failure +mapping uses 409 `environment_input_expired` / `environment_input_cancelled`, 503 +`execution_unavailable` for ownership loss, and existing 404 for deletion. Exact +hosted failure status/body and pending-input crash recovery remain unverified. +Principal acceptance must use public Session creation and input against the built +standalone service, including a wait exceeding its ordinary 30-second write timeout, +real remote commands/files and a second native-history Turn. Private provisioning +or injected API handlers cannot substitute for that workflow. + + +The opt-in native Codex executor registry lives in +`services/agents-api/internal/executor/codex`, outside public API handlers and the +daemon device gateway. It reuses the worker's execution lease and Store ownership +reads. The operator issues connect-only executor keys for a complete typed principal +within an already verified project-to-tenant mapping. A key has a stable explicit +management UUID, immutable principal and optional exact-Environment restriction; +a principal key needs no Session at issuance. Only its digest, creation/issuance +times and revocation state are persisted. Ordinary issuance never replaces an ID; +rotation and revocation require that ID and full principal. Exact-target issuance +and rotation share the Session deletion lock and require its recorded creator. + +Every authorization checks the current digest, non-revocation, project partition, +Session creator kind/ID, optional restriction and live Session in one database +snapshot. Unknown historical creators cannot authorize an executor. Deleting one +Session denies that target without revoking a principal key serving other Sessions. +Keys have no connection-ticket expiry; their validity ends through explicit +rotation/revocation, while each target remains subject to current ownership checks. +Keep caller, device, harness and executor credentials independent; no raw-token +import or read-back is provided. Never log registry bearer or URL capabilities. + +Migration 26 retains legacy key digests/restrictions under their Environment UUIDs +but revokes them with unknown principals. Do not infer historical identities or +project mappings. Stop older registry and operator writers before migration; +deploy the issuer, registry and launcher together, explicitly reissue keys and +restart executors. Reserved legacy IDs cannot be claimed or rotated into principal +keys. Downgrade cannot discard new principal-key identities or undo revocation. + +Registration IDs, five-minute connection capabilities and socket generations are +process-local. Re-registration replaces the current socket; late close callbacks +cannot clear its successor. Restart invalidates old URLs and requires registration +again. An executor retaining a valid credential may register again: permanently +excluding it requires key revocation/rotation. The current executor digest is rechecked for registration, validation, socket +attachment and live socket heartbeats; rotation/revocation applies without restart. +Registration replacement orders credential observations so an older request cannot +overwrite a newer credential's registration. Heartbeats run every five seconds +with a four-second authorization budget; closing sockets is an observation bound, +not immediate revocation of remote side effects. Ownership is also rechecked; failed execution ownership closes the registry. This +is bounded connection observation, not a guarantee of native process quiescence. +Durable connection state and immutable Environment event snapshots follow the +leased observation path below; a socket never establishes harness readiness. The harness registry grants a distinct, exact-Environment credential access to +native `/connect`; the executor alone calls `/validate`. Each connection URL and +one-use key authorization are separate five-minute capabilities bound to the +current registration, executor socket and complete harness public key. Grants are +bounded; refresh may issue unused grants without disturbing an active pair. + +Internal execution owners obtain random harness credentials from the native +registry after current lease and exact tenant/Environment authorization. The +registry retains at most 32 credential digests in memory. The owner context spans +preparation and its transferred Run; release, owner cancellation and registry +shutdown invalidate that credential, its pending grants and its own connected pair. +Recheck the same live credential under the registry lock after authorization +queries in connect, attach and validation. Old cleanup cannot revoke a successor. +The five-minute connection-ticket lifetime does not expire an active execution +owner or impose a Turn deadline. Pair closure is not proof of OS quiescence. +Static harness-key files are retired explicitly, without a fallback or public +issuance endpoint. Executor authorization also requires the recorded Session +creator; tenant ownership alone cannot authorize executor connections. No credential bearer belongs in snapshots, events, logs or the database. + +One independent harness connection pairs with each executor connection. Native +binary messages pass unchanged, up to the pinned 256 KiB limit, with one data +writer and one in-flight message per direction. Write deadlines bound stalled +peers. Either peer disconnecting closes both physical sockets and invalidates the +pair's grants; this lets native Session/process recovery run in the executor. +Never forward queued ciphertext to a replacement or invent transport replay. +Concurrent native commands and files share one connection; additional independent +harnesses are rejected without eviction. Full public Environment conformance and other engine +placements remain separate work. Native transport annotations are excluded from the +pinned public SDK OpenAPI output; their routes are documented in the service guide. + +Remote file operations must share the native execution owner's filesystem and +authorized connection. The pinned stock app-server `fs/*` methods select its local +Environment and cannot access an executor-only workspace. The opt-in +[shared-filesystem probe](services/agents-api/tests/native/README.md#shared-native-filesystem-owner) +instead injects one upstream `EnvironmentManager` into the native in-process +app-server and uses its typed filesystem directly. This is a prerequisite +experiment, not a production daemon selection or public file implementation. +The pinned in-process transport can silently drop notifications under saturation; +absence of a `Lagged` event does not prove lossless delivery. Resolve that event +contract and process/authorization ownership before adopting an embedded runtime. +Public workspace paths, file references, live metadata and pagination require +separate protocol acceptance; no model prompt or shell command implements file IO. + +The opt-in [raw manager qualification](services/agents-api/tests/native/raw_manager/README.md) +tracks an explicit patch against the same native pin. It publishes the raw runner's +stock-built manager through an additive entrypoint, retaining native configuration, +processor and transport assembly. The ordinary runner remains unchanged. Handle +publication is not readiness or revocation; its owner must supervise runner failure, +gate operations on initialization/readiness and release retained handles on teardown. +This is a private native dependency experiment, not a production runtime selection. +Record the patch, build overlay and artifact identities separately from upstream. +Production adoption requires real acceptance of the requested operations against +the remote workspace/history, bounded ownership and caller authorization. Require +stale-write fencing when admitting mutations or replacing their owner, rather than +making it a prerequisite for every read. Connection observation generations alone +cannot retract already-issued filesystem mutations. + +The opt-in [private harness artifact](packages/codex-harness/README.md) consumes +that same hook in a separately named executable at the unchanged native pin. +Its canonical patch lives in the package; qualification manifests reference the +same bytes. Export the exact upstream commit, verify the lock normalization and +named-binary overlay, and retain source/toolchain/artifact provenance. Do not build +from a mutable upstream worktree or present this integration as a stock binary. +Capture operator selectors before native bootstrap; retain native dotenv/helper +initialization before threads and its alias guard until runtime teardown. +The existing Go RPC owns its raw stdio child. A private same-user local socket +offers metadata and bounded reads through that runner's manager, with a frozen registry +Environment UUID, the adapter's native `remote` manager key, and no local fallback. +Keep socket admission bounded and stop it when the runner ends. Caller disconnect +only stops response delivery. Runner completion stops pending frames/new admission +and drains the already admitted operation within its original deadline before local +release; an unresolved drain remains an owner failure. This retains a native wait, +not a remote retirement guarantee. External forced child exit can interrupt the +drain; the existing daemon RPC's short grace/local-reap contract must be reconciled +before a file consumer can infer settlement from release. An unresolved native +file timeout must stop the owner before admitting another operation; client +frame/response timeouts are connection-local. Never equate dropping the native +response future with remote settlement. Bound Tokio runtime shutdown so an +uncancellable native stdin read cannot hide local process exit from the RPC owner. +The separately hashed bounded-read hook pins one existing native RPC connection +for open, sequential block reads and acknowledged close. It retains the pinned +native wire and stock stream behavior. Bound returned bytes and use one-byte +lookahead for exact/truncated results; do not promise a file snapshot. Uncertain +open/read results remain uncertain even if a later close replies. Unconfirmed +close fails the owner, with no partial success or connection replacement retry. +These are private adapter outcomes, not new official Files fields or error semantics. +The socket directory +must be new and private under `~/.parsar`; native/helper/socket selectors remain +operator configuration. `PARSAR_CODEX_HARNESS_BIN` opts the native Codex adapter +into this artifact for validated remote preparations only; stock helper discovery +and all nonremote execution remain unchanged. The adapter derives each private +Environment/workspace binding and owns a short IPC directory under canonical +`~/.parsar`, independently of deeper `PARSAR_HOME` profiles. Reuse the existing +Prepared-to-Session transfer and RPC child; remove IPC only after that same child +has been reaped, including initialization failure and Close timeouts. No wrapper, +new capability, public Files admission or default daemon selection is introduced. Private file path checks do not qualify filesystem isolation, idle +ownership, remote retirement, or the existing RPC's full backpressure behavior. +Public cancellation qualification for this artifact reuses the fixed SDK/raw HTTP +fixture with a task-isolated native system configuration and independently observed +owners. Preserve existing behavioral assertions and keep that test placement +separate from production isolation or public Files admission; see the +[native acceptance guide](services/agents-api/tests/native/README.md#public-cancellation-with-the-optional-harness). + +The private daemon `workspace_read` control targets an existing preparation handle +or its transferred active Run on the same authenticated device connection. Require +the exact frozen Environment identity; callers cannot supply sockets, credentials +or workspace roots. Shared routing uses the optional `agent.WorkspaceReader` +interface, without selecting an engine by name. The optional Codex artifact uses +its existing same-manager socket; stock Codex and other adapters remain unsupported. +The same control accepts `operation: directory` through the optional +`agent.WorkspaceDirectoryLister`, with mutually exclusive byte/entry limits and +typed directory metadata. Directory responses carry at most 1024 single-component +UTF-8 names of at most 255 bytes, so escaped metadata stays below the existing +frame bound. These are private transport limits, not public Files parameters. +Byte and directory operations share target checks, correlation, capacity and +retained operation waits; neither creates a Run or selects an engine by name. +Current bound preparation admission requires `RemoteEnvironment`; the qualified +co-located Claude workspace profile does not accept that placement. Its private +directory capability therefore does not establish end-to-end control admission. +Integrate its verified workspace identity through the existing lifecycle before +claiming Claude control/public Files acceptance; never fabricate remote bindings. +This control is not a public Files endpoint or capability advertisement. + +The separate optional `agent.WorkspaceDirectoryLister` observes one workspace-relative +directory on the existing Prepared/Session owner; empty path selects its root. +Return single-component names, entry kind, regular-file byte size and explicit +truncation only after directory/metadata access and handle cleanup settle. Reuse +byte-read admission, uncertainty and caller-detach ownership where applicable. +Do not promise a snapshot, recursive traversal or public pagination through this +private interface. Codex uses the same manager's native process backend to invoke +an operator-installed `agents-api-codex-directory` through explicit argv, without a +shell. `PARSAR_CODEX_DIRECTORY_HELPER` selects the executor-side executable and is +frozen in the private child binding; an absent or invalid selector rejects only +this operation. Keep that qualified installation outside the writable workspace. +Require the verified Linux sandbox, read-only workspace/helper runtime access and +restricted network. The helper anchors all traversal to no-follow descriptors and +bounds enumeration before collecting names. Accept only a complete versioned result +after native exit/output close. Account for native output and terminal event +sequence numbers: retained-output eviction or a capped response's `closed` flag +cannot establish completeness. Reject missing/oversized/invalid output; terminate +and confirm exit/output close when rejection precedes settlement. Keep the existing +retained wait and owner failure on native uncertainty; never retry unknown work. +Private directory support alone does not enable a +daemon control operation, public Files route or capability advertisement. + +Bound encoded request payloads to 8 KiB and correlation IDs to 128 bytes before +admission. Do not echo oversized IDs; omit oversized trace metadata in replies. +Bound raw control results to 1 MiB within the existing 4 MiB transport frame; the +native hook's separate 8 MiB bound is unchanged. Neither is a pinned public protocol +limit. Successful reads require complete bytes/truncation and acknowledged native +close. Safe native rejections carry no bytes; interrupted or ambiguous reads remain +unknown and stop further reads on that owner. Local RPC reap never establishes file +settlement. Retain a dispatched read's original bounded waiter across observer +cancellation and resource transfer/release; stop new admission on resource closure. +The gateway bounds subscriptions and never retries or replays on reconnect. Duplicate +pending operation IDs cannot start another read; this control does not promise durable +idempotency or result recovery. Preparation/Run ownership, public path authorization, +remote retirement and future Claude placement retain their separate requirements. + +The private [raw Files composition](services/agents-api/tests/native/raw_files/README.md) +reuses the pinned native socket client and the same typed Files/registry fixture. +Record its fixture-only workspace dependency patch separately from the manager +hook and third-party versions. Its finite real Files/history workflow does not +qualify the client's internal unbounded event queue for production. Client closure +is not runner shutdown; join the stock runner before reporting owner teardown. +Bounded native stream checks retain the original whole-file assertions. A truncated +read's asynchronous close and stream EOF are not operation-retirement receipts; +keep production caller-detachment and path-admission qualification separate. +Its dedicated cancellation scenario distinguishes native interruption from command +retirement. Target native background termination only by observed current-Turn +item/process identity, and verify Files plus retained interrupted history through +the maintained native client before any production ownership or public admission. + +The optional Codex file installer runs through the existing native process interface +with bounded stdin chunks, declared length and a SHA-256 commit trailer. It fills +the demonstrated native hard-link overwrite and whole-message size gaps; it is +not a second filesystem service or public admission. Reuse held-directory traversal +and existing rustix directory-relative operations for replacement. Keep preparation, caller +authorization and uncertain mutation recovery in their existing owning layers. +Require an existing disjoint staging directory on the destination filesystem. +The operator must protect that directory and its ancestors from native tool and +background-process writes; same-user mode bits alone do not do so. Use separate +native filesystem policies for the installer and workspace tools, with a dedicated +staging directory per Environment. The qualified installer sees one writable parent +containing only that Environment's workspace and staging; tools retain workspace-only +write access. Keep history, credentials and other tenants outside that parent. +Separate sandbox bind mounts may reject rename even on the same backing filesystem; +never fall back to copying. The helper cannot attest that placement rule; +public admission must establish it. Concurrent workspace writers need not be +globally stopped to protect staged bytes. Temporary-file cleanup is best effort. +A queued stdin receipt, missing helper result or process termination is not a file +commit receipt. See the [installer contract](packages/codex-executor/README.md#scoped-file-installer) +for private limits, cleanup, metadata and concurrency semantics. + +The private harness file socket admits writes only with a frozen operator helper +and staging binding; read-only preparation removes that binding. Workspace and +staging must be distinct siblings under one non-root Environment parent, and the +helper must be outside that writable parent. Receive the complete bounded body +before starting a native process. Transfer 64 KiB chunks plus the digest through +one captured native process; require Linux sandboxing and an exact versioned +commit result with successful exit and complete output closure. Native queued +stdin is not a commit. Caller detach does not cancel admitted work; uncertain +input, output or deadline stops the existing owner without replay or replacement +claims. Public Files.create, trusted placement admission and durable mutation +recovery remain separate work. See the [private transport contract](packages/codex-harness/README.md#private-file-writes). + +The private [retirement qualification](services/agents-api/tests/native/retirement/README.md) +separates native connection/processor shutdown from already admitted filesystem +work. Its hashed test-only scheduling overlay is not a production native patch. +Do not admit a replacement writer based only on socket closure, task cancellation, +command exit or a Core lease change. Require an actual executor mutation-drain or +enforced placement-retirement boundary; retain uncertainty across recovery when +that boundary cannot be established. Native source evidence, instrumented mechanism +tests and uninstrumented real execution remain distinct acceptance claims. +The opt-in whole-placement fixture uses an exact task-owned Docker instance and +cgroup/process observations before successor writes. This is a local-filesystem +qualification, not an authenticated remote retirement receipt or public admission. + +The explicit `parsar-daemon placement enroll/retire` operator commands own the +first local Runtime retirement consumer in `internal/agentdaemon/placement`. +They use a fixed local Docker socket, an exact labeled container/incarnation, +private durable state under `~/.parsar/placements`, and a per-target process lock. +Enrollment is limited to the documented unprivileged Linux/cgroup-v2 local-storage +profile. Keep controller state and the canonical Docker socket outside generated-code mounts, +including when a workspace source is a filesystem root. Every source must reside +on a whole-filesystem host mount whose device appears exactly once in the controller +mount namespace; bind aliases, subvolume roots, stacked mounts and missing mount +evidence are unqualified. This bounded profile does not resolve arbitrary mount graphs. +Stopping, independent membership/process observations and non-forced removal must +precede a durable successful receipt. Recovered receipts must complete their directory-sync barrier before success. +Missing evidence or a crash after removal +but before receipt persistence remains unknown; never clear it based on absence. +Optional `--environment` enrollment freezes one canonical Environment UUID in a +version-2 local receipt. Every scoped retire/reconcile must match it before any +supervisor access or completed-receipt recovery; omission cannot bypass the check. +Version-1 unscoped records remain unscoped and cannot be adopted by a scoped retry. +Older controllers reject version-2 records. Enrollment is trusted operator consent, +not verification of Core resource existence or tenant ownership; the future Core +consumer must validate those using its existing authenticated associations. +Normal harness release is unchanged. This local operator command is not Core +admission, authenticated remote receipt support, or public Files compatibility. +See the retirement fixture README for the profile and explicit native acceptance. + +Connection observations use the existing execution lease and Session lock. A +separate `environment_connections` row retains the current generation and revision; +`environments.status` and its Session Environment-event snapshot commit together. +The producer serializes replacements, then numbers socket observations within each +generation. Duplicate or older revisions and superseded generations are inert. +Replacement retires a previously connected observation before publishing its new +registration. Registration alone creates no connected event. Event payloads contain +only public Environment identity/type/status and nullable error, never configuration, +credentials, registration IDs or revisions. Transport observations have no asserted +Turn association. `connected`/`disconnected` are distinct from native preparation +readiness; do not cast resource `expired` into the event vocabulary or emit `ready` +for a self-hosted connection. + +Registry writes run synchronously outside the relay mutex, with a four-second +operation budget independent of client disconnect. Shutdown closes sockets, shares +one four-second budget across captured disconnects, and drains accepted writes +before the Worker releases its lease. Missing/deleted/terminal targets retire only +the matching connection; other write failures are logged, retained by +`LifecycleError`, and close the registry until restart. No successful persistence +or continuous connectivity is inferred after a failed write. Before starting +connection producers, a new Worker clears old generations and records disconnected +state for old connected observations in batches of 32. Deleted resources stay +hidden. Stop old writers before migrating/deploying this lifecycle; downgrade +refuses to discard retained generation fencing. Metadata reads and full lifecycle conformance remain separate requirements. + +The optional [Codex executor launcher](packages/codex-executor/README.md) is a +separate Cargo package. Pin its native git revisions, transport patches, toolchain +and lock; use the upstream executor/auth/runtime APIs without changing stock CLI +credential protection. It reads only an explicit principal-key credential file with an optional exact-Environment restriction +and uses the matching installed native binary/resources for hidden filesystem and +sandbox helper modes. Keep its state below `~/.parsar/`; do not load ambient +OpenAI login credentials. HTTPS certificate/hostname verification remains enabled. +The separately named command does not establish stock-command compatibility or +enable public Environment admission. Linux x86_64 is its initial deployment target. + +The executor build also provides a small `agents-api-codex-directory` helper for +bounded, descriptor-scoped directory observations where the pinned native walk +cannot maintain path isolation during concurrent ancestor replacement. Use the +existing native process API, explicit argv and a qualified read-only sandbox; +keep the executable at a trusted operator path outside the writable workspace. +The adapter supplies its frozen workspace root. Enumerate and stat using retained +no-follow directory descriptors, bound scanning before collecting all names, and +require complete output plus native exit/close settlement. This is a private +adapter prerequisite, not a new public protocol, transport or filesystem framework. +The helper alone grants no tenant authority, public Files admission, snapshot or +workspace-replacement guarantee. Preserve the stock executor/model loop. + +Native app-server placement is a prerequisite to typed dispatch. The pinned Codex +app-server accepts registry configuration at startup; use explicit native Environment +selections for the first thread and every Turn. Resume does not restore selections +from history. Keep its process cwd and persistent `CODEX_HOME` local, separately +from the executor cwd. Readiness and observed tool/file results are required: +a completed native Turn alone does not establish successful remote execution. +Apply an intentional native shell environment policy; upstream defaults do not +filter all credential variables. Filtering is not process or filesystem isolation. +The opt-in [placement probe](services/agents-api/tests/native/README.md) documents +its real-provider prerequisites and limits. It does not enable public admission. + +The private daemon `remote_environment` descriptor carries Environment identity, +executor workspace and transient native connection URL/token. `WorkDir` and +`CODEX_HOME` remain harness-local. The selected adapter owns the connection +protocol; Codex Noise configuration and native Environment selectors never enter +the API core. Do not persist the connection token in configuration, events or +completion metadata. Existing private provider configuration and device profiles +have separate credential ownership. + +The initial Codex adapter advertises this mode only for the verified 0.153.4 +protocol, propagating the capability through the real heartbeat/gateway. It checks +native remote readiness and absence of local fallback before starting a thread. +Supply a stable state key, strict resume and completion release: each prompt owns +one harness, and a bound Environment permits one harness connection. Send the +native selection on first thread creation and every Turn; cold resume does not +restore it. Reject conflicting native transport settings, unsupported engines or +versions, non-POSIX executor paths, and local managed Skills/MCP/plugins/authoring +or attachments. Apply explicit core shell inheritance and credential exclusions. + +Codex preparation initializes the existing RPC child and verifies environment +readiness without creating a native thread or starting model work. It carries no +RunID or prompt; the existing Factory resolves its state key before using the same +Prepare/Start implementation. The resolved plan, tools and resume identity are +fixed during preparation. Start accepts the actual RunID, prompt and output sink, +checks remote status on the retained RPC without reconnecting, and transfers that +resource once to the normal Session. Failed start or abandoned preparation closes +the child and cleans temporary plan resources; deferred preparation Close is inert +after transfer. The owner context spans the whole harness lifetime, while startup +operation deadlines remain separate. Owner cancellation/RPC exit release pending +resources; executor loss is checked at Start, not continuously monitored. This +adapter seam does not provide public admission or a new scheduler. + +Every executable preparation must implement `PreparedCancellation`; read-only +preparations may implement only `Prepared`. The Router rejects and closes an +executable preparation before `ready` when that contract is missing. Codex fences +future Start under the transfer lock; unused resources use preparation teardown, +while transferred resources use the existing Session cancellation path inside the +adapter. `Close` remains inert after transfer. +`CancellationOutcome` exposes observed content, Usage and verified native identity; +an unstarted resource has no measured Usage or observed resume identity. This is +best-effort cancellation and an observed snapshot, not immutable final output, +notification drain, caller-deadline compliance or remote process quiescence. +From Start admission onward, the exact `PreparedCancellation` object is the sole +release target: a late Session never receives fallback `Cancel`, and the Router +never falls back to preparation `Close` or owner-context cancellation. Successful +`Cancel` means local cleanup is complete and no more output writes can occur. A +failed or timed-out call returns without waiting for output, retains execution +ownership, and permits only a later explicit serialized retry on that same object. +Before publication, failed cleanup also retains the preparation slot. Successful +publication permanently transfers resource tracking to the Run; subsequent release +does not restore preparation ownership or make its old handle cancel that Run. +Forwarded permission and user-choice observations from that cancelled handoff do +not register actionable interactions. Codex prepared cancellation also waits for +the transferred Session's local cleanup, which can finish after output closes; +ordinary Session cancellation retains its existing behavior. +One prepared-output consumer starts before `Prepared.Start`, so native output beyond +the 64-frame channel capacity cannot deadlock Start. It retains the first terminal +frame, drains later output, and forwards accepted frames before the observed cancellation +outcome receipt. Missing capability, failed cancellation or failed forwarding cannot +produce an applied receipt. An unused resource may supply an empty observed outcome; +the Router never fabricates one. + +The returned Session remains private until the `started` status send succeeds. The +handoff has one permanent release claim, one current native attempt and one +success-only settlement. Function results, permission decisions, user-choice +decisions and steering admitted before the claim hold the same operation barrier +through native submission, replay bookkeeping and receipt delivery. Natural +completion waits for Start publication before cancellation; an abort wakes that +same attempt and may fence a concurrent Start. The initial started-status send is +bounded by the preparation deadline, which is rechecked at publication commit. +Delayed expiry callbacks cannot cancel a successfully published handoff. The successful attempt waits for Start and the sole output +consumer, then forwards the retained terminal frame and removes the Run. Internal +paths join the current attempt; only an explicit cancel, Release, expiry, device +shutdown or later Router Shutdown may retry a failed attempt. Workspace reads require +a published, open Session but keep their independent bounded lifecycle. + +Receipt settlement waits at most ten seconds, with a separate five-second send +budget and the existing gateway settlement deadline. A timeout does not free the +resource or stop tracking late cleanup. Shutdown cancels receipt waits while owned +Start/cleanup work remains tracked. This ordering covers cancellation received while +Start is pending; ordinary post-transfer cancellation, complete native output and +remote process exit retain their separate limitations. + +The private daemon preparation controls reuse execution configuration but reject +input, RunID, Conversation, attachments and product authoring. The initial profile +requires a remote environment, stable state key, strict resume and completion +release. Its separate capability is registered through an execution-only factory +and preserved through the product registry wrapper and heartbeat mapping. Native +details remain inside the adapter; this private profile does not narrow upstream. + +Preparation request IDs correlate only control responses. The daemon returns a +fresh opaque handle before slow work; Start supplies that handle and the actual +RunID/prompt. Handles belong to one daemon connection. Gateway preparation +subscriptions do not register Runs. Per-handle revisions order asynchronous status +snapshots; reject responses describe control errors without inventing run events. +At most four native preparations may be preparing, ready, starting or closing. +Start admission expires five minutes after acceptance; retries do not extend it. +Failed cleanup retains the native resource and its capacity until Close succeeds; +terminal handles with retained resources cannot be pruned or started again. +At most 64 request records are retained; retired request IDs may allocate a fresh +handle, while old handles cannot consume replacements. This is not durable +exactly-once preparation or cross-connection recovery. + +Preparation and Start execute outside the receive loop and router lock, with +tracked lifetime work. Start reserves the real RunID and fixed cancellation target +before native work; cancellation in that phase uses the adapter contract across +the transfer. A late result cannot resurrect released ownership. Shutdown claims +or retries every prepared release under the lock before closing its cancellation +signal. A failed native attempt returns Shutdown without waiting on an output +consumer that may still be blocked; a later Shutdown retries the same target. +Successful publication stops the preparation deadline and uses the same prepared +output consumer and completion release; +later preparation Release cannot cancel that Run. Released/expired status makes +the handle unusable; asynchronous native cleanup still counts toward capacity and +does not promise immediate OS quiescence. Release retries retained cleanup. +Concurrent Shutdown calls join one tracked attempt within their caller deadlines; +a later call retries failed preparation cleanup and reports any remaining error. +A caller timeout does not discard ownership or repeat in-flight cleanup. These +records remain connection-local, not a persistent remote retirement fence. +The public idle-text path uses this +admission/start wiring; complete Environment lifecycle remains required work. + +This daemon slice keeps existing best-effort cancellation and harness cleanup. +Native detached-session cleanup may stop remote commands after a delay; an applied +receipt is not immediate process quiescence or complete final output/Usage. The +opt-in registered-daemon test independently observes PID exit and stopped heartbeats +while the daemon, registry and executor stay alive. Targeted process termination, +cross-Turn background preservation and complete public cancellation/lifecycle remain +separate work. The public idle-text profile has its own built-service acceptance; +an adapter probe alone does not establish public compatibility. + +The private Dispatcher can execute a pending Environment input on an already bound, +capable daemon. It requires the current leased Store before resolving transient +connection credentials. A typed callback supplies only the URL, token and release; +native registry types remain outside execution code. Derive Environment identity +and workspace from Store ownership. Retain the same physical peer and preparation +handle through readiness, atomic promotion/claim and the first non-replay Start. +Initial prompt/cursor come from the reserved batch and its receipts; later messages +use ordinary steering. Never hold a database lock during native preparation. + +Observe the original pending deadline, cancellation, deletion and peer loss while +waiting for readiness. Preparation failure leaves pending input and its deadline +intact unless storage has settled it; it creates no failed Turn or input history. +During Start, consume preparation controls alongside the ordinary Run stream so a +control-only rejection or pending-start cancellation can settle promptly. Reuse +ordinary journal, receipt and completion/native-history persistence. Once cancellation +is sent, preparation errors/closure cannot replace its receipt or timeout path. +Pending-start cancellation uses the adapter's observed outcome after daemon handoff +and output forwarding. Missing or unconfirmed outcomes still fail conservatively; +preparation control errors cannot substitute for the cancellation receipt. +Do not fabricate an empty cancellation outcome or infer native quiescence. +The connection owner spans preparation and the transferred Run without a reservation-derived Run +deadline; every exit releases it. + +The existing Worker discovers pending input with a connected, non-revoked device +in the same tenant when its Dispatcher has a connection resolver. An unbound +Session selects a device using the same engine capability checks as ordinary +work, including remote preparation support, then uses the existing immutable +binding before preparation. Existing bindings never move, including when their +device is offline, revoked or lacks a required capability. A missing device or +binding conflict leaves pending input and its deadline intact without a Turn; +other database/ownership errors stop the Worker. Preparation, claim, Run and cleanup occupy one of the same four slots as ordinary Turns, keyed +by Session. Both queues advance bounded ID cursors and alternate candidates; the +pending queue is scanned at most once every five seconds on the existing tick. +A preparation failure may retry while still pending, without extending its stored +deadline. This is private scheduling policy, not an upstream timing guarantee. +Unknown promotion results and errors after admission stop scheduling; existing +claimed-Turn reconciliation handles restart without another Start. Expiry retains +its ordinary cadence even at capacity. Public idle-text admission and principal +identity are implemented; initial inputs and complete public lifecycle remain separate. + +The standalone service wires this resolver when its daemon gateway and +`AGENTS_API_EXECUTOR_URL` are configured. Construct the gateway and native registry, +configure the Dispatcher, then acquire Worker ownership before starting scheduling +or HTTP consumers. Registry construction does not call the ownership callback; +its Worker reference is assigned once before either consumer starts. Invalid +registry configuration therefore fails before acquiring the execution lease. +Shutdown waits for Run cleanup, drains registry observations while the Worker +still owns its lease, then releases execution ownership and the gateway. +The Codex resolver issues a fresh exact-Environment credential for the supplied +execution owner; it does not admit public Environment input or +define a transport for other engines. + +#### Independent build artifacts + +`make build-agents-api` produces `agents-api`, `agents-api-migrate`, +`agents-api-device` and `agents-api-environment-key` under `${PARSAR_HOME:-$HOME/.parsar}/build/agents-api`. +`AGENTS_API_BUILD_DIR` may select another absolute output directory. The build +uses only the explicit source set in `scripts/build-agents-api.sh`: the execution +service, its Go contracts and required shared daemon/logging packages, plus the +root Go module manifests. Product server/frontend, other applications and their +migrations/assets are absent from the temporary build context. Keep this boundary +explicit when introducing shared dependencies; do not copy the whole repository +to make an accidental product dependency compile. + +The build uses Go directly with workspace discovery and CGO disabled, read-only +module manifests and trimmed paths. It requires no Node, Docker or product setup. +`make check-agents-api` runs this build before its tests, so the full `make check` +and the dedicated CI workflow enforce the same boundary. CI exercises the built +migration command and uses the built server for official-client HTTP checks. +`make docker-build-agents-api` reuses that build for Linux amd64 and sends only +its four executables and `services/agents-api/Dockerfile` to Docker. The pinned +Distroless runtime runs without root, a shell, product assets or an embedded +harness. Keep runtime credentials outside the image and migrations explicit. +`make check-agents-api-container` runs the existing official-client suite against +the image with a read-only root filesystem; it requires Linux Docker, a non-root +host user, the pinned SDK and a dedicated execution test database. Dedicated CI +runs this after binary validation. Changes to the image/build path require this +check in addition to `make check`; do not make ordinary Go builds require Docker. +Registry publication, additional runtime architectures, daemon packaging and +product cutover remain separate work. + +`make build-agents-api-release` reuses the isolated build for a Linux amd64 archive +under `~/.parsar/`, with its four commands, license, operator guide, source/tree and +protocol manifest, and file/archive checksums. It requires clean committed source +and Python 3.9+, stages output privately, and packages fixed artifacts deterministically. +Keep runtime configuration, credentials, product sources and separately installed +daemons/harnesses out of the archive. Archive changes require content/hash and +fresh-extraction operator checks plus `make check`; execution acceptance uses the +packaged operators and public protocol, not private Store provisioning. Preserve +the database and native history when replacing the API package. This target does +not publish a release or provide an installer/supervisor. + +`AGENTS_API_RELEASE_RUNTIME_IMAGE=sha256:` adds an explicitly qualified +Linux amd64 Runtime to the same release builder. The Docker archive contains that +immutable image export, the committed seccomp policy and hosted operator guide, +with hashes in its manifest and checksums. The default Core-only archive remains +Docker-free. Image selection and packaging do not qualify an arbitrary image or +prove compatibility between unrelated Core/Runtime versions: accept the exact +package with a fresh database, extracted binaries, loaded image and real public +workflow. Keep model/operator credentials external and Provider ownership stable +across upgrades. This is the same managed Runtime, not user-managed enrollment. + +#### Current implementation + +The constraints below describe existing code, not requirements to preserve legacy +design. The [protocol assessment](contracts/agents-api/README.md#implementation-direction) +identifies replacements and gaps. Update these rules when their implementation is +replaced; do not carry obsolete compatibility code forward to satisfy this section. + +- `internal/agentdaemon/gateway` is the shared daemon connection implementation. + Its persistence interfaces use `internal/agentdaemon/device`, never product Store + types. Product adapters live in `server/internal/agentdaemon`. Keep protocol + frames in `internal/agentdaemon/proto` until the contracts directory migration. + Store aliases preserve existing callers during this transition. +- Session deletion uses a durable `sessions.deleted_at` marker, committed with an + existing Turn cancellation request under the tenant Session lock. Public reads, + metadata changes, event streams and input admission exclude deleted Sessions; + admission checks visibility under that lock before retry lookup. Creation keys + remain reserved and cannot resurrect deleted Sessions. Missing/repeated deletion + locally returns 404 and reuse of a deleted creation identity returns 409; exact + hosted errors and overlapping stream timing remain unverified. Existing streams + close when removal is observed without a fabricated deletion event. + Internal Turn/receipt/finalization and restart reconciliation retain access so + hidden work can settle under the existing execution lease. Queued deletion + prevents claim; an already claimed execution may complete or receive cancellation. + Confirmation does not guarantee native quiescence. Never revoke a shared device, + remove a saved Agent or touch product data as part of Session deletion. Physical + SQL/native history cleanup remains a separate required implementation gap; these + records are retained, not claimed purged. Do not deploy a pre-deletion service + against a database with deletion markers; migration rollback refuses to remove + the column while deleted records exist, preventing public resurrection. +- `services/agents-api` owns its SQL schema, sqlc queries and embedded goose + migrations. `AGENTS_API_DATABASE_URL` is required; never fall back to the product + database URL. Its first persistence slice stores tenant-scoped Sessions with a + stable engine and idempotent creation. It does not switch production execution. +- Reusable Agents have their own tenant-scoped `agents` records, independent of + Session snapshots, engine bindings and product Agent definitions. The Store + persists caller-validated non-secret configuration and metadata without applying + harness capability restrictions or model defaults. Resource identity and equal + initial creation/update timestamps come from the persistence boundary. The + create primitive creates a fresh resource; public retry conformance remains + unverified. Internal storage admission is 512 KiB for configuration and 64 KiB + for metadata, not a claim about upstream limits. +- Vaults have their own tenant-scoped records in the execution database. Initial + `POST /v1/vaults` and `GET /v1/vaults/{vault_id}` operations persist and read the + resource without creating Sessions or contacting an engine. Omitted name is + null; a supplied non-null string is trimmed and limited to 1–256 UTF-8 bytes. + Omitted/null metadata becomes an empty object. Reuse the 64 KiB encoded metadata + storage bound, without applying Session-specific pair/character limits. This is + a local bound, not hosted parity. `GET /v1/vaults` lists the authenticated project's + records using creation-time/ID keysets, default descending order and a default + limit of 20 clamped to 1–100. Other resource limit policies are unchanged. + Status accepts `active`/`archived` as a scalar or SDK `status[]` array, with both + included by default. Private stored classification defaults existing/new rows + to active; it is never exposed in the Vault response. Listing reads no Credentials + and needs no encryption key or execution service connection. Mixed status encodings + and repeated scalar parameters are rejected locally. Exact hosted errors, equal-time + ordering and changes between pages remain unverified. Private archived fixtures + prove filtering only: there is no public archive writer, archive timestamp or + inferred delete-to-archive behavior. Retrieval, Session binding and dispatch retain + their existing rules. Archive/revocation lifecycle remains a separate gap; + do not introduce product roles or speculative lifecycle fields. Migration rollback + refuses to discard classification while archived rows exist. +- Vault `DELETE /v1/vaults/{vault_id}` removes the project-owned parent and all + Credentials through the existing foreign-key cascade in one SQL mutation. Do not + loop through child deletions, decrypt secrets, require the storage key or call + providers. Deletion applies to both stored classifications. Local missing/repeated + deletion returns not-found; subsequent parent/child reads and new attachments + cannot use the removed resources. Preserve Session snapshots, frozen choices, + history and recorded retries. Later secret lookups fail without selecting another + attached Vault or anonymous MCP. Already-resolved tokens and running Sessions + are not revoked. Exact hosted archive, visibility, overlapping-mutation and error + semantics remain unverified; row removal does not prove physical storage erasure. +- Static-bearer Credentials are children of tenant-owned Vaults in the execution + database. Creation admits the owner in the same SQL statement as the insert; + retrieval joins the owning Vault and selects public metadata only. No public + operation decrypts or returns a token. Encrypt before passing secret values to + SQL, using the execution service's separately configured random 32-byte key and + the standard library's random-nonce AES-GCM. The versioned authenticated binding + includes tenant, Vault, Credential, authentication purpose and exact destination. + Never reuse product master-key conventions or daemon transport encryption for + this storage boundary. Missing key configuration disables credential writes; + malformed explicit configuration fails startup. See + [`services/agents-api/credentials.md`](services/agents-api/credentials.md) for + key persistence and current limits. OAuth and storage-key rotation remain + separate gaps; resource creation never contacts the destination. +- `GET /v1/vaults/{vault_id}/credentials` lists safe metadata only, with both + project and Vault ownership enforced on the parent, cursor and row query. An + inaccessible parent returns not-found, even when the collection would be empty. + Reuse the Vault status/limit parser and Credential metadata mapping. SQL must + never select ciphertext for listing; no encryption key or execution is needed. + Credential status is a separate private active/archived classification, defaulting + historical/new records to active and never derived from the parent Vault's status. + Synthetic archived fixtures prove filtering only. There is no public archive writer, + timestamp or delete-to-archive inference; existing create/retrieve/token replacement, + Session bindings and dispatch keep their rules. Migration rollback refuses to lose + archived classification. Archive/revocation lifecycle, OAuth and hosted + query/concurrency semantics remain gaps. +- Credential `POST /v1/vaults/{vault_id}/credentials/{credential_id}` replaces only + the static-bearer token and update time. Require `auth.type=static_bearer` and a + string `auth.token`, preserving opaque bytes; reject extra mutation fields before + writing. Reuse safe metadata for the immutable encryption binding, then scope the + atomic SQL mutation independently by tenant, Vault, Credential, static auth type + and exact destination. Never decrypt the previous token or send plaintext to SQL. + Missing encryption configuration or a failed write preserves the old row. Return + the existing safe metadata projection; identity, name, destination, creation time + and Session snapshots stay unchanged. Subsequent dispatch reads use the committed + replacement through existing scoped lookup; already-resolved requests may retain + the old token. This is not storage-key rotation, in-flight revocation or hot reload. + OAuth and exact hosted concurrent-update/retry/timestamp semantics remain gaps. +- Credential `DELETE /v1/vaults/{vault_id}/credentials/{credential_id}` removes one + owned row, including ciphertext, with tenant/Vault/ID checked in the same SQL + mutation. It needs no encryption key, secret read or network call. Local reads, + updates, listings and subsequent dispatch lookups cannot use that ID; missing + and repeated deletion return not-found. Preserve frozen Session choices, retry + identities and history without fallback to another credential or anonymous MCP. + A token already read before deletion may remain in a dispatched request. Deletion + does not revoke provider tokens, cancel Sessions or prove physical erasure from + native history, WAL or backups. Do not infer a delete-to-archive mapping; exact + hosted archive, post-delete visibility and repeat/error semantics remain unverified. +- Public reusable Agent create/retrieve uses `/v1/agents` and the same authenticated + tenant/Beta-header boundary as Sessions. The resource envelope owns identity, + timestamps and metadata, separately from saved configuration and Session state. + Resolve known defaults and validate supported schema before writing. Preserve + model/name/instructions verbatim, nullable fields and structured JSON numbers. + Stored reasoning/service tiers, enabled multi-agent settings, JSON Schema output + and deferred/tool-search/programmatic tools do not imply execution support. + Reuse function wire validation, keeping Session execution restrictions separate. + Model-derived reasoning effort is unresolved when omitted; do not infer it from + the selected harness. Omitted/null service tier currently uses `auto`; complete + upstream default/error/retry conformance and remaining MCP/web-search variants + remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. +- Public HTTP MCP uses the native harness client and tool loop. The supported + execution profiles are Codex with `environment:none` or `self_hosted`, and + Claude SDK with `environment:none`. Both require an explicit `service` + connection origin and a trusted service-side harness. The execution device is + part of the service deployment; an arbitrary caller executor cannot be relabeled + service-origin. Admission requires the advertised `mcp_http_tools` capability + during selection and again before claiming work. The `self_hosted` combination + additionally requires `mcp_http_remote_environment` plus existing remote preparation + capabilities, including at the daemon before the factory; individual MCP/remote + capabilities on old peers do not imply the combination. MCP stays in the trusted + service harness while workspace commands use the executor. Static Vault Bearer + authentication additionally requires `mcp_http_remote_bearer_auth`; an older peer + supporting anonymous remote MCP and environment:none authentication separately + cannot execute the authenticated combination. Native remote readiness and exact + MCP preflight both precede + thread creation/resume. Other engines and placements remain implementation gaps. + Claude SDK admission additionally applies the supported values described in + [its adapter profile](#claude-sdk-adapter-foundation), including before persistence. +- Keep accepted public MCP credential profiles separate from private adapter + capabilities. The execution service declares the verified public bearer profiles + centrally; a daemon capability alone cannot open a public profile. Reuse frozen + binding validation at admission and later input, then the same MCP capability and + placement checks at device selection, final preclaim and request construction, + before scoped decryption. Native configuration and token injection stay in the + adapters. Add bounded shared checks while changing the related execution path; + do not defer known duplication to a general engine or plugin framework. +- The shared MCP resolver preserves omitted/null `allowed_tools` as unrestricted + and an explicit empty list as deny-all. Saved HTTP transport output includes + `headers:{}`; the effective Session transport omits headers, matching the two + pinned resource types. Saved-Agent updates never change existing Session + snapshots; per-Session tools replace the whole field. The initial profile admits + HTTP(S), boolean `required` (default false), empty/null metadata and empty/null headers. + Static bearer authentication requires HTTPS and the attached-Vault rules below. + Inline authorization, URL userinfo/query/fragment, + other origins and stdio remain explicitly unsupported. +- Codex required MCP initialization additionally needs `mcp_http_required`, advertised + only for the verified native pin and checked during selection, final preclaim + and daemon dispatch/preparation. Preserve the boolean through typed messages, + native rendering and exact configuration preflight. Reuse native required-server + initialization during root thread creation and cold resume; send no native Turn + until it succeeds, and never replace a failed strict resume with a new thread. + Public work may already be accepted/queued/in progress while native initialization + waits. This is not a continuing health monitor or a new public readiness state; + exact hosted Session creation timing and initialization errors remain unverified. +- Send MCP declarations through typed daemon fields, independently of function + callbacks. In Codex, a non-nil declaration replaces operator MCP options; use the existing + native renderer and original tool names for `enabled_tools`, including `[]`. + Before thread creation/resume, query native `config/read` with the exact cwd and + reject additional servers or effective configuration differences. Disable native + plugins/apps and select file-only MCP credentials; reject existing credentials + in the private native home without deleting them or native history. Native + reserved labels are an adapter restriction, not a saved-resource schema rule. + Requests without this typed field keep the existing product behavior. The check + is a snapshot on trusted service compute, not an atomic barrier against concurrent + operator configuration changes. Discovery of a declared deny-all server can still + contact it; deny-all governs tool exposure. Reuse neutral tool observations and + the existing public `mcp_call` projection, never add a second MCP/model loop. +- The private Codex adapter's HTTPS MCP bearer authentication requires + `mcp_http_bearer_auth` and the existing MCP/environment capabilities, checked + before the factory. It is restricted to trusted service-side Codex with + `environment:none` or the explicitly supported authenticated remote combination. A transient + per-server `bearer_token` becomes a fresh daemon-owned `bearer_token_env_var` + reference for each native process. Put the exact secret only in that app-server + child's environment, after auxiliary launch probes; never in global environment, + arguments, configuration/history, public snapshots or logs. Preflight accepts + only the expected server/reference pairing and retains the existing rejection + of ambient credential sources. Remote commands use the existing core-only native + environment policy; service-side bearer variables must not enter executor + environments, commands, files or native history/snapshots. Use the native HTTP + client with TLS verification. + This execution profile rejects empty values and bytes outside RFC 6750 b64token + syntax with generic errors; it never trims tokens or narrows opaque Credential + storage. OAuth and hosted redirect/error equivalence + remain separate work. +- Session `vault_ids` omission/null/empty means `[]`; nonempty attachments must all + belong to the authenticated tenant. Preserve caller order and public MCP + `credential_id`. Saved Agents may store a nullable/nonempty credential reference + without authorizing its use. Session admission resolves an explicit credential + only inside attached Vaults for the exact declared URL, or selects the unique + matching static credential when the ID is omitted/null. No match remains + anonymous; ambiguity is a local 400 and unavailable references use the same 404. + Resolve before any Session, initial input or event write. Freeze safe bindings, + including anonymous decisions, in private Session configuration; never populate + the public credential field from implicit resolution. At actual dispatch, recheck + tenant, attached Vault, selected ID, static auth type and exact URL before scoped + decryption. Metadata queries select no ciphertext; tokens enter only the existing + transient daemon request. Selected authentication requires `mcp_http_bearer_auth` + during device selection and the final preclaim check. Missing/wrong keys or + binding failures never fall back to anonymous execution. Exact URL equality, + immutable selection timing, implicit response population and hosted error/redirect + semantics remain local decisions or unverified gaps. No new MCP loop is permitted. +- Public Agent updates use `POST /v1/agents/{agent_id}` with the same tenant/Beta + boundary and shared saved-field validation. Preserve omission separately from + null; only supplied fields replace saved values. Metadata is a separate whole-map + replacement, with null/empty clearing it. Lock the tenant-owned Agent row while + merging validated fields and enforcing the complete configuration bound, then + commit configuration, metadata and update timestamp together. Never write a stale + full snapshot over another update. No-field updates read without changing timestamps. + Supplied nested fields currently replace the whole field and explicit null uses + existing saved defaults; exact hosted nested/null and no-op timestamp semantics + remain unverified. Model-derived reasoning defaults remain a separate gap. + Neither updates nor retries modify existing Session snapshots or execution state. +- Public Agent deletion uses `DELETE /v1/agents/{agent_id}` and one tenant-scoped + `DELETE RETURNING id` statement. Return the stored canonical ID with + `object=agent.deleted` and `deleted=true`; missing/repeated deletion locally + returns not found. It never deletes Sessions, history or runtime state and does + not cancel accepted execution. Recorded creation identities still recover the + accepted Session; new references cannot resolve an absent source. Historical + identities retain their documented limitation. Exact hosted errors and ordering + of overlapping source creation/deletion remain unverified; no tombstone or + successful result is fabricated for an absent resource. Reject query/body data. +- Reusable Agent listing uses the same tenant/Beta-header and response mapping as + create/retrieve. Page by `(created_at, id)` with a same-tenant saved-Agent cursor; + listing never resolves Sessions, product objects or execution capabilities. + Reuse shared list-query parsing. Agent requests accept positive int64 limits and + return at most 100 records per page with accurate continuation; other resources + retain their current 1..100 request rule. The local default is 20. Return the + list envelope with data/has_more and first/last IDs (null for empty pages). + Exact pinned upstream default/cap, empty-envelope and error semantics remain + unverified; do not present local limits or generic SDK parsing as full conformance. +- Session `agent_id` lookup uses the authenticated tenant. Copy the saved resource + ID and effective configuration into the immutable Session snapshot; saved metadata + does not become Session metadata. Never look up the source Agent when reading or + executing an existing Session. Omitted override fields inherit; supplied objects + and arrays replace whole fields before defaults and execution admission apply. + Reuse saved configuration validation and keep native capability restrictions at + Session admission. Reject unsupported effective options instead of dropping them; + an explicit supported replacement may make a saved configuration executable. + Inline Sessions use the same admission path. New saved-reference Sessions and + inline requests with Vault attachments or credential references record a separate + caller-intent hash: source ID (empty for inline), + supplied overrides (including field presence), environment/vaults, original metadata + and normalized initial input. Exclude response streaming and resolved source values. + Compare that same-tenant retry identity before looking up the source. A matching + retry returns the existing Session without input admission or source revalidation; + ordinary reads include current activity. Stream retries use the row's committed + event cursor and emit no created event. Recheck after source resolution failure + for a concurrently committed creator; do not hold a lock across resolution. + The unique creation upsert remains authoritative when concurrent resolutions differ. + Unrelated inline requests keep their existing resolved/default equivalences. + Recorded credential-bound retries recover before reading mutable Vault contents, + so another same-URL Credential cannot change an accepted selection. Rows with a + known creator but without recorded request intent retain resolved-hash behavior; + original overrides cannot be reconstructed, so no backfill is permitted. Source + mutation-independent retries apply only to recorded identities. Exact hosted + retry/error semantics remain separate work. +- Tenant scope must come from authenticated service identity before calling the + execution Store. Product workspace/user references in metadata grant no access. + Keep credentials and effective execution options out of Session metadata. + Store resolved, non-secret Agent/environment configuration in the Session's + immutable configuration snapshot. Inline and historical creation identities include + the resolved configuration; new saved references use the separate caller intent. + Session metadata updates replace only metadata under the authenticated tenant: + omission is a read, null/empty clears, and a nonempty object replaces all pairs. + Keep execution state, timestamps and the original creation request hash unchanged; + creation retries return the current resource without restoring its old metadata. + Public schema validation belongs to the API; the Store validates JSON structure. +- Session listing optionally filters by the immutable root `configuration.agent.id` + within the authenticated tenant. IDs are opaque and include inline Agents; never + require a surviving saved Agent or resolve product ownership. Apply filtering + before pagination and activity projection, using the tenant/Agent/creation index. + Omission retains unfiltered listing; a supplied empty string remains a filter. + Preserve the existing tenant-owned cursor and creation-time/ID ordering rules. + Hosted empty-filter, mismatched-filter cursor and exact error semantics remain + unverified. Other resource lists do not accept this parameter. +- `make sqlc-generate` and the drift gate cover both services. Run + `make check-agents-api` with `PARSAR_AGENTS_API_TEST_DATABASE_URL` pointing to a + dedicated `parsar_agents_api_*_tests` database for Session integration tests. + CI provides a separate PostgreSQL service. Migration immutability and ordering + apply independently to each service directory. +- Turn writes serialize on the tenant-scoped Session row. An idle message starts + a Turn; messages during queued/running/waiting work belong to that same Turn. + Store input retry identities and order durably. Cancellation retains its first + target, including an idle no-op, so retries cannot stop later work. Queued work + can cancel before dispatch; active work needs an executor outcome. Terminal + states and outcomes cannot be overwritten. Public event admission uses these primitives; live output streams remain separate. +- Input requests are ordered batches committed under the same Session lock. A + retry key identifies the complete ordered batch; changed length/order/content + conflicts and a failed transaction leaves no partial inputs or cancellation. + Existing single-event requests retain their identities at batch position zero. + Internal admission limits are 64 events and 512 KiB of payload per request; + the public API must still validate the upstream event schema. +- The external protocol reference is `openai/openai-python`'s `beta/agents`, pinned + in `contracts/agents-api/upstream.json`. Follow its Session/Turn/event semantics + and verify supported behavior using the official client. Track current coverage + in `contracts/agents-api/README.md`; SDK workflow objects are not this contract. +- Shared supported wire types live in `contracts/agents-api/v1`. `make openapi` + separately generates the product spec and `contracts/agents-api/openapi.yaml`; + never mix their routes or authentication schemes. CI checks both for drift. +- The standalone service uses `AGENTS_API_DATABASE_URL` and operator-provisioned + SHA-256 API key bindings from `AGENTS_API_KEYS_FILE`. Each key resolves one + organization/project and typed user/service-account principal. The internal + tenant UUID is its project resource partition. Before starting the listener or + Worker, atomically insert or verify the configured project-to-tenant bijection + in `execution_project_scopes`; never remap or delete existing associations when + keys change. Configuration requires explicit identities, with no legacy default. + Optional `OpenAI-Organization` and `OpenAI-Project` headers must match the key; + repeated/conflicting values fail authentication. Metadata, forwarded identities + and product session cookies grant no access. Keys can rotate under the same + principal; changing or removing caller bindings requires a service restart. + Every new Session requires an explicit typed creator at the Store boundary, + including internal callers. Public creation derives it only from the authenticated + principal. Persist creator kind/ID in the creation transaction and never rewrite + them on retry, update or source mutation. The tenant remains the project partition; + do not duplicate project identifiers or create a product identity dependency. + Both early saved-reference recovery and the authoritative creation upsert require + matching creator kind/ID before returning a Session or event cursor. Different + credentials for the same principal can retry; another principal using the same + project/key receives the local idempotency conflict. This does not introduce + creator-only resource reads or mutations, or claim verified hosted retry parity. + Pre-migration Sessions retain null creator columns and remain project-readable; + creation retries cannot claim them. Missing creator and missing request intent + are distinct. Never infer historical ownership from keys, metadata or product + records. Retire older API writers before serving the creator-enforced deployment; + mixed-version writers are not supported. Tests must supply explicit synthetic + creators; only controlled historical fixtures may seed unknown ownership. + The operator-selected + `AGENTS_API_ENGINE` is separate from the requested model. + Public execution supports Codex and Claude SDK with environment `none`, plus + the Codex self-hosted text/function profile and the qualified Codex/Claude dedicated + Docker hosted profiles; reject unsupported + input/environment/agent options explicitly. +- `packages/agents-client/v1` configures the pinned official `openai-go` Session + service. Use SDK request/response types, pagination and errors directly rather + than reimplementing transport or copying wire types. Supply an explicit service + base URL/key and creation retry key; SDK retries are disabled by default. Product + integration is a later cutover, not a side effect of constructing this client. +- `services/agents-api/tests/official_client.py` verifies the actual server with + the pinned SDK and strict response validation. It requires a dedicated test DB + prepared by the Store tests and `AGENTS_API_SERVER_BIN`; it never starts Docker. + The same harness runs the official Go client with fresh execution tenants and + checks its created Sessions through the Python SDK. +- Execution devices are operator-provisioned in the Agents API database with + tenant ownership and a credential digest. Their internal daemon gateway uses + `/api/v1/agent-daemon/*`, separately from the official `/v1/agents/*` surface; + device credentials grant no Session API or product permissions. The optional + `AGENTS_API_DAEMON_WS_URL` enables that gateway. It is a single-process registry, + not a claim of multi-pod execution or the public self-hosted executor protocol. + Session/device bindings are tenant-scoped and immutable. Revocation denies new + connections and binding reads; an existing connection closes on its next + heartbeat. Connectivity comes from the live registry, not a persisted online + flag. `last_seen_at` is diagnostic only. Product gateway behavior is unchanged. +- `services/agents-api/internal/execution` dispatches internally resolved Turns + through that gateway. Claim `queued` to `in_progress` before subscribing/sending; + never automatically replay a claimed or interrupted Turn. Ordered extra inputs + require native steering receipts. Commit terminal outcome and native Session ID + together under the admission lock; unapplied messages prevent successful completion. + Resolve credentials separately from the immutable non-secret snapshot. +- Internal execution requires the advertised `durable_turns` engine capability, + strict resume, and optional cancellation receipts + and `release_on_completion` support. Reject unadvertised peers before claiming; + failed strict resumes must not fall back to a new native thread. Release the native writer before forwarding + completion, so the next Turn can resume its durable native ID. For + release_on_completion Runs, close new steering admission and finish all + existing steering receipt sends before releasing the executor and forwarding + Done. Cached input identities and conflicts remain readable while completing; + queue/write success is not consumption. The receipt worker stays busy through + its send, and router shutdown cancels its native and transport waits. + `durable_input_receipts` is required before execution binding/claiming. The + per-input `durable_receipt` opt-in requires release-on-completion and a phased + adapter. Its ten-second transport timer stops only after a complete native write; + a separate `written` acknowledgement stops the API's thirty-second delivery timer. + Neither that phase nor legacy `in_flight` advances the input cursor. Await final + native acceptance/consumption under the Run lifetime without automatic redelivery. + Receipt sends retain a separate five-second shutdown-aware context, and Done + retains a fifteen-second final settlement bound. Once cancellation is sent, its + receipt owns the terminal outcome even if an input becomes unknown first. + Calls without the opt-in retain their existing response deadlines. Existing product + requests retain their default idle-process policy. Native history still requires + the device's persisted engine files; IDs alone cannot restore deleted history. + Cancellation receipts carry the stopped engine's continuity snapshot when no + Done is emitted. Preserve separately reported usage on failure; do not add the + same counters again when Done also includes them. +- The dispatcher is an internal entry point used by the standalone service worker. + Its private `daemon` configuration is neither `environment:none` nor the official + self-hosted executor protocol. Further pending interactions and provider allocation + remain separate slices. Unexpected interaction requests fail explicitly until supported. +- `environment_none` advertises an adapter's explicit environment-disable + path. Execution snapshots with public `environment.type=none` require that + capability and set `disable_execution_environment` on the internal prompt. + For Codex, the daemon forces `CODEX_EXEC_SERVER_URL=none` after caller environment options + and confirms native `local` and `remote` environments are unknown before starting + or resuming a thread. Unsupported binaries fail closed. The bound device hosts + the engine process; it is not a user execution environment. This is not an OS + isolation guarantee, and engine state still lives on that host. Ordinary product + requests retain their existing environment. The public worker selects an authenticated same-tenant engine host for this mode. +- `execution_controls` advertises the typed search/verbosity block on the daemon + prompt. Agents API requires it in addition to the selected engine's required capabilities + before binding/claiming work. Older peers with only option-based capabilities + must not receive controls they would ignore. The API sends resolved search and + text verbosity values; native option names belong to adapters. Codex translates + them using its existing validation/catalog path after cloning operator options, + so explicit controls take precedence without mutating those options. Omitting + the entire block preserves ordinary product behavior; a supplied block requires + both valid fields. This internal contract does not add public configuration or + engine support. Future native adapters must verify the same semantics before + advertising the capability. +- `web_search_control` advertises the Codex adapter's explicit `web_search` option + (`disabled`, `cached`, or `live`). Agents API requires this capability before Codex dispatch; + the typed execution controls force search off on new and resumed Turns. Native configuration translation stays in the + adapter. Product requests that omit the option inherit their existing defaults. + This is tool selection, not a network isolation guarantee. +- Inline Agent `text.verbosity` accepts `low`, `medium` and `high`; omitted or + null values resolve to `medium` in the immutable configuration snapshot. The + Codex dispatcher requires `text_verbosity` support and sends the effective value in + typed execution controls through the Codex adapter for both new and resumed Turns. The adapter queries + the native active catalog with `codex debug models`, checks model support and + pins that catalog snapshot for execution. The probe requires Unix process-group + cancellation; other daemon hosts do not advertise this capability. For models + without declared verbosity support, including the native unknown-model fallback, + `medium` selects native default text generation by omitting the override. The + pinned protocol defines `medium` as the default text amount. Supported models + still receive explicit `medium`, even when their catalog default differs. + Unsupported `low`/`high` and unreadable catalogs fail before model execution; + unsupported non-default levels remain an explicit implementation gap. + Product requests that omit the native option retain their existing defaults. + Structured output formats remain a separate protocol gap. +- `subagent_control` advertises native subagent tool control. Agents API requires + it when resolved `multi_agent.enabled` is false and sends the typed internal + `disable_subagents` policy on both new and resumed Turns. Native translation + stays in the adapter: Codex disables both multi-agent feature generations, + overriding operator feature preferences. Product prompts that omit the policy + retain their defaults. Enabled multi-agent execution and public Subagent + resources remain separate implementation gaps; the Agent tools list is not + proven to enumerate every harness-internal utility. +- Private `observe_subagent_identities` requests discover direct root children + from completed native spawn/resume Items. The Codex adapter verifies exact child + identity, persisted parent and original spawn-source parent against its RPC-bound + root. Use parent-filtered persisted `thread/list`; `thread/read` can synthesize + creation time before persistence. Native fields stay in the adapter. One worker + allows 64 candidates and 64 metadata RPCs per dispatch, four 100-row pages per + lookup pass, and three seconds per lookup. Root terminal content and Usage freeze + before a separate, three-second settlement wait; keep the reader free for RPC + replies and deliver successful observations before Done. Owner cancellation, + missing persistence, overflow, failed spawn and late discovery remain explicit + gaps, never invented identities or public closure. Child lifetime is unchanged. + The leased execution journal projects neutral identity facts in its existing + Session transaction; unrequested observations are rejected. Device and engine + come from the authorized Session binding, not daemon-supplied project ownership. + The service assigns a stable ID unique within device/engine/native identity and + freezes Session, parent, native creation and first-event provenance. Conflicts + roll back the whole event batch; identical or later continuation observations + preserve the original binding. Internal reads enforce project and visible Session + scope. This is a private consumer prerequisite, not public Subagent admission, + lifecycle, child output reconstruction or complete discovery/recovery. +- `function_tools` advertises the optional native function-call bridge. Explicit + prompt definitions become Codex dynamic tools; unchanged prompts carry none. + Requests and ordered text/image results are scoped by Run and native call ID. + Normalize string results into one text part at the public execution boundary; + the internal result carries a typed content array, and adapters translate it + to native content without fetching images or dropping parts. Validate content + before consuming a pending call. Retry identity includes the complete ordered + content and success flag. Reuse + application receipts and conflict detection; a receipt confirms the native + reply was written, not that an external side effect succeeded. Pending calls + end with their Run; the execution service owns persistence and recovery, while + Parsar retains business approval and credential-owner authorization. Do not + map native approval requests to invented official protocol resources. +- Function-call storage is scoped by authenticated tenant, Session and Turn, with + immutable public/executor call identities and arguments. Result admission and + application receipts serialize on the same Session lock as cancellation and + terminal transitions. Store the complete caller-validated result object; wire + validation and native translation belong to their API and execution boundaries. + Identical retries return the saved decision; changed results conflict. Pending + reads exclude applied calls and cancelling/terminal Turns while history remains + readable. Persistence does not imply transparent native-process recovery. + Recording a call moves the Turn to `waiting`; the last application receipt + resumes it. Session reads use one database snapshot for Turn, actions and usage. + Session state events retain their action snapshot, without private executor IDs + or results. Cancelling/terminal Turns expose no actionable calls. A waiting Turn + can fail or cancel before a result arrives; successful execution requires resume. + Actions remain visible until native application is acknowledged. This timing is + an implementation choice, not verified upstream event sequencing. +- Function results can join internal message/cancel input batches. Their explicit + Turn/call identity selects an existing call; admission never creates a Turn for + a result. Save the complete result and its input retry record in the same Session + transaction. Any invalid target, conflicting result or later batch error rolls + back the whole request. Identical saved results remain retryable after termination + without applying them again. The execution input cursor skips function results; + their separate native receipts still determine application. Public result events + validate variant-specific fields and required values before admission; retain + omitted versus null error/output and ordered text/image parts. Inline function + tools resolve into the immutable configuration with explicit + `defer_loading=false`. Validate required strings and parameter objects before + persistence; reject unsupported deferred discovery. Omitted/null/empty tool + lists resolve to no tools. The public worker selects or waits for a same-tenant + device advertising `function_tools` when the Session has functions. + Function results are Session input Items: emit `item.added` without an output + index, and never emit `item.done`, whose upstream union only allows agent output. + Project their public output/error from the saved submission, including missing + versus null fields; native content normalization must not change public history. +- Internal function execution requires an advertised `function_tools` capability + before claiming a Turn. Translate resolved definitions in the execution adapter, + persist declared callbacks before exposing actions, and deliver each saved result + once per live dispatch. Keep its success flag and ordered text/image output; + append a non-null error as a final text part because the native result has no + separate error field. Retain the original complete result in storage. Do not + treat transport delivery as application or automatically replay an uncertain + result. The adapter waits for outstanding application receipts even when Done + arrives first. Waiting Turns still accept execution observations and cancellation. + The public function workflow is verified with the pinned SDK and a real daemon + and Codex process against a synthetic model endpoint. This does not verify + other tool types, deferred discovery or upstream service timing. +- `message_items` advertises native assistant-message observations. Agents API + opts in with `observe_messages` only for advertised peers; ordinary product + requests retain their existing frame sequence. Opted-in text deltas carry their + native item ID, and `output_message` records start/completion, phase and the + completion text snapshot. A snapshot is not another delta; uncompleted messages + remain partial when their Turn ends. Keep these observations in the journal + before projecting public Items. This does not promise daemon event replay. +- Legacy `tool_items` / `observe_tools` raw snapshots remain available to old + daemon callers. New Agents API execution does not request or decode them. +- `tool_observations` advertises engine-neutral tool snapshots. The opt-in + `observe_tool_observations` takes precedence over legacy `observe_tools`: + attach the typed `observation` to existing tool-call frames without `native_item`. + Native adapters own discriminator/status/action translation and preserve raw + structured values; reuse the shared function-result content type. Kinds are + `command`, `mcp`, `function` and `web_search`; observation status is + `in_progress`, `completed`, `failed` or `incomplete`. A present empty function + content array remains distinct from missing content. These are + execution facts, not public Items: the API owns public IDs, schema projection, + lifecycle events and persistence. Product requests that omit the opt-in keep + their frame sequence and fields. Agents API requires this capability before + claiming work and always requests neutral observations. Its Item projector + validates this shared contract and never decodes engine-native tool snapshots. +- Codex callers opting into neutral tool observations also receive `command_output` + fragments with the existing native command identity. The adapter filters the root + Thread/Turn; the service requires an already indexed command in the same Turn. + Journal and Item updates commit with `agent.output.command_execution_output.delta` + events, retaining original fragments and the command's stable output index. + Completion output replaces accumulated drafts; absent completion output retains + observed text. Terminal Items ignore late fragments, and cancellation preserves + partial output without inventing successful command completion. Native text + conversion and output quotas still apply; this is not a byte-complete stdout/stderr + guarantee. Pinned native 0.153.4 also has an early-output subscription window; + missing native notifications/aggregate bytes remain a separate execution gap, + not output to reconstruct from model tool-result prose. Older peers may supply + only completion snapshots. Product requests + without the observation opt-in retain their existing frames. +- Execution observations are written to tenant-scoped `turn_events` in ordered, + idempotent batches before they can back recovery or publication. Keep daemon + payloads intact; this internal journal is not the public SSE protocol. Flush at + least every 100 ms while consuming events and before terminal persistence; + uncommitted observations can be lost on a hard process crash. Terminal outcome, + journal entry and native continuity commit together. Preserve partial text on + cancellation, including frames queued before a separate cancellation receipt. + Do not infer successful completion after a persistence error or stream overflow. +- Agents API uses the gateway's durable subscription; overflow or disconnection + closes it with an explicit error. Product subscriptions retain their existing + best-effort behavior. Journal limits are 512 KiB per payload, 1 MiB per batch, + 65,536 observations and 32 MiB per Turn; terminal persistence reserves one + additional outcome entry. These are internal admission limits, not promises + about upstream API limits or durable daemon-to-service replay. + +- Live Session SSE reads execution-owned `session_events`, committed with the + corresponding input, Item or lifecycle transition under the Session lock. + Store immutable transition snapshots; never render an old event from a later + Turn state. Reuse the API's response mapping and keep internal snapshots out of + wire payloads. Historical index rebuilding emits no live events. +- The notification buffer retains at most 256 events and 64 MiB per Session + after each transaction, retaining a single oversized event if necessary. + Read batches are bounded to 32 events / 1 MiB, with the same single-event + exception. This buffer is not a public replay log: GET begins at the committed + high-water mark, ignores Last-Event-ID, and polls committed events every 100 ms. + Missing sequence positions produce a safe stream error and close; recover via + Session/Turn/Items queries. Socket writes have a five-second deadline and hold + no database connection. Client disconnect releases the handler; comments keep + idle connections alive. SSE does not close merely because one Turn finishes. + +- Session creation with `stream=true` reuses atomic input admission and the live + event loop. The upsert returns its cursor under the Session lock, before initial + inputs; never replace it with a post-commit cursor lookup. A new response emits + one request-local `agent.session.created` with the pre-input resource snapshot, + then committed changes from that cursor. The local creation retry key excludes + response mode: retries observe only later events and admit no work again. Retry + the same request/key with `stream=false` to recover a lost Session ID. GET event + streams retain their current live-only start. Disconnect never cancels admitted + work. Exact upstream created-snapshot timing, POST stream lifetime and creation + retry response semantics remain unverified; the separate SDK one-Turn helper + does not define this endpoint. Do not present local retry behavior as replay. + +- Public Turn retrieve/list project persisted execution state and the immutable + Session Agent identity. Scope both resources and pagination cursors to the + authenticated tenant and Session, ordering by creation time then ID. Do not + expose adapter outcomes, native IDs or raw errors. Failure uses a customer-safe + category; usage is nullable when a complete upstream breakdown is unavailable. Submission uses the separate Session events endpoint. + + +- Public Items list reads a persisted execution-owned projection, updated in the + same Session transaction as admitted messages and journal batches. IDs derive + from the Turn and source identity; the first-observation timestamp and stable + tie breakers never change when content or status changes. Allocate each new + Item's Session position under the Session lock, preserving observation order + for equal timestamps. Allocate a separate zero-based `output_index` per Turn; + inputs do not consume output indexes. Updates and retries retain both values. + Existing indexed history keeps its pre-upgrade deterministic order; missing + original ordering cannot be reconstructed. Cursors are scoped to the authenticated Session. + Terminal Turns expose unfinished Items as `incomplete`, preserving completed + message/tool states independently of the Turn outcome. +- Public history reads use the persisted index; the private pre-Items journal + backfill is retired. Migration 15 rejects unprepared historical Turns before + removing the obsolete indexing marker. Prepare them with release `906069e` + before upgrading, following `services/agents-api/README.md`. The migration + preserves indexed Item payloads, positions, output indexes and source journals; + never mark unprepared history indexed by hand or replay engine execution. +- Project only the declared public Item variants; native adapter metadata is not + a response schema. Preserve structured tool JSON without float conversion. + Completion text replaces accumulated deltas. Item merging must not mutate the + incoming observation or the previous snapshot: public text delta events read the + original fragment after merging, while Items retain the accumulated text. + Copy the content slice before replacing its text pointer. Keep partial output on termination; + do not turn an unfinished call into a successful result. Thinking fragments are + internal observations, not a claim of upstream reasoning-item support. + +- Public `POST /v1/agents/sessions/{session_id}/events` accepts ordered text-message + and cancellation batches through the pinned official client. Preserve individual + input messages in the Item index while deriving text for native dispatch. Batch + idempotency and cancellation targets remain durable; unsupported variants fail + before admission. Session creation accepts initial text as a string + or user-message array through the same parser and admission path. Commit the + Session, initial input, first Turn and Item/event projections in one transaction. + A creation retry returns the existing Session without re-admitting initial work, + including after terminal or later Turns. Omitted/null input retains idle creation. + Creation streaming uses the shared live path above; non-text messages remain a gap. +- Enabling `AGENTS_API_DAEMON_WS_URL` also starts a bounded execution worker. Select + only connected, capable devices owned by the authenticated tenant; bind once and + preserve native continuity. Metadata cannot select a device. Offline work stays + queued and can be cancelled. An engine host is not a self-hosted environment. +- One worker service owns an execution database through a dedicated PostgreSQL + advisory-lock connection. Its execution Store view uses that same connection for + every Session transaction: binding, claim/reconciliation, journal/Items/Usage, + function callbacks/application receipts and terminal/native continuity. Serialize + these short transactions and lease pings; execution transactions have a five-second + deadline including gate and Session-lock waits. Never hold a transaction across + daemon/model work, reconnect the writer or fall back to the pool after lease loss. + The original Store handles public admission and device/auth maintenance on pooled + connections. Execution reads may also use the pool; a read grants no write authority. + At startup, reconcile previously claimed work as failed, preserve queued inputs + and never replay uncertain execution. Shutdown cancels active dispatch and attempts + terminal persistence before releasing the lease; a lost owner cannot commit it. + Lease Close invalidates its writer and waits for pgx connection cleanup within + the caller deadline. A later Close can resume that wait after a timeout. This + drains client resources; it does not acknowledge remote advisory-lock release. + Worker shutdown retains its existing bounded best-effort close policy. + This fences database writes, not already queued daemon commands or native effects. + Native quiescence/reconnect and recovery of unreported outcomes remain separate + gaps; this is not distributed exactly-once side-effect execution. +- Session state and last activity derive from its latest persisted Turn. Queued or + active work is `in_progress`, successful/cancelled work is `idle`, and failures + use a safe public error. The worker does not replace product dispatch, business + authorization, or the separate approval/environment lifecycle work. + +### SDK subprocess ownership + +The shared daemon `clirunner` offers opt-in Unix process-group ownership for +adapters whose SDK launches a native child. Existing callers keep their current +process policy. Explicit cancellation and parent-context cancellation share a +TERM grace period and bounded KILL escalation. An internal reaper also cleans +remaining group members when the direct process exits, even if a descendant +still holds stdout open. During cancellation, surviving descendants keep the +remaining TERM grace after the leader exits. Unsupported hosts reject this mode before launch. + +Owned output pipes remain readable after the leader exits. Consumers must drain +stdout and stderr before calling `Wait`, which joins the cached process result +and closes the readers. `Done` reports leader reaping and group cleanup signals; +it is not a native execution receipt or proof of persisted history. SDK adapters +must close their query, await their native child and drain observations before +publishing completion. Process groups are lifecycle supervision, not OS isolation +or containment of descendants that deliberately leave the group. + +### Harness qualification and onboarding + +Codex, Claude and future harnesses have equal architectural status. The common +Runtime wire protocol and Factory/Session/Prepared interfaces own lifecycle, +input receipts, cancellation, recovery and resource access; each native adapter +retains its implementation and model/tool loop. A new engine supplies an adapter, +a qualified profile in `services/agents-api/internal/engine`, registration and +an independently verified deployment. It does not add engine-name branches to +API handlers, persistence, dispatch or scheduling. + +Agents Core is pre-release. Replace superseded internal interfaces and execution +paths cleanly; do not retain version fallbacks or compatibility shims. Preserve +the pinned official public protocol, valid data and still-used infrastructure. + +The small static profile catalog owns engine-specific public admission and value +limits. Profile callbacks are pure and use existing public/protocol types; they +cannot query business data, decrypt credentials or control native processes. +Public schema validation, qualified engine support and actual Runtime capabilities +remain separate. Runtime advertisements alone never enable public operations. +Shared dispatch checks capability combinations, not a whitelist of engine names. +Harness onboarding does not require feature equality. Verify common lifecycle +obligations and use the same public assertions for each declared operation, +retaining native isolation tests where appropriate. Optional native differences +remain independently prioritized capability/protocol work, not onboarding blockers. +Keep the complete pinned public protocol target and accepted functionality intact. +Never equate accepted parameters with applied native behavior. + +The base daemon `Session` owns cancellation. Permission and user-choice response +methods are optional `PermissionResponder` and `UserChoiceResponder` interfaces; +an adapter only implements them when it emits those interactions. Unsupported +responses receive a negative receipt, never fabricated application. The router +retains its existing interaction routing and retry ownership. + +`execution.Policy` supplies immutable service qualification to HTTP admission, +Worker device selection and final dispatch. Custom service composition must give +the same Policy to `api.WithExecutionPolicy` and `Dispatcher.Policy`. Zero values +use built-in profiles; an explicitly empty catalog authorizes none. Runtime +advertisements cannot add service profiles. No mutable global registration or +compatibility fallback is permitted. The synthetic third-harness acceptance under +`services/agents-api/internal/store` exercises the actual gateway and daemon router; +its fixture under `apps/parsar-daemon/testdata` is never a production engine. +See [the integration guide](contracts/agents-api/harnesses.md) for contract and +operation-specific acceptance and the [onboarding reference](contracts/agents-api/harness-onboarding.md) +for implementation and registration steps. + +MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for +`environment:none` text execution. It reuses the shared lifecycle without public +workspace, functions, MCP or native Subagents. Native configuration disables +file/shell authority and external +capability discovery; the child receives a private Session home and a restricted +environment. Active-input application requires a native ACP receipt, cancellation +settles the process and output, and continuation requires the exact owned native +history. Do not infer history IDs or qualify hosted execution from this text +profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). + +The MiniMax workspace profile retains the published CLI and isolates native +workspace tools behind its standard MCP client. The process and native Session +share one private control directory; public workspace files cannot configure that +process or become privileged project instructions. A trusted adapter-owned bridge +runs the original six tool implementations in the upstream Linux sandbox, with no +unsandboxed fallback. Keep native history bound to the control directory and Files/ +Artifacts bound to the public workspace. Core and shared file helpers remain engine +neutral. This internal MCP transport does not admit public MCP configuration. +Record published CLI and worker-source provenance separately; complete +[workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) before enabling +hosted execution. The standalone companion uses its own npm lock; `make check` +runs its lifecycle tests and script checks, while its exact-source Linux build and +Docker qualification (including `native.test.mjs` under both network policies) +are required when the companion changes. + +Claude hosted functions compose the existing SDK function bridge with the native +workspace sandbox. Only declared function tools and the verified native tool +inventory are available. The bundle advertises this combination separately from +basic workspace execution; function preparations require that verified combination. +External hosted MCP remains unqualified. Function callbacks do not change file, +credential, history, subagent or network authority. + +### Claude dedicated Docker Runtime + +Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use +`scripts/build-claude-runtime.sh` with the existing shared workspace helper build. +See [deployment and engine onboarding](services/agents-api/deploy/claude/README.md). +The helper executables retain their historical Codex names; their local directory, +write and export operations are shared and do not launch an engine. + +`PARSAR_CLAUDE_SDK_WORKSPACE=managed` requires the shared dedicated local binding, +canonical workspace and its same-inode `/workspace` mount, and explicit immutable +network policy. Native history, home and scratch live separately under +`PARSAR_HOME/runtime/claude-sdk`; daemon authentication stays under +`PARSAR_HOME/parsar-daemon`. The trusted image and protected staging directory +remain outside writable workspace roots. No product state or native user profile +is imported. The separate unbound `environment:none` profile keeps its behavior. + +The Docker operator option `nested_sandbox` is false by default. The qualified +Claude image requires it: Docker supplies an init process and permits nested procfs +mounting by removing its outer `/proc` masks/read-only submounts. `/sys/firmware` +and powercap remain masked; the root and sysfs mounts remain read-only, capabilities +remain dropped, and the existing seccomp/no-new-privileges policy remains enabled. +Do not enable privileged mode, weaken the native sandbox, mount host process state, +or apply host-global policy changes. This is an image deployment prerequisite, +not a public API option or an engine-name branch in the Provider. + +The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract. +Registration combines that contract with the verified operator binding. Core uses +an explicit accepted engine profile independently of advertisements. This profile +supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation +and same-history continuation. The separately advertised `workspace_functions` +combination supports declared public functions with text results. External HTTP +MCP remains unqualified here; its existing `none` support is retained. +Native Bash network access uses the harness's HTTP proxy; no alternate networking +or tool loop is implemented by Core. + +Recovery uses the SDK's history APIs. An explicitly supplied native identity must +exist. If Core requires existing history without having recorded an identity, the +adapter accepts only one nonempty native history for the exact bound cwd. Missing, +foreign, ambiguous or metadata-only history rejects before model input. The Runtime +volume and shared Environment/Session binding establish ownership; this lookup +cannot select another Session's home or infer ownership from a model response. + +### Claude SDK adapter foundation + +`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK +and native message translation. The Go `claudesdk.NewFactory` uses the shared +owned process runner and emits the existing daemon delta/error/Done frames. +The SDK owns the model loop. Its narrow stdio protocol carries a start request, +text deltas, function calls/results/receipts, active text input/receipts, usage +snapshots and one terminal result/error; native translation stays inside the adapter. +With `observe_messages`, it also emits the existing neutral `output_message` +start/completion snapshots and tags deltas with the native Messages API message +ID, not the SDK event UUID. Text blocks in one native message share that identity. +The SDK's per-block assistant snapshots replace draft block text; only native +`message_stop` completes the message, without replaying its text as another delta. +Thinking/tool-only messages produce no text Items; interrupted messages retain +their streamed partial text. No phase is inferred from the final result. +SDK/native child release and output draining precede daemon completion. + +`claudesdk.Config.Workspace` is a private, trusted operator binding for one +qualified placement. It enables native Bash/Read/Edit and declared host functions +in the existing SDK loop. The entire factory must already run inside an outer mount/process boundary +that excludes application, daemon and other-tenant credentials and host policy. +The factory does not create that boundary. The dedicated Docker profile below +selects this binding at startup; cwd and request options cannot select its policy. +The workspace, managed history, runtime home, scratch and protected secret roots +must be pre-existing canonical, separate directories. Runtime code and dependency +search paths must remain outside those roots and be read-only in the placement. +The complete packaged runtime directory, including `node_modules`, must not +overlap any bound root. Its bridge uses the packaged `dist/main.js` layout. +Node, the bridge entrypoint and its readiness companion must use canonical file +paths. Dependency aliases outside mutable roots are resolved before use in PATH; +aliases within mutable roots are rejected even if their current target is safe. +The operator owns allocation, exclusive use and retention; a binding is not +per-Session authorization, a tenant boundary or an idle Files owner. + +For this profile, `Config.Env` replaces inheritance for both readiness and +execution, selecting only supported provider/proxy variables. The adapter fixes +HOME/history/scratch, native enforcement flags and tool inventory; the bridge +request contains variable names, never credential values. Native Bash uses the +strict sandbox with no fallback or weaker isolation. Separate native file-tool +permissions and a session tool hook restrict Read/Edit to the bound workspace +and deny protected roots; background/unsandboxed Bash requests are rejected. +The deployment must retain these controls, including the SDK-owned hook. +External MCP and remote-environment combinations remain rejected in this private +profile until separately qualified. The existing `none` profile retains its +behavior. Packaged `workspace_tools` establishes bridge support only, not host +isolation or a public capability. The dedicated Runtime integration composes +public preparation, shared placement quotas, command Items and Files ownership. +`TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a +qualified placement, including effects, cancellation and same-history continuation. + +The private workspace bridge also accepts `prepare` without a prompt. It freezes +validated configuration and resume identity, checks required history, and retains +one native process through the pinned SDK's `startup`/`query` API. Preparation +requires initialization and acknowledgement of the required hooks while the input +iterator remains empty. Its `prepared` receipt permits one later `start` containing +only the initial prompt; configuration replacement, premature or duplicate start +is rejected. Native Session identity and actual tool inventory are still checked +at execution initialization before `input_ready`. Existing direct execution uses +the same observation and completion path. + +Unused EOF, owner signals, invalid control input and native exit release owned +resources before a terminal event. The private `workspace_prepare` runtime feature +identifies this bridge contract only. It does not register daemon preparation, +enable public admission, or project public Environment readiness. Preparation may +write native runtime metadata outside the workspace and perform startup traffic; +it does not prove provider authentication, complete sandbox health or tenant +placement authorization. + +`claudesdk.NewPreparationFactory` privately binds that workspace bridge to +`agent.Prepared`; the existing workspace factory uses the same Prepare/Start path. +Preparation receives configuration and required resume identity without a RunID or +prompt, checks the installed `workspace_prepare` feature, and owns the process until +one successful Start transfers it. The selected configuration and environment are +fixed before returning; a later Start supplies only its actual RunID, prompt and +output channel. Early preparation failure returns without an executing Session or +fabricated completion. The non-workspace direct factory keeps its existing path. + +The preparation owner context spans the eventual Session. Start's context bounds +that operation only, and Close is inert after successful transfer. Abandoned or +failed preparation, owner cancellation and native exit release owned work. The +same output consumer and cancellation settlement follow the resource across Start; +an unstarted preparation has no measured Usage or observed native Session identity. +A cancellation deadline cannot establish cleanup completion while cleanup remains +pending. This adapter ownership seam does not register a daemon capability or +supply per-Session placement authorization, public admission or an idle Files owner. + +The optional private `agent.WorkspaceReader` on this preparation and transferred +Session requires the packaged `workspace_read` feature. It sends bounded relative +paths to that same SDK Query's native `readFile` control. Only the adapter combines +the path with the frozen workspace root; callers cannot replace the placement. +The pinned native read handler awaits file-handle close before its successful +base64 response. The adapter validates bytes and truncation, bounds each result +to 1 MiB and each request to 8 KiB, and admits one read at a time. Its continuous +bridge output consumer retains read receipts during preparation and across Start. +Caller cancellation detaches observation without cancelling the Run or discarding +an admitted waiter; its original deadline still applies. Owner closure stops +admission. Native null, malformed receipts, timeout and interrupted delivery remain +uncertain and stop the owner; local reap is not a successful read settlement. +The SDK's nullable result catches all native/control errors, so it cannot distinguish +missing files from denial or transport failure. This does not provide a public +Files endpoint, snapshot consistency, placement registration or idle owner policy. +The qualified live workspace fixture also checks binary, empty and bounded reads +before input and during real execution, plus effects before cancellation and reads +on fresh-process history continuation. + +The optional private `agent.WorkspaceDirectoryLister` requires the Linux-only +`workspace_directory` bridge feature on the same preparation or transferred Session. +The pinned SDK has no directory-enumeration control; its fuzzy file suggestions are +not an inventory. This narrow adapter operation therefore reads metadata inside the +already qualified co-located mount/process boundary. It pins the configured workspace +root and opens each relative directory component with `O_DIRECTORY | O_NOFOLLOW`, +using `/proc/self/fd` paths anchored to held descriptors. It rejects directory symlink +traversal; `lstat` reports a symlink entry itself without following its target. +The fixed workspace policy requires canonical, disjoint protected roots and excludes +dynamic permission replacement and remote-workspace fallback. It does not implement +an additional permission engine or authorize an unqualified placement. + +Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit +truncation, literal names, kinds, and sizes only for regular files. They do not promise +ordering, snapshots, recursion, or public pagination. Missing and permission errors +are returned only from distinguishable filesystem outcomes; unknown results stop the +owner. Each operation closes its directory and intermediate descriptors before a +successful receipt; the root descriptor remains owned until bridge release. +Caller cancellation, Start transfer and owner shutdown retain the existing workspace +read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated +Runtime integration supplies public placement and ownership. + +With `ObserveToolObservations`, private workspace execution requires the packaged +`workspace_command_observations` feature and emits the existing neutral command +snapshots. Match root, current-query native Bash call/result identities after input; +ignore historical replay, synthetic and child work. Preserve exact command text and +the native per-call textual result, including native rendering or truncation. This +is final native output, not incremental stdout/stderr or reconstructed interleaving. +Native error results are failed; unambiguous structured interruption is incomplete. +Missing results close as incomplete after the observation drain; query cancellation +does not overwrite an already observed native failure. Do not infer an +exit code from rendered text or supply cwd/duration without qualified native fields. +Preparation alone emits no command. Cold continuation must not reissue historical +observations. This private translation does not enable public workspace admission, +Read/Edit Items or Files ownership. + +The private adapter also accepts typed anonymous HTTP and static-bearer HTTPS MCP +declarations on the trusted `environment:none` harness host. The packaged readiness +report must include `mcp_http_tools`; discovery advertises that feature only when +present, and execution +rechecks the installed bundle before dispatching an MCP request. An unchanged SDK +version alone cannot qualify an older bridge. Authenticated private requests also +require the packaged `mcp_http_bearer_auth` feature at discovery and dispatch. +The daemon generates a separate environment reference for each server and launch; +only those references enter the bridge request and native SDK configuration. +The native HTTP client expands them from its owned process environment. Literal +bearers must never enter SDK MCP headers because that configuration enters argv. +Readiness probes receive no per-request bearer environment. Token validation is +shared with the Codex adapter; credential storage remains an opaque-string contract. +Public Claude MCP admission reuses the shared resolver, immutable Session snapshots +and neutral Item/event projection. +The API checks the supported profile before persistence, during device selection +and again before claiming execution; a missing runtime capability leaves work queued. + +MCP queries use the SDK's main-thread Agent definition to restrict model-visible +tools, in addition to empty built-ins, strict MCP configuration, empty setting +sources and default-deny permissions. Permission allowlists alone do not restrict +the native model inventory. Null selects all tools from a declared server; an empty +list selects none. Host functions compose with those selections. Native server +status supplies original tool identities; map their normalized native aliases while +preserving the original names in observations. Native status deduplicates aliases, +so it does not prove a complete original server inventory. A native PreToolUse hook +waits for inventory verification before admitting root calls and denies unverified, +mismatched or cancelled calls. The native Agent restriction controls model-visible +tools; inventory verification is not a barrier before the model request. +Anonymous HTTP declarations explicitly set an empty Authorization header to disable +native OAuth and automatic credential injection. Preserve that header; do not erase +native history or credentials to enforce this boundary. Servers that reject a blank +Authorization header, normalized name collisions and inventory changes during a +query require separate validation; this profile covers static inventories. +Private SDK status/control objects can contain expanded authentication headers. +Read only connection and tool identity fields; never retain, log or publish raw +status/configuration or control responses. Diagnostic projections must whitelist +safe fields. This does not permit filtering actual model/tool output to hide a leak. +The bounded adapter profile currently requires connected servers, reserves the +`functions` label, accepts alphanumeric/underscore/hyphen server labels and +alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote +environments. Required startup is separately qualified by `mcp_http_required`. +All HTTP MCP queries use native SDK startup and an empty input iterator to +confirm initialization hooks. Required declarations additionally check connected +server status before the initial prompt is released exactly once. Pending, failed, +missing or ambiguous required status rejects before input; native startup timeouts are retained without +an adapter retry loop. Normal system/init still verifies Session identity and the +complete inventory before input readiness/tool authority. Optional servers retain +their existing inventory checks without a new pre-input connection requirement. +A Runtime must advertise the concrete required-initialization capability; there +is no fallback to an older execution path. + +Public Claude static-bearer HTTPS MCP reuses the shared +Vault attachment, frozen selection and scoped decryption path. Selection and final +preclaim require the existing bearer capability; shared authentication dispatch +uses capability/placement checks rather than a Codex-name restriction. Missing keys +or failed lookup/decryption never fall back to anonymous execution; an attached +Vault with no matching credential may remain anonymous. These are execution limits, +not saved-Agent schema restrictions or changes to the official protocol. + +Root assistant tool calls and live root user results produce the existing neutral +MCP observations. Correlate actual Session/call identities; exclude replay, +synthetic and subagent work and keep host function receipts separate. Preserve +the exact native `tool_use_result` when one result is unambiguous, otherwise the +per-call result content. Native errors remain observed native errors. The SDK can +replace annotated MCP content with rendered structuredContent and flatten MCP +errors; these observations do not claim original MCP envelope fidelity or hosted +output parity. Do not reconstruct lost fields or infer output from model prose. +Unfinished observed calls become incomplete on shutdown, without claiming that +remote tool effects were cancelled. Rich content, native truncation and asynchronous +MCP task results remain unverified. + +Daemon `connect` optionally registers this factory as `claude_sdk` when the +operator sets `PARSAR_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. +`PARSAR_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves +Node once and checks that exact configuration before pairing; the SDK's bounded +runtime check is independent of legacy CLI version probes. A ready SDK alone is +sufficient to start the daemon. No configuration means no SDK probe or descriptor; +failed readiness reports an unavailable descriptor with a rejecting factory. +Runtime checks establish local readiness, not provider authentication. + +SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently +of the replaceable runtime bundle. Both the entrypoint and managed state root must +be absolute. Background re-execution inherits operator configuration; it does not +persist provider credentials in pairing profiles. Product `claude_code` remains +unchanged. Product registration explicitly opts existing engines into +`WorkspaceAuthoring`; the authoring registry wraps only that opt-in. SDK registration +bypasses product capability-download, skill-upload and workspace-authoring wrappers. +It does not accept caller-supplied environment variables or business write authority. + +The SDK descriptor advertises the validated daemon subset, including durable +Turns/input receipts, text observations, function tools, raw usage and restrictive +execution controls. It does not advertise permissions, product authoring, legacy +raw tool Items, general web-search control or text-verbosity levels. Router admission +for `environment:none` uses the available engine capability, not an engine name. +The independent API selects new Session engines through `AGENTS_API_ENGINE` +(`codex` by default, `claude_sdk` or `mcode`); existing Sessions keep their stored engine. +This remains the deployment default; the optional Core harness extension selects +an enabled engine for one saved or inline Agent configuration. API admission, +device selection and the final preclaim check share the execution service's narrow +engine policy without importing native adapters. Selected engines require the common +durable execution capabilities. Codex retains its general search/verbosity checks; +Claude uses its restrictive profile without claiming those general capabilities. +Idle and initial-input Session creation qualify the resolved configuration before +persistence; saved Agent resources remain independent of engine restrictions. +Claude additionally requires medium verbosity and explicit object-root function +schemas. Function-result batches normalize through the existing shared parser and +reject non-text content before any batch write, preserving pending calls and retry +identity. These are implementation limits, not changes to the upstream contract. +Do not bypass them by dropping fields, changing model identity or fabricating usage. +Operators may configure the daemon provider environment or the existing transient +`AGENTS_API_EXECUTION_OPTIONS_FILE` with adapter-owned `claude_provider` +(`base_url` HTTPS and `bearer_token`). Core forwards these opaque options without +persisting them in Session configuration. The adapter exclusively selects the +provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged. +The `none` public profile accepts only +text, explicit model/system instructions, managed state, exact native resume and +declared functions with ordered text results, and the HTTP MCP subset +described above. It rejects unsupported request +options and disables built-in tools and undeclared MCP discovery. +`DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about +this fixed restrictive profile. Omission does not enable built-in tools. New and +resumed queries use the SDK's empty built-in tool set, explicit function MCP +configuration and allowlist, strict MCP configuration and empty user/project/local +setting sources. Without HTTP MCP declarations, native initialization and real +provider request inventories must contain only the declared host functions. Managed operator policy may further +restrict execution; it must not widen the profile. This limits model tool access, +not native state files or filesystem access by an explicitly supplied host function; +it is not sandbox/file isolation. The private factory accepts typed execution +controls only for disabled search and medium text verbosity. Search remains excluded +by the native tool inventory; medium retains the SDK's default text generation, +without adding instructions or changing caller input. The pinned SDK has no native +verbosity-level option: low/high and enabled search remain explicit implementation +gaps. Missing/invalid fields in a supplied control block fail before native setup; +omitting the block keeps the same restrictive profile. Public engine admission is +qualified separately by the API policy described above. +Use the SDK's history lookup before explicit resume; never fall back to a new +Session. Native files remain device-affine under a caller-selected managed +runtime directory. The launch configuration supplies trusted provider environment; +request options cannot supply environment variables or business write authority. +Omitted, null and empty `system_prompt` map to empty SDK instructions only at this +adapter boundary; null model values and unsupported options remain rejected. + +The internal SDK function-server helper uses the maintained MCP server's public +request handlers and standard Tool/CallToolResult types. It snapshots definitions +and forwards JSON Schema without a JSON Schema-to-Zod conversion; supplied tools +are always loaded. Native call identity comes from the pinned harness's +`claudecode/toolUseId` MCP metadata, independently of request IDs, names or arrival +order. Missing identities and undeclared tools fail before invoking the host. +Return content/error fields unchanged over MCP and forward its per-request abort +signal. The private Go factory connects declared functions through this helper and +reuses the daemon function-call/result interface and opt-in neutral observations. +The native function-server registry must contain exactly those functions. SDK allowlisting admits +only these host callbacks; the host still owns result decisions and any business +permission checks. It grants no runtime-token business authority. + +Function results remain pending after stdin/MCP delivery. A matching live, root +native user tool_result confirms application only when its Session/call identity, +error flag and returned text match the submission. Ignore replayed, synthetic and +subagent messages. Native error text joins the submitted text parts with newlines; +neutral observations retain their original order and separate failure status. +Missing/mismatched receipts fail the execution; do not replay unknown delivery. +Result submission waits at most ten seconds for a receipt and cancels uncertain +execution on timeout. Invalid or unsupported image results fail before consuming +a pending call. Function state belongs to one live Run and ends with it; the +existing router owns receipt retry/conflict handling. This does not establish +crash recovery or exactly-once effects. Public schemas outside MCP's object-root +contract and image result mapping remain admission/execution gaps. + +Each SDK result supplies one native usage snapshot, including reported failures. +`Usage.Raw.claude_sdk_result` holds the latest; queries with multiple native results +also retain all snapshots in order under `claude_sdk_results`. Main-loop `usage` +is per native turn, while query-pipeline `modelUsage` and estimated `total_cost_usd` +are cumulative within the query. Retain subtype/error provenance and earlier +snapshots even when a later failure reports zero counters. Reuse the latest full +snapshot set in Usage and Done; never sum cumulative measurements. Each factory +invocation owns one SDK query, including cold resume, so no prior query counters +are carried forward. Missing native results do not imply zero consumption. SDK estimates stay +in raw evidence, outside the billed cost field; do not select an arbitrary model +or invent missing public token breakdowns. The API does not parse native counters. +Precise public usage projection, unreported costs and crash/partial accounting +remain gaps; the native snapshot alone is not complete protocol Usage compatibility. + +Active text uses the SDK's `AsyncIterable` input, with a fresh +native UUID mapped to each daemon input ID. A native query may fold text into its +current native turn or queue another; one daemon Run can therefore contain several +native turns. Never promise Codex's same-native-turn semantics. Writes, queued +notifications and user-message echoes do not confirm consumption. Only matching +root assistant/partial/result `user_message_uuids` (or the singular fallback) +confirm applied input. Typed mid-turn folds may appear only on the native result. +Preserve that receipt even when the result reports failure. Check pending functions +after the query drains: the SDK may dispatch later-turn callbacks before the +earlier result handler finishes. +Keep the iterator open until every submitted input has a consuming result, even +when an earlier result reports an empty native queue. Close admission before +releasing final receipt waiters; drain and release the SDK/native processes before +one daemon Done. Cancellation resolves unconfirmed pending receipts as unknown and ends the +owned execution. Successful private SDK Cancel waits for owned-process exit and +stdout/stderr drain, then exposes the same settled CancellationOutcome as Done. +It retains native identity verified at input readiness, partial text and observed +Usage even on cancellation/failure; requested resume identity alone is not evidence. +A caller deadline before settlement reports failure/unknown, while cleanup continues. +Settlement precedes terminal publication so router completion cleanup cannot wait +on its own Done consumer. Cancellation releases intermediate event backpressure; +the settled outcome remains readable even when connection loss prevents publication. +A receipt timeout after a full write preserves the process and pending identity +without redelivery; a blocked write is cancelled and released. +The private adapter permits one input awaiting consumption and at most 63 extra +inputs per Run, preserving the native 64-UUID receipt bound. Durable receipt opt-in +separates bounded writes from native consumption waits; calls without it retain +the router's ten-second deadline. Larger input capacity and interrupted-input +recovery remain separate work. Daemon registration alone does not establish public acceptance. + +Public text/function execution, active input, pending-call cancellation and cold +continuation are accepted for the registered restrictive profile. Environment +provisioning, broader tools/verbosity, complete public Usage, image results and +process-loss recovery remain gaps. Managed installation and release publication +remain separate tasks. `make check-cli` also builds +and tests the SDK package, including native output draining; CI selects that check +for changes to the package. Live adapter +acceptance is opt-in and must use a real provider with private credentials. + +### Private Claude SDK runtime artifact + +`make build-claude-sdk-runtime` exports the compiled bridge and pinned production +SDK/MCP dependencies, including the native package for the build host, into a +platform/architecture/libc-specific `.tar.gz` and SHA256 file under +`${PARSAR_HOME:-$HOME/.parsar}/build/claude-sdk-runtime`. `CLAUDE_SDK_BUILD_DIR` +may select another absolute output directory. The production dependency closure +requires Node20 or newer; Node22 is the tested version. Node is operator-supplied +and is not bundled; the bundle is independent of product sources, services and databases. +It does not add Node or SDK assets to the Agents API binaries/image. + +The build validates source manifests with the repository-pinned pnpm frozen +install and compiles into fresh managed staging, never exporting incremental +checkout output. It then uses modern `pnpm deploy` with command-scoped workspace injection +and its dedicated frozen lock. The adapter has no workspace dependencies; keep +that boundary explicit. Do not enable injection globally or replace this with a +custom dependency copier. Export only compiled `dist` and production dependencies; +retain their package metadata, lockfile and licenses. Check dependency links stay +inside the export, pinned SDK/MCP/native versions, native `--version`, and bridge +startup before publishing the archive. Startup with stdin EOF is an import check, +not model execution acceptance. `make check-cli` includes this artifact check. + +Extract the archive into a fresh managed runtime directory on a matching host +and use its absolute `dist/main.js` as the private factory entrypoint. Validate +relocation and real provider cancellation/continuation before accepting an +artifact. Linux x64/glibc with Node22 is the currently exercised platform; +other hosts require their own native acceptance. Do not reuse a bundle across +platforms or libc variants. Automatic Node installation, managed activation and +release publication remain separate work. Operator-configured daemon discovery/registration is supported as +specified above. + +The exported `dist/runtime_check.js` companion is the local readiness contract. +It checks Node20+, installed SDK/MCP/native versions against the package manifest, +contained dependency resolution, native startup, and the exact `dist/main.js` bridge +with stdin EOF. It emits one versioned JSON report without calling a model or +creating Session state. The artifact check reuses this companion and separately +checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` +uses the same operator-supplied Node, entrypoint and environment as execution, +with shared process-group ownership, bounded output and a 15-second deadline +plus bounded cleanup. Both native and bridge probes have five-second limits. +Return unavailable on failed or malformed probes; never forward native diagnostics +or treat local readiness as provider authentication, public capability acceptance +or filesystem isolation. Automatic installation remains separate. + + +### Harness selection and Agent defaults + +Core accepts the optional `agent.x_agents_core.harness` extension through the +saved Agent and inline Session configuration paths. Define the extension once in +`contracts/agents-api/v1`; never use metadata or a competing top-level selector. +Resolve saved overrides before selecting the existing Session engine, and apply +that engine's execution policy before persistence. Omitted selection preserves +the deployment default; explicit unavailable selection fails without fallback. +Effective extension reads use the persisted engine; Sessions without the extension +retain the official Agent response shape. See the [extension contract](contracts/agents-api/harness-selection.md) +for null/retry behavior and operator configuration. + +Hosted engine-to-provider selection belongs to Core composition. Admission and +initial allocation share the mapping; retained allocations use their persisted +provider identity. Runtime images must satisfy their existing qualification rules. +Transient model options are partitioned by engine and must not expose another +engine's credentials. Do not infer an engine from a model name or template. + +Parsar Agents save default model, harness extension and a typed, non-confidential +`config.environment` selector. The Agent form requires an explicit harness before +saving; existing records without one remain unset in read-only views. The Agent +list shows the configured environment type and harness in both table and compact +layouts. The environment selector is product configuration; +it becomes the official Session `environment`, never part of the protocol Agent. +Only a template reference or supported environment selector is stored, not live +container identity or initialization secrets. An explicitly chosen conversation +environment retains its existing override behavior. Otherwise the first message +uses the Agent defaults. Creating an Agent or opening an empty chat performs no +Core Session or container creation. One conversation/Agent binding freezes the +request on first execution; later messages and observer retries reuse it. Separate +conversations get independent Sessions and environments. Edits affect future +Sessions only. Keep the existing product navigation and direct empty-chat composer. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..60d8db904 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax-AI-Dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..258cf8f5a --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +SHELL := /bin/bash +SQLC_VERSION ?= v1.29.0 +SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) + +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-harness check-agents-harness check-agents-harness-native build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime + +help: + @printf '%s\n' 'make build-agents-api Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' + +check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-mcode-harness check-agents-executor check-agents-harness + @printf 'Parsar Core checks passed.\n' + +check-database: + @test -n "$${PARSAR_AGENTS_API_TEST_DATABASE_URL:-}" || { echo 'Set PARSAR_AGENTS_API_TEST_DATABASE_URL to a dedicated test PostgreSQL database' >&2; exit 1; } + +sqlc-generate: + cd services/agents-api && $(SQLC) generate + +check-sqlc: + python3 scripts/check-sqlc.py + +check-go: + go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... -count=1 + +build-daemon: + @set -e; output="$${PARSAR_HOME:-$$HOME/.parsar}/build/daemon"; \ + [[ "$$output" == /* ]] || { echo 'Daemon output directory must be absolute' >&2; exit 1; }; \ + mkdir -p "$$output"; \ + CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$$output/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon + +build-agents-api: + ./scripts/build-agents-api.sh + +build-agents-api-release: + ./scripts/build-agents-api-release.sh + +check-agents-api: build-agents-api + go test ./services/agents-api/... ./packages/agents-client/... -count=1 + +docker-build-agents-api: + ./scripts/build-agents-api-image.sh + +check-agents-api-container: docker-build-agents-api + AGENTS_API_IMAGE="$${AGENTS_API_IMAGE:-agents-api:dev}" AGENTS_API_SERVER_BIN="$(CURDIR)/services/agents-api/tests/container_server.py" $${PARSAR_OFFICIAL_SDK_PYTHON:-python3} services/agents-api/tests/official_client.py + +node-deps: + pnpm install --frozen-lockfile + +check-claude-sdk: node-deps + pnpm --filter @parsar/claude-sdk-adapter test + $(MAKE) build-claude-sdk-runtime + +build-claude-sdk-runtime: + ./scripts/build-claude-sdk-runtime.sh + +check-mcode-harness: + node --test packages/mcode-harness/*.test.mjs + @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done + bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh + +build-agents-executor: + ./scripts/build-agents-executor.sh + +check-agents-executor: + ./scripts/check-agents-executor.sh + +build-agents-harness: + ./scripts/build-agents-harness.sh + +check-agents-harness: + ./scripts/check-agents-harness.sh + +check-agents-harness-native: + ./scripts/build-agents-harness.sh check + +build-agents-runtime: + ./scripts/build-agents-runtime.sh + +build-claude-runtime: + ./scripts/build-claude-runtime.sh + +build-mcode-harness: + ./scripts/build-mcode-harness.sh + +build-mcode-runtime: + ./scripts/build-mcode-runtime.sh diff --git a/README.md b/README.md new file mode 100644 index 000000000..7b3f98bb4 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Parsar Core + +Standalone Agent API Core and its execution runtimes, copied from +[Parsar](https://github.com/MiniMax-AI-Dev/parsar) at +[`72ab4d37`](https://github.com/MiniMax-AI-Dev/parsar/commit/72ab4d37d49245f15b63d34f5741780e540bcec0). +The source repository retains both its product and its existing Core copy. + +This repository contains the API service, PostgreSQL migrations, pinned public +protocol, execution daemon, Docker/E2B providers, native Harness adapters, +runtime image builders, client library, tests and operator documentation. +It does not contain the Parsar web application, product backend, product database, +business CLI or product deployment stack. + +## Start here + +- [API setup, authentication and execution](services/agents-api/README.md) +- [Standalone containers](services/agents-api/CONTAINER.md) +- [Docker Runtime](services/agents-api/deploy/codex/README.md) +- [Protocol coverage and known gaps](contracts/agents-api/README.md) +- [Harness selection](contracts/agents-api/harness-selection.md) +- [Contributor rules](CONTRIBUTING.md) +- [Copy provenance and validation](provenance/README.md) + +```sh +make build-agents-api +make build-daemon +``` + +These builds require the Go version pinned in `go.mod`. Output goes under +`~/.parsar/build/`; no product checkout, frontend or product database is needed. +Provision a dedicated Core PostgreSQL database and caller credentials using the +operator guide before starting the service. Native execution also needs the +appropriate Runtime image and provider configuration. + +The copied Go module/import paths, executable names and `PARSAR_*` environment +variables intentionally retain their existing names. They resolve to source in +this checkout, not a dependency on the Parsar product repository. This extraction +does not rename protocols or change execution behavior. Third-party native +sources and packages remain pinned dependencies, not vendored binaries. + +## Validate + +On Linux with Go, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 (including +rustfmt/Clippy), OpenSSL development libraries and a dedicated test PostgreSQL: + +```sh +export PARSAR_AGENTS_API_TEST_DATABASE_URL='postgres://.../parsar_agents_api_core_tests?sslmode=disable' +make check +``` + +The full gate requires the test database rather than silently skipping persistence +tests. Native model/provider fixtures remain explicit, credential-dependent +acceptance checks; see the [native tests](services/agents-api/tests/native/README.md). +Importing existing implementations does not establish additional protocol coverage. diff --git a/apps/parsar-daemon/.gitignore b/apps/parsar-daemon/.gitignore new file mode 100644 index 000000000..e660fd93d --- /dev/null +++ b/apps/parsar-daemon/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/apps/parsar-daemon/cmd/parsar-daemon/main.go b/apps/parsar-daemon/cmd/parsar-daemon/main.go new file mode 100644 index 000000000..e91015b77 --- /dev/null +++ b/apps/parsar-daemon/cmd/parsar-daemon/main.go @@ -0,0 +1,19 @@ +// Command parsar-daemon is the reverse-WebSocket worker that pairs a user +// machine with a Parsar server and exposes a local agent CLI +// subprocess as a connector_type=agent_daemon target. See +// apps/parsar-daemon/README.md for the subcommand spec. +package main + +import ( + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli" +) + +func main() { + if err := cli.Execute(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "parsar-daemon: %v\n", err) + os.Exit(1) + } +} diff --git a/apps/parsar-daemon/internal/agent/binpath/binpath.go b/apps/parsar-daemon/internal/agent/binpath/binpath.go new file mode 100644 index 000000000..39811fbe2 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/binpath/binpath.go @@ -0,0 +1,66 @@ +// Package binpath resolves which executable each agent adapter should +// probe and spawn. +// +// By default every engine is looked up by bare name on PATH ("claude", +// "codex", ...). That breaks in images where PATH is not under our +// control: e2b's base image, for instance, ships its own +// /usr/local/bin entries that can shadow the ones we install, and a +// bare-name lookup then resolves to the wrong (or no) binary. The +// symptom is the worst kind — `parsar-daemon connect` reports +// "no supported agent CLI available" and the device never dials in, +// with no indication of which lookup failed. +// +// The env overrides below let an image or operator pin an absolute path +// instead. They are read in ONE place so the version probe +// (CheckCLIAvailable) and the run-time spawn (sessionConfig) can never +// disagree: a probe that succeeds against /custom/claude while the run +// spawns PATH's `claude` would advertise a capability the daemon cannot +// actually honour. +package binpath + +import ( + "os" + "strings" +) + +// Env var names for the per-engine executable overrides. Empty or unset +// means "look up the default name on PATH". +const ( + EnvClaudeCode = "PARSAR_CLAUDE_BIN" + EnvCodex = "PARSAR_CODEX_BIN" + EnvPi = "PARSAR_PI_BIN" + EnvOpenCode = "PARSAR_OPENCODE_BIN" + EnvMCode = "PARSAR_MCODE_BIN" +) + +// Default executable names, used when the matching env var is unset. +const ( + DefaultClaudeCode = "claude" + DefaultCodex = "codex" + DefaultPi = "pi" + DefaultOpenCode = "opencode" + DefaultMCode = "mcode" +) + +// resolve returns the trimmed env override when set, else fallback. +func resolve(envVar, fallback string) string { + if v := strings.TrimSpace(os.Getenv(envVar)); v != "" { + return v + } + return fallback +} + +// ClaudeCode returns the claude executable to probe and spawn. +func ClaudeCode() string { return resolve(EnvClaudeCode, DefaultClaudeCode) } + +// Codex returns the codex executable to probe and spawn. +func Codex() string { return resolve(EnvCodex, DefaultCodex) } + +// Pi returns the pi executable to probe and spawn. +func Pi() string { return resolve(EnvPi, DefaultPi) } + +// OpenCode returns the opencode executable to probe and spawn. +func OpenCode() string { return resolve(EnvOpenCode, DefaultOpenCode) } + +// MCode returns the mcode executable to probe and spawn. +func MCode() string { return resolve(EnvMCode, DefaultMCode) } diff --git a/apps/parsar-daemon/internal/agent/binpath/binpath_test.go b/apps/parsar-daemon/internal/agent/binpath/binpath_test.go new file mode 100644 index 000000000..079f35f18 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/binpath/binpath_test.go @@ -0,0 +1,39 @@ +package binpath + +import "testing" + +func TestResolvers(t *testing.T) { + tests := []struct { + name string + envVar string + fallback string + resolve func() string + override string + wantPinned string + }{ + {name: "claude", envVar: EnvClaudeCode, fallback: DefaultClaudeCode, resolve: ClaudeCode, override: " /opt/agents/claude ", wantPinned: "/opt/agents/claude"}, + {name: "codex", envVar: EnvCodex, fallback: DefaultCodex, resolve: Codex, override: " /opt/agents/codex ", wantPinned: "/opt/agents/codex"}, + {name: "mcode", envVar: EnvMCode, fallback: DefaultMCode, resolve: MCode, override: " /opt/agents/mcode ", wantPinned: "/opt/agents/mcode"}, + {name: "pi", envVar: EnvPi, fallback: DefaultPi, resolve: Pi, override: " /opt/agents/pi ", wantPinned: "/opt/agents/pi"}, + {name: "opencode", envVar: EnvOpenCode, fallback: DefaultOpenCode, resolve: OpenCode, override: " /opt/agents/opencode ", wantPinned: "/opt/agents/opencode"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.envVar, "") + if got := tt.resolve(); got != tt.fallback { + t.Fatalf("unset override: got %q, want %q", got, tt.fallback) + } + + t.Setenv(tt.envVar, tt.override) + if got := tt.resolve(); got != tt.wantPinned { + t.Fatalf("explicit override: got %q, want %q", got, tt.wantPinned) + } + + t.Setenv(tt.envVar, " \t ") + if got := tt.resolve(); got != tt.fallback { + t.Fatalf("blank override: got %q, want %q", got, tt.fallback) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask.go b/apps/parsar-daemon/internal/agent/claudecode/ask.go new file mode 100644 index 000000000..aee5e9387 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/ask.go @@ -0,0 +1,479 @@ +package claudecode + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// askUserQuestionToolName matches Claude Code's built-in tool name. +// We intercept it because it expects a tool_result the human composes, +// and Claude Code's stdin loop has no other way to deliver one. +const askUserQuestionToolName = "AskUserQuestion" + +// pendingAskTable maps daemon-minted ask_<8hex> ids to the originating +// Claude Code tool_use id and the structured question payload. The +// daemon keeps both directions so SubmitPromptForUserChoice can write +// a matching tool_result back into the agent's stdin, and so a future +// cancel path (Session.Cancel mid-ask) can clean up by ask id. +// +// AskUserQuestion is a normal tool_use frame on the claude side, not a +// control_request — so the table sits beside pendingTable rather than +// extending it. Same code shape, different keying. +type pendingAskTable struct { + mu sync.Mutex + byAskID map[string]pendingAskEntry + byToolID map[string]string +} + +type pendingAskEntry struct { + // ToolUseID is set when AskUserQuestion arrives via the + // assistant→tool_use path. Empty when it arrived via control_request. + ToolUseID string + + // CCRequestID is set when AskUserQuestion arrives via the + // control_request path (claude-code under --permission-prompt-tool + // stdio wraps the tool in a can_use_tool permission check). Empty + // when it arrived via tool_use. SubmitPromptForUserChoice picks the + // reply shape based on which one is set: tool_result for tool_use, + // control_response for control_request. + CCRequestID string + + // Questions snapshots the question list this ask was raised for so + // the writeback can echo header→answer back to the model in the + // same shape Claude's built-in handler emits. Length >= 1. + Questions []proto.PromptForUserChoiceQuestion +} + +// reverseKey returns whichever id was used to populate byToolID so +// Take / Delete can drop the reverse mapping without caring which +// path recorded the entry. +func (e pendingAskEntry) reverseKey() string { + if e.ToolUseID != "" { + return e.ToolUseID + } + return e.CCRequestID +} + +func newPendingAskTable() *pendingAskTable { + return &pendingAskTable{ + byAskID: make(map[string]pendingAskEntry), + byToolID: make(map[string]string), + } +} + +// Record links a freshly minted ask id to the originating tool_use id +// and the question snapshot. Used by the assistant tool_use path. +func (p *pendingAskTable) Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) { + if askID == "" || toolUseID == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.byAskID[askID] = pendingAskEntry{ToolUseID: toolUseID, Questions: questions} + p.byToolID[toolUseID] = askID +} + +// RecordControl links a freshly minted ask id to the originating CC +// request_id (control_request path). Used when claude-code wraps +// AskUserQuestion as a can_use_tool permission check. +func (p *pendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { + if askID == "" || ccRequestID == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.byAskID[askID] = pendingAskEntry{CCRequestID: ccRequestID, Questions: questions} + // Re-use byToolID as a generic reverse map: keyed by either + // tool_use_id or cc_request_id depending on path. The two id + // namespaces don't collide (tool_use ids look like toolu_..., cc + // ones are UUIDs). + p.byToolID[ccRequestID] = askID +} + +// Take atomically reads + deletes the entry recorded for askID. Used by +// SubmitPromptForUserChoice so two near-simultaneous callers (timer- +// fired cancel racing a server-delivered answer) can't both pass and +// each write a tool_result. The loser sees ok=false and returns +// ErrUnknownAsk instead. +// +// Trade-off vs Resolve+Delete: if the subsequent stdin write fails, the +// entry is already gone — a retry surfaces ErrUnknownAsk rather than +// re-doing the write. The double-fire risk is the bigger hazard here +// (timer + server can both reach Submit; stdin flakes are rare and the +// session is going to die anyway when stdin errors), so we accept it. +func (p *pendingAskTable) Take(askID string) (pendingAskEntry, bool) { + if askID == "" { + return pendingAskEntry{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byAskID[askID] + if !ok { + return pendingAskEntry{}, false + } + delete(p.byAskID, askID) + if key := e.reverseKey(); key != "" { + delete(p.byToolID, key) + } + return e, true +} + +// Peek returns the entry without removing it. Reserved for diagnostic / +// test paths that want to inspect pending state without consuming it. +func (p *pendingAskTable) Peek(askID string) (pendingAskEntry, bool) { + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byAskID[askID] + return e, ok +} + +// LookupByToolUse reverses the mapping. Reserved for the (currently +// unimplemented) "claude internally cancels its own tool" path. +func (p *pendingAskTable) LookupByToolUse(toolUseID string) (string, bool) { + if toolUseID == "" { + return "", false + } + p.mu.Lock() + defer p.mu.Unlock() + askID, ok := p.byToolID[toolUseID] + return askID, ok +} + +// Delete removes both directions for askID. +func (p *pendingAskTable) Delete(askID string) { + if askID == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byAskID[askID] + if !ok { + return + } + delete(p.byAskID, askID) + if key := e.reverseKey(); key != "" { + delete(p.byToolID, key) + } +} + +// Len reports the number of outstanding ask requests. +func (p *pendingAskTable) Len() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.byAskID) +} + +// interceptAskUserQuestion handled the tool_use path historically. It's +// retained as exported test scaffolding (legacy unit tests still cover +// the case-by-case payload parsing) — translateAssistant no longer +// calls it. See parser.go's tool_use branch comment. +// +// Returns ok=false (no envelope, fall through to a normal TypeToolCall) +// when askPending / askMint are missing, the input shape doesn't fit, +// or the tool_use id is empty. +func (t *translator) interceptAskUserQuestion(toolUseID string, input map[string]any) (proto.Envelope, bool) { + if t.askPending == nil || t.askMint == nil { + return proto.Envelope{}, false + } + if toolUseID == "" { + return proto.Envelope{}, false + } + questions, ok := parseAskUserQuestionInput(input) + if !ok { + return proto.Envelope{}, false + } + + askID := t.askMint() + t.askPending.Record(askID, toolUseID, questions) + + env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, t.runID, proto.PromptForUserChoicePayload{ + AskID: askID, + Questions: questions, + ToolUseID: toolUseID, + }) + if err != nil { + return proto.Envelope{}, false + } + return env, true +} + +// interceptAskUserQuestionFromControlRequest is the control_request- +// path twin. Under --permission-prompt-tool stdio, claude-code wraps +// AskUserQuestion as a can_use_tool permission check instead of +// emitting a normal tool_use frame, so the interception entry point is +// different and the writeback shape (control_response) differs from +// the tool_use path (tool_result). +// +// ccRequestID is the SDK's request_id we'll echo back in the +// control_response. ToolUseID stays empty in the envelope payload — +// there's no tool_use_id available on this path. +func (t *translator) interceptAskUserQuestionFromControlRequest(ccRequestID string, input map[string]any) (proto.Envelope, bool) { + if t.askPending == nil || t.askMint == nil { + return proto.Envelope{}, false + } + if ccRequestID == "" { + return proto.Envelope{}, false + } + questions, ok := parseAskUserQuestionInput(input) + if !ok { + return proto.Envelope{}, false + } + + askID := t.askMint() + t.askPending.RecordControl(askID, ccRequestID, questions) + + env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, t.runID, proto.PromptForUserChoicePayload{ + AskID: askID, + Questions: questions, + }) + if err != nil { + return proto.Envelope{}, false + } + return env, true +} + +// parseAskUserQuestionInput pulls the AskUserQuestion fields out of +// the raw tool input. The schema mirrors Claude Code's built-in: +// +// { +// "questions": [{ +// "header": "...", +// "question": "...", +// "multiSelect": false, +// "options": [{"label": "...", "description": "..."}, ...] +// }, ...] +// } +// +// Returns ok=false (fall through to a normal TypeToolCall) when the +// shape doesn't fit. Any single question with an empty question text +// or zero options invalidates the whole call — we'd rather let claude +// see the raw tool_use and re-emit than render a half-broken card. +func parseAskUserQuestionInput(input map[string]any) ([]proto.PromptForUserChoiceQuestion, bool) { + rawQuestions, exists := input["questions"] + if !exists { + return nil, false + } + list, isList := rawQuestions.([]any) + if !isList || len(list) == 0 { + return nil, false + } + + out := make([]proto.PromptForUserChoiceQuestion, 0, len(list)) + for index, rawEntry := range list { + q, isMap := rawEntry.(map[string]any) + if !isMap { + return nil, false + } + question, _ := q["question"].(string) + header, _ := q["header"].(string) + multiSelect, _ := q["multiSelect"].(bool) + + rawOptions, _ := q["options"].([]any) + options := make([]proto.PromptForUserChoiceOption, 0, len(rawOptions)) + for _, raw := range rawOptions { + om, isOptMap := raw.(map[string]any) + if !isOptMap { + continue + } + label, _ := om["label"].(string) + if label == "" { + continue + } + description, _ := om["description"].(string) + options = append(options, proto.PromptForUserChoiceOption{ + Label: label, + Description: description, + }) + } + if question == "" || len(options) == 0 { + return nil, false + } + out = append(out, proto.PromptForUserChoiceQuestion{ + ID: fmt.Sprintf("q%d", index), + Header: header, + Question: question, + MultiSelect: multiSelect, + // Claude Code's AskUserQuestion always permits the built-in + // "Other" free-text answer in addition to declared options. + IsOther: true, + Options: options, + }) + } + return out, true +} + +// buildAskUserToolResult turns the human's decision into the NDJSON +// frame the daemon writes back to claude's stdin. The shape is a +// stock Claude Code "user message" carrying a tool_result block — +// claude's SDK then closes the loop on the original tool_use as if +// the local handler had returned the result. +// +// Body shape (one line, no trailing comma): +// +// {"type":"user","message":{"content":[ +// {"type":"tool_result","tool_use_id":"", +// "content":[{"type":"text","text":""}], +// "is_error":false} +// ]}} +// +// For a normal answer we encode {"questions":[{"header":..,"answer":..}]} +// — same shape Claude's built-in AskUserQuestion handler emits — so the +// model treats the daemon-mediated path identically to the local one. +// +// For Cancelled answers (timeout, operator stop) we send a plain +// sentence with is_error=false. is_error=true would invite the model +// to retry the same AskUserQuestion call right away, which is exactly +// the deadlock we're trying to avoid. +func buildAskUserToolResult(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { + text := formatAskUserResultText(entry, decision) + body, err := json.Marshal(map[string]any{ + "type": "user", + "message": map[string]any{ + "content": []map[string]any{{ + "type": "tool_result", + "tool_use_id": entry.ToolUseID, + "content": []map[string]any{{ + "type": "text", + "text": text, + }}, + "is_error": false, + }}, + }, + }) + if err != nil { + return nil, err + } + return append(body, '\n'), nil +} + +// buildAskUserControlResponse is the control_request-path twin of +// buildAskUserToolResult. Used when claude-code's +// --permission-prompt-tool stdio mode wrapped AskUserQuestion in a +// can_use_tool permission check — the daemon has to respond on the +// control_response channel, not via a user message. +// +// We deny the "permission" (claude won't actually invoke its own +// AskUserQuestion local handler) but pass the human's answer through +// the message field. The SDK surfaces this message to the model as +// the tool_result, closing the loop the same way as the user-message +// path. Body shape: +// +// {"type":"control_response","response":{ +// "subtype":"success","request_id":"", +// "response":{"behavior":"deny","message":""}}} +func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { + text := formatAskUserResultText(entry, decision) + body, err := json.Marshal(map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": entry.CCRequestID, + "response": map[string]any{ + "behavior": "deny", + "message": text, + }, + }, + }) + if err != nil { + return nil, err + } + return append(body, '\n'), nil +} + +// formatAskUserResultText is the shared text-formatting helper, split +// out so tests can lock the wire shape without re-marshalling the +// whole envelope. +func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) string { + if decision.Cancelled { + reason := strings.TrimSpace(decision.Reason) + switch reason { + case "timeout": + return "The user did not make a selection within 10 minutes. Stop the current operation, report the timeout to the user and ask about follow-up intent; do not retry this tool." + case "cancelled": + return "The user cancelled this operation. Stop follow-up actions." + default: + return "The user did not give a selection (" + reason + "). Stop follow-up actions and wait for further instructions from the user." + } + } + + // Multi-question path: stable QuestionID is authoritative. Positional + // pairing remains only as a compatibility fallback for older peers; + // Header is never a key because duplicate or blank headers are valid. + if len(entry.Questions) > 0 { + out := make([]map[string]any, 0, len(entry.Questions)) + anyAnswer := false + // Single-question + legacy Answers slice with multiple entries + // = the multi-select case the old callback shape used. Join + // them with the same "、" we render to the human so the model + // sees one merged answer string for that question. + if len(entry.Questions) == 1 && len(decision.QuestionAnswers) == 0 && len(decision.Answers) > 1 { + merged := strings.Join(decision.Answers, "、") + return mustMarshalAskQuestions([]map[string]any{{ + "header": entry.Questions[0].Header, + "answer": merged, + }}) + } + for i, q := range entry.Questions { + answer := "" + if i < len(decision.QuestionAnswers) { + answer = decision.QuestionAnswers[i].Answer + if len(decision.QuestionAnswers[i].Answers) > 0 { + answer = strings.Join(decision.QuestionAnswers[i].Answers, "、") + } + } + for _, candidate := range decision.QuestionAnswers { + if q.ID != "" && candidate.QuestionID == q.ID { + answer = candidate.Answer + if len(candidate.Answers) > 0 { + answer = strings.Join(candidate.Answers, "、") + } + break + } + } + // Legacy callback path: a single-question slot answered via + // the flat Answers slice. Multi-question slots always populate + // QuestionAnswers, so this branch is no-op for them. + if answer == "" && len(decision.Answers) > i { + answer = decision.Answers[i] + } + if answer != "" { + anyAnswer = true + } + out = append(out, map[string]any{ + "header": q.Header, + "answer": answer, + }) + } + if !anyAnswer { + // Treat as cancel; the operator effectively chose nothing. + return "The user did not choose any option. Stop follow-up actions and wait for further instructions from the user." + } + return mustMarshalAskQuestions(out) + } + + // Legacy fallback: pendingAskEntry has no Questions snapshot + // (e.g. test scaffold that didn't pass one). Re-emit whatever + // Answers carries as a single-question payload so the model still + // receives a parseable result. + answers := decision.Answers + if len(answers) == 0 { + return "The user did not choose any option. Stop follow-up actions and wait for further instructions from the user." + } + answer := answers[0] + if len(answers) > 1 { + answer = strings.Join(answers, "、") + } + return mustMarshalAskQuestions([]map[string]any{{ + "header": "", + "answer": answer, + }}) +} + +func mustMarshalAskQuestions(qs []map[string]any) string { + payload, _ := json.Marshal(map[string]any{"questions": qs}) + return string(payload) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go new file mode 100644 index 000000000..f3388644d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go @@ -0,0 +1,535 @@ +package claudecode_test + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// askCounterMinter emits ask_001, ask_002, ... for deterministic +// envelope IDs in tests. +func askCounterMinter() func() string { + var ( + mu sync.Mutex + n int + ) + return func() string { + mu.Lock() + defer mu.Unlock() + n++ + return fmt.Sprintf("ask_%03d", n) + } +} + +// TestTranslateAskUserQuestionToolUsePathFallsThrough locks in the +// dedupe behaviour: claude-code under --permission-prompt-tool stdio +// emits the same AskUserQuestion as BOTH a tool_use frame AND a +// control_request "can_use_tool" frame. We intercept only the +// control_request path (translateControlRequest); the tool_use frame +// passes through as a regular TypeToolCall so the run timeline still +// shows the call but we don't double-render the card. +func TestTranslateAskUserQuestionToolUsePathFallsThrough(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + askPending := claudecode.NewPendingAskTableForTest() + tr := claudecode.NewTranslatorWithAskForTest("run_a", pending, askPending, counterMinter(), askCounterMinter()) + + line := []byte(`{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"toolu_abc","name":"AskUserQuestion","input":{ + "questions":[{ + "header":"Confirm delete", + "question":"Delete /tmp directory?", + "multiSelect":false, + "options":[ + {"label":"Confirm delete","description":"Run rm -rf /tmp"}, + {"label":"Cancel","description":"Do not run"} + ] + }] + }} + ]}}`) + + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 { + t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) + } + env := out.Envelopes[0] + if env.Type != proto.TypeToolCall { + t.Fatalf("env.Type = %q, want TypeToolCall (tool_use path no longer intercepts AskUserQuestion)", env.Type) + } + if askPending.Len() != 0 { + t.Errorf("askPending should be empty (tool_use path is a pass-through), got %d entries", askPending.Len()) + } +} + +// TestTranslateAskUserQuestionMultiQuestionIntercepted locks in the +// new "questions length > 1 still goes through the ask flow" path — +// daemon now renders a single multi-question card instead of falling +// through to a plain tool_call frame. +func TestTranslateAskUserQuestionMultiQuestionIntercepted(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + askPending := claudecode.NewPendingAskTableForTest() + tr := claudecode.NewTranslatorWithAskForTest("run_m", pending, askPending, counterMinter(), askCounterMinter()) + + line := []byte(`{"type":"control_request","request_id":"cc_multi","request":{ + "subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{ + "questions":[ + {"header":"q1","question":"pick A or B","options":[{"label":"A"},{"label":"B"}]}, + {"header":"q2","question":"pick X or Y","options":[{"label":"X"},{"label":"Y"}]} + ] + } + }}`) + + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 { + t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) + } + if out.Envelopes[0].Type != proto.TypePromptForUserChoice { + t.Errorf("want TypePromptForUserChoice, got %q", out.Envelopes[0].Type) + } + var payload proto.PromptForUserChoicePayload + if err := json.Unmarshal(out.Envelopes[0].Payload, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if len(payload.Questions) != 2 { + t.Fatalf("Questions len = %d, want 2", len(payload.Questions)) + } + if payload.Questions[0].Header != "q1" || payload.Questions[1].Header != "q2" { + t.Errorf("headers mismatch: %+v", payload.Questions) + } + if askPending.Len() != 1 { + t.Errorf("askPending should have one entry, got %d", askPending.Len()) + } +} + +// TestTranslateAskUserQuestionWithoutAskHookFallsThrough covers the +// legacy translator path (no askPending wired): even AskUserQuestion +// produces a regular TypeToolCall so older callers that don't care +// about the ask flow keep working. +func TestTranslateAskUserQuestionWithoutAskHookFallsThrough(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_a", pending, counterMinter()) + + line := []byte(`{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"toolu_y","name":"AskUserQuestion","input":{ + "questions":[{"header":"h","question":"?","options":[{"label":"a"}]}] + }} + ]}}`) + + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeToolCall { + t.Fatalf("expected TypeToolCall fall-through, got %#v", out.Envelopes) + } +} + +// TestPendingAskTableTakeIsAtomic locks in the Take contract: +// concurrent Take(askID) callers must see exactly one ok=true. This is +// the contract SubmitPromptForUserChoice relies on to make sure a +// timer-fired cancel and a server-delivered answer can't both write a +// tool_result back into claude's stdin. +func TestPendingAskTableTakeIsAtomic(t *testing.T) { + tbl := claudecode.NewPendingAskTableForTest() + headerQs := []proto.PromptForUserChoiceQuestion{{Header: "header text", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} + emptyQs := []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} + tbl.Record("ask_1", "toolu_aaa", headerQs) + tbl.Record("ask_2", "toolu_bbb", emptyQs) + + if tbl.Len() != 2 { + t.Fatalf("Len = %d, want 2", tbl.Len()) + } + + // First Take consumes the entry and reverse mapping. + e, ok := tbl.Take("ask_1") + if !ok { + t.Fatalf("Take(ask_1) ok=false") + } + if e.ToolUseID != "toolu_aaa" || len(e.Questions) != 1 || e.Questions[0].Header != "header text" { + t.Errorf("entry mismatch: %+v", e) + } + if _, ok := tbl.Take("ask_1"); ok { + t.Errorf("Take(ask_1) twice both ok=true; want second take to lose") + } + if _, ok := tbl.Peek("ask_1"); ok { + t.Errorf("Peek(ask_1) after Take still ok") + } + if tbl.Len() != 1 { + t.Errorf("Len = %d, want 1", tbl.Len()) + } + + // Empty / unknown is a no-op, not a panic. + tbl.Record("", "x", emptyQs) + tbl.Delete("nope") +} + +// TestPendingAskTableTakeRace ensures two goroutines calling Take on +// the same askID see exactly one winner — the real-world race we care +// about is the AskTimeout watchdog firing the same instant the server +// delivers the human's answer. +func TestPendingAskTableTakeRace(t *testing.T) { + qs := []proto.PromptForUserChoiceQuestion{{Header: "h", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} + for i := range 200 { + tbl := claudecode.NewPendingAskTableForTest() + tbl.Record("ask_x", "toolu_x", qs) + + var wg sync.WaitGroup + var winners int32 + start := make(chan struct{}) + wg.Add(2) + for range 2 { + go func() { + defer wg.Done() + <-start + if _, ok := tbl.Take("ask_x"); ok { + atomic.AddInt32(&winners, 1) + } + }() + } + close(start) + wg.Wait() + if winners != 1 { + t.Fatalf("iter %d: winners = %d, want 1", i, winners) + } + } +} + +// TestPendingAskTableRejectsEmptyKeys verifies the defence against +// half-built calls polluting the table. +func TestPendingAskTableRejectsEmptyKeys(t *testing.T) { + qs := []proto.PromptForUserChoiceQuestion{{Header: "h", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} + tbl := claudecode.NewPendingAskTableForTest() + tbl.Record("", "toolu", qs) + tbl.Record("ask_1", "", qs) + if tbl.Len() != 0 { + t.Errorf("Len = %d, want 0 (both records must be rejected)", tbl.Len()) + } +} + +// askUserResult is the JSON shape we expect SubmitPromptForUserChoice +// to write back into claude's stdin. +type askUserResult struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"is_error"` + } `json:"content"` + } `json:"message"` +} + +func decodeAskResult(t *testing.T, raw []byte) askUserResult { + t.Helper() + raw = []byte(strings.TrimSpace(string(raw))) + var v askUserResult + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode ask result: %v\nraw=%s", err, raw) + } + if v.Type != "user" { + t.Errorf("Type = %q, want user", v.Type) + } + if len(v.Message.Content) != 1 { + t.Fatalf("Content len = %d, want 1", len(v.Message.Content)) + } + if v.Message.Content[0].Type != "tool_result" { + t.Errorf("Content[0].Type = %q, want tool_result", v.Message.Content[0].Type) + } + return v +} + +// TestBuildAskUserToolResultSingleSelect locks in the single-answer +// wire shape — what we send to claude's stdin must be a valid +// tool_result with the JSON {"questions":[{header, answer}]} body. +func TestBuildAskUserToolResultSingleSelect(t *testing.T) { + body, err := claudecode.BuildAskUserToolResultForTest( + claudecode.PendingAskEntry{ToolUseID: "toolu_abc", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Confirm delete"}}}, + proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Confirm delete"}}, + ) + if err != nil { + t.Fatalf("buildAskUserToolResult: %v", err) + } + v := decodeAskResult(t, body) + if v.Message.Content[0].ToolUseID != "toolu_abc" { + t.Errorf("ToolUseID = %q, want toolu_abc", v.Message.Content[0].ToolUseID) + } + if v.Message.Content[0].IsError { + t.Errorf("IsError = true; success answer must use is_error=false") + } + if !strings.Contains(v.Message.Content[0].Content[0].Text, `"answer":"Confirm delete"`) { + t.Errorf("answer not encoded: %s", v.Message.Content[0].Content[0].Text) + } + if !strings.Contains(v.Message.Content[0].Content[0].Text, `"header":"Confirm delete"`) { + t.Errorf("header not echoed: %s", v.Message.Content[0].Content[0].Text) + } +} + +// TestBuildAskUserToolResultMultiSelect ensures multiple answers join +// into a single human-friendly answer string. +func TestBuildAskUserToolResultMultiSelect(t *testing.T) { + body, err := claudecode.BuildAskUserToolResultForTest( + claudecode.PendingAskEntry{ToolUseID: "toolu_m", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Pick lens"}}}, + proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Safety", "Performance"}}, + ) + if err != nil { + t.Fatalf("buildAskUserToolResult: %v", err) + } + v := decodeAskResult(t, body) + if !strings.Contains(v.Message.Content[0].Content[0].Text, `"answer":"Safety、Performance"`) { + t.Errorf("multi-select join failed: %s", v.Message.Content[0].Content[0].Text) + } +} + +// TestBuildAskUserToolResultMultiQuestionPositional locks in the +// positional pairing contract: when two questions share the same Header +// (or both are blank — claude-code treats `header` as optional), each +// question still gets its own answer back. A previous header-keyed map +// approach collapsed duplicates and fed the model the wrong tool_result. +func TestBuildAskUserToolResultMultiQuestionPositional(t *testing.T) { + // Two questions with IDENTICAL headers — the realistic shape when the + // model omits header entirely and both fall back to "". + body, err := claudecode.BuildAskUserToolResultForTest( + claudecode.PendingAskEntry{ + ToolUseID: "toolu_pos", + Questions: []proto.PromptForUserChoiceQuestion{ + {Header: "", Question: "q1"}, + {Header: "", Question: "q2"}, + }, + }, + proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ + {Header: "", Answer: "A1"}, + {Header: "", Answer: "B1"}, + }, + }, + ) + if err != nil { + t.Fatalf("buildAskUserToolResult: %v", err) + } + v := decodeAskResult(t, body) + text := v.Message.Content[0].Content[0].Text + // Both answers must round-trip; the bug we're locking in against was + // "both questions end up with B1" because map["":B1] overwrote "":A1. + if !strings.Contains(text, `"answer":"A1"`) { + t.Errorf("question 0 answer (A1) missing: %s", text) + } + if !strings.Contains(text, `"answer":"B1"`) { + t.Errorf("question 1 answer (B1) missing: %s", text) + } + // Lock in the order — A1 must come first. + if strings.Index(text, `"answer":"A1"`) > strings.Index(text, `"answer":"B1"`) { + t.Errorf("answers out of order (A1 must precede B1): %s", text) + } +} + +func TestBuildAskUserToolResultMatchesStableQuestionIDs(t *testing.T) { + body, err := claudecode.BuildAskUserToolResultForTest( + claudecode.PendingAskEntry{ + ToolUseID: "toolu_ids", + Questions: []proto.PromptForUserChoiceQuestion{ + {ID: "environment", Header: "Environment"}, + {ID: "checks", Header: "Checks"}, + }, + }, + proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ + {QuestionID: "checks", Answers: []string{"Unit", "Integration"}}, + {QuestionID: "environment", Answers: []string{"Staging"}}, + }, + }, + ) + if err != nil { + t.Fatalf("buildAskUserToolResult: %v", err) + } + v := decodeAskResult(t, body) + text := v.Message.Content[0].Content[0].Text + if !strings.Contains(text, `"answer":"Staging","header":"Environment"`) { + t.Fatalf("stable environment answer missing: %s", text) + } + if !strings.Contains(text, `"answer":"Unit、Integration","header":"Checks"`) { + t.Fatalf("stable multi-select answer missing: %s", text) + } +} + +// TestBuildAskUserToolResultTimeoutKeepsSuccessShape is the contract +// "don't trigger a retry": even on timeout we set is_error=false and +// rely on the body text to redirect the agent. +func TestBuildAskUserToolResultTimeoutKeepsSuccessShape(t *testing.T) { + body, err := claudecode.BuildAskUserToolResultForTest( + claudecode.PendingAskEntry{ToolUseID: "toolu_t", Questions: []proto.PromptForUserChoiceQuestion{{Header: "?"}}}, + proto.PromptForUserChoiceDecisionPayload{Cancelled: true, Reason: "timeout"}, + ) + if err != nil { + t.Fatalf("buildAskUserToolResult: %v", err) + } + v := decodeAskResult(t, body) + if v.Message.Content[0].IsError { + t.Errorf("IsError = true on timeout; want false to avoid retry loop") + } + text := v.Message.Content[0].Content[0].Text + if !strings.Contains(text, "10 minutes") { + t.Errorf("timeout text doesn't mention the window: %s", text) + } +} + +// askControlResponse is the JSON shape we expect from the control_request +// writeback path. The SDK reads back {response.subtype, response.request_id, +// response.response.behavior, response.response.message}. +type askControlResponse struct { + Type string `json:"type"` + Response struct { + Subtype string `json:"subtype"` + RequestID string `json:"request_id"` + Response struct { + Behavior string `json:"behavior"` + Message string `json:"message"` + } `json:"response"` + } `json:"response"` +} + +func decodeAskControlResponse(t *testing.T, raw []byte) askControlResponse { + t.Helper() + raw = []byte(strings.TrimSpace(string(raw))) + var v askControlResponse + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode control_response: %v\nraw=%s", err, raw) + } + if v.Type != "control_response" { + t.Errorf("Type = %q, want control_response", v.Type) + } + if v.Response.Subtype != "success" { + t.Errorf("Response.Subtype = %q, want success", v.Response.Subtype) + } + return v +} + +// TestTranslateControlRequestAskUserQuestionIntercepted locks in the +// control_request path: when claude-code runs with +// --permission-prompt-tool stdio it wraps AskUserQuestion as a +// can_use_tool permission check rather than a normal tool_use frame. +// The daemon must surface a TypePromptForUserChoice envelope (not a +// generic TypePermissionRequest) and record the CC request_id so the +// writeback can hit the control_response channel. +func TestTranslateControlRequestAskUserQuestionIntercepted(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + askPending := claudecode.NewPendingAskTableForTest() + tr := claudecode.NewTranslatorWithAskForTest("run_c", pending, askPending, counterMinter(), askCounterMinter()) + + line := []byte(`{"type":"control_request","request_id":"cc_req_xyz","request":{ + "subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{ + "questions":[{ + "header":"Confirm delete", + "question":"Delete /tmp directory?", + "multiSelect":false, + "options":[{"label":"Confirm"},{"label":"Cancel"}] + }] + } + }}`) + + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 { + t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) + } + env := out.Envelopes[0] + if env.Type != proto.TypePromptForUserChoice { + t.Fatalf("env.Type = %q, want %q", env.Type, proto.TypePromptForUserChoice) + } + if env.ID != "run_c" { + t.Errorf("env.ID = %q, want run_c (envelope id is run id)", env.ID) + } + + var payload proto.PromptForUserChoicePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload.AskID != "ask_001" { + t.Errorf("payload.AskID = %q, want ask_001", payload.AskID) + } + + entry, ok := askPending.Peek("ask_001") + if !ok { + t.Fatalf("ask_001 not recorded") + } + if entry.CCRequestID != "cc_req_xyz" { + t.Errorf("entry.CCRequestID = %q, want cc_req_xyz", entry.CCRequestID) + } + if entry.ToolUseID != "" { + t.Errorf("entry.ToolUseID = %q, want empty (control_request path)", entry.ToolUseID) + } + + // And: a normal control_request (non-AskUserQuestion) still falls + // through to the legacy permission path. + tr2 := claudecode.NewTranslatorWithAskForTest("run_c2", claudecode.NewPendingTableForTest(), claudecode.NewPendingAskTableForTest(), counterMinter(), askCounterMinter()) + bashLine := []byte(`{"type":"control_request","request_id":"cc_bash_1","request":{ + "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"ls"} + }}`) + out2, err := tr2.Translate(bashLine) + if err != nil { + t.Fatalf("Translate Bash: %v", err) + } + if len(out2.Envelopes) != 1 || out2.Envelopes[0].Type != proto.TypePermissionRequest { + t.Fatalf("Bash control_request must still produce TypePermissionRequest, got %#v", out2.Envelopes) + } +} + +// TestBuildAskUserControlResponseSingleSelect locks in the wire shape +// of the control_response writeback: behavior=deny + message carries +// the JSON answer payload. deny is intentional — claude shouldn't try +// to actually invoke its own AskUserQuestion local handler; the +// message is what the SDK surfaces to the model as the tool_result. +func TestBuildAskUserControlResponseSingleSelect(t *testing.T) { + body, err := claudecode.BuildAskUserControlResponseForTest( + claudecode.PendingAskEntry{CCRequestID: "cc_req_xyz", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Confirm delete"}}}, + proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Confirm"}}, + ) + if err != nil { + t.Fatalf("buildAskUserControlResponse: %v", err) + } + v := decodeAskControlResponse(t, body) + if v.Response.RequestID != "cc_req_xyz" { + t.Errorf("RequestID = %q, want cc_req_xyz", v.Response.RequestID) + } + if v.Response.Response.Behavior != "deny" { + t.Errorf("Behavior = %q, want deny", v.Response.Response.Behavior) + } + if !strings.Contains(v.Response.Response.Message, `"answer":"Confirm"`) { + t.Errorf("answer not encoded in message: %s", v.Response.Response.Message) + } +} + +// TestBuildAskUserControlResponseTimeout: timeout must still produce +// behavior=deny (no retry) + the canned timeout sentence in message. +func TestBuildAskUserControlResponseTimeout(t *testing.T) { + body, err := claudecode.BuildAskUserControlResponseForTest( + claudecode.PendingAskEntry{CCRequestID: "cc_req_to", Questions: []proto.PromptForUserChoiceQuestion{{Header: "?"}}}, + proto.PromptForUserChoiceDecisionPayload{Cancelled: true, Reason: "timeout"}, + ) + if err != nil { + t.Fatalf("buildAskUserControlResponse: %v", err) + } + v := decodeAskControlResponse(t, body) + if v.Response.Response.Behavior != "deny" { + t.Errorf("Behavior = %q, want deny", v.Response.Response.Behavior) + } + if !strings.Contains(v.Response.Response.Message, "10 minutes") { + t.Errorf("timeout text missing window: %s", v.Response.Response.Message) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/export_test.go b/apps/parsar-daemon/internal/agent/claudecode/export_test.go new file mode 100644 index 000000000..be900dd72 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/export_test.go @@ -0,0 +1,97 @@ +package claudecode + +// This file uses _test.go so it only compiles into the test binary, +// but lives in the production package — re-exports internal symbols +// for the external claudecode_test package without polluting the +// public surface. + +import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + +func NewPendingTableForTest() *PendingTable { return (*PendingTable)(newPendingTable()) } + +// PendingTable is the test-visible alias for pendingTable. +type PendingTable pendingTable + +type PendingEntry = pendingEntry + +func (p *PendingTable) Record(permID, ccRequestID string, input map[string]any) { + (*pendingTable)(p).Record(permID, ccRequestID, input) +} +func (p *PendingTable) Resolve(permID string) (PendingEntry, bool) { + return (*pendingTable)(p).Resolve(permID) +} +func (p *PendingTable) LookupByCC(ccReq string) (string, bool) { + return (*pendingTable)(p).LookupByCC(ccReq) +} +func (p *PendingTable) Delete(permID string) { (*pendingTable)(p).Delete(permID) } +func (p *PendingTable) Len() int { return (*pendingTable)(p).Len() } + +// PendingAskTable is the test-visible alias for pendingAskTable. +type PendingAskTable pendingAskTable + +type PendingAskEntry = pendingAskEntry + +func NewPendingAskTableForTest() *PendingAskTable { + return (*PendingAskTable)(newPendingAskTable()) +} + +func (p *PendingAskTable) Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) { + (*pendingAskTable)(p).Record(askID, toolUseID, questions) +} +func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { + (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) +} +func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { + return (*pendingAskTable)(p).Take(askID) +} +func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { + return (*pendingAskTable)(p).Peek(askID) +} +func (p *PendingAskTable) Delete(askID string) { (*pendingAskTable)(p).Delete(askID) } +func (p *PendingAskTable) Len() int { return (*pendingAskTable)(p).Len() } + +// NewTranslatorForTest constructs a translator with a deterministic +// perm-id minter. askPending and askMint default to nil — covers the +// legacy permission-only callers; pass via NewTranslatorWithAskForTest +// when exercising the AskUserQuestion interception path. +func NewTranslatorForTest(runID string, pending *PendingTable, mint func() string) *Translator { + t := newTranslator(runID, (*pendingTable)(pending), nil, permIDMinter(mint), nil) + return (*Translator)(t) +} + +// NewTranslatorWithAskForTest is the ask-aware variant. +func NewTranslatorWithAskForTest(runID string, pending *PendingTable, askPending *PendingAskTable, mint func() string, askMint func() string) *Translator { + t := newTranslator(runID, (*pendingTable)(pending), (*pendingAskTable)(askPending), permIDMinter(mint), askIDMinter(askMint)) + return (*Translator)(t) +} + +type Translator translator + +type Translation = translation + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +// Re-export proto types for the external test package. +type ( + Envelope = proto.Envelope +) + +func BuildUserMessageForTest(prompt string, attachments []proto.PromptAttachment) ([]byte, error) { + return buildUserMessageWithAttachments(prompt, attachments) +} + +// BuildAskUserToolResultForTest exposes the daemon-side tool_result +// builder so ask_test.go can lock in the JSON shape claude's stdin +// expects. +func BuildAskUserToolResultForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { + return buildAskUserToolResult(entry, decision) +} + +// BuildAskUserControlResponseForTest exposes the control_request-path +// writeback builder so ask_test.go can pin the control_response shape +// claude's stdin expects under --permission-prompt-tool stdio. +func BuildAskUserControlResponseForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { + return buildAskUserControlResponse(entry, decision) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go b/apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go new file mode 100644 index 000000000..ee75abfb3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go @@ -0,0 +1,162 @@ +package claudecode + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func installConcurrentFixture(ctx context.Context, kind, root, url string, body []byte) error { + var dirs, warnings []string + var err error + if kind == "plugin" { + var result PluginInstallResult + result, err = installPlugins(ctx, discardLogger(), root, []pluginDescriptor{ + {Name: "fixture", Version: "1.0.0", DownloadURL: url, SHA256: sha256Hex(body)}, + }) + dirs, warnings = result.PluginDirs, result.Warnings + } else { + var result SkillInstallResult + if kind == "managed" { + result, err = InstallManagedSkills(ctx, discardLogger(), root, []any{map[string]any{ + "name": "fixture", "version": "1.0.0", "download_url": url, "sha256": sha256Hex(body), + }}) + } else { + result, err = installSkillsAtRoot(ctx, discardLogger(), root, []skillDescriptor{ + {Name: "fixture", Version: "1.0.0", DownloadURL: url, SHA256: sha256Hex(body)}, + }, "skills") + } + dirs, warnings = result.SkillDirs, result.Warnings + } + if err != nil { + return err + } + if len(dirs) != 1 || len(warnings) != 0 { + return fmt.Errorf("dirs=%v warnings=%v", dirs, warnings) + } + content, err := os.ReadFile(filepath.Join(dirs[0], "SKILL.md")) + if err != nil { + return err + } + if string(content) != "Complete fixture contents.\n" { + return fmt.Errorf("incomplete file: %q", content) + } + return nil +} + +func TestInstallsConcurrentSameRoot(t *testing.T) { + for _, kind := range []string{"plugin", "skill", "managed"} { + t.Run(kind, func(t *testing.T) { + body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = w.Write(body) + })) + defer srv.Close() + parent := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(parent, alias); err != nil { + t.Fatal(err) + } + results := make(chan error, 8) + for i := range 8 { + root := filepath.Join(parent, "new", "root") + if i%2 == 1 { + root = filepath.Join(alias, "new", "root") + } + go func() { results <- installConcurrentFixture(context.Background(), kind, root, srv.URL, body) }() + } + for range 8 { + if err := <-results; err != nil { + t.Error(err) + } + } + if calls.Load() != 1 { + t.Errorf("downloads=%d, want one installation shared through the cache", calls.Load()) + } + }) + } +} + +func TestInstallWaitCancellation(t *testing.T) { + for _, kind := range []string{"plugin", "skill", "managed"} { + t.Run(kind, func(t *testing.T) { + body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) + started, release := make(chan struct{}, 2), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started <- struct{}{} + select { + case <-release: + _, _ = w.Write(body) + case <-r.Context().Done(): + } + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + root := t.TempDir() + first := make(chan error, 1) + go func() { first <- installConcurrentFixture(ctx, kind, root, srv.URL, body) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("first download did not start") + } + waiting, cancelWait := context.WithCancel(ctx) + cancelWait() + if err := installConcurrentFixture(waiting, kind, root, srv.URL, body); err != context.Canceled { + t.Errorf("waiting install error=%v, want cancellation", err) + } + close(release) + if err := <-first; err != nil { + t.Fatal(err) + } + if err := installConcurrentFixture(ctx, kind, root, srv.URL, body); err != nil { + t.Fatalf("cancelled waiter damaged installation: %v", err) + } + if len(started) != 0 { + t.Fatal("cancelled waiter started another download") + } + }) + } +} + +func TestInstallsIndependentRootsRemainConcurrent(t *testing.T) { + body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) + started, release := make(chan struct{}, 2), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started <- struct{}{} + select { + case <-release: + _, _ = w.Write(body) + case <-r.Context().Done(): + } + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + results := make(chan error, 2) + for _, root := range []string{t.TempDir(), t.TempDir()} { + go func() { results <- installConcurrentFixture(ctx, "plugin", root, srv.URL, body) }() + } + for range 2 { + select { + case <-started: + case <-ctx.Done(): + t.Fatal("independent roots were serialized") + } + } + close(release) + for range 2 { + if err := <-results; err != nil { + t.Error(err) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/options.go b/apps/parsar-daemon/internal/agent/claudecode/options.go new file mode 100644 index 000000000..e8679a5b8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/options.go @@ -0,0 +1,346 @@ +// Package claudecode is the agent_kind=claude_code implementation. It +// wraps the `claude` CLI in stream-json mode as a subprocess: the +// daemon writes user messages (and control responses for permission +// decisions) to stdin and translates the NDJSON event stream coming +// out of stdout into proto.Envelope frames for the dispatch router. +package claudecode + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// BuildResult is the output of BuildArgs. Cleanup is always non-nil +// (no-op when nothing was written) so callers can `defer res.Cleanup()` +// blindly. +type BuildResult struct { + Args []string + Env []string + Cleanup func() +} + +// BuildArgs translates an AgentOptions map into the `claude` CLI argv. +func BuildArgs(opts map[string]any, resumeSessionID string) (BuildResult, error) { + args := []string{ + "--output-format", "stream-json", + "--input-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--permission-prompt-tool", "stdio", + } + var cleanups []func() + cleanup := func() { + for _, c := range cleanups { + c() + } + } + env := []string{ + "DISABLE_TELEMETRY=1", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + // IS_SANDBOX=1 tells Claude Code to skip the "cannot be used + // with root/sudo privileges" guard. envd's RunCommand only + // passes a fixed PARSAR_* env allowlist into parsar-daemon, so the + // sandbox image's own IS_SANDBOX=1 does NOT propagate down to + // claude. Re-asserting it here is the actually-honored opt-out + // (--allow-dangerously-skip-permissions alone does NOT satisfy + // the check on 2.1.169). + "IS_SANDBOX=1", + // CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta + // fields (e.g. context_management.clear_thinking_20251015) + // from every /v1/messages body. Internal Anthropic-compatible + // gateways (vela-proxy) reject unknown fields with HTTP 400. + // The desktop app ships ~/.claude/settings.json preconfigured; + // the sandbox image doesn't, so the daemon sets it explicitly. + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", + } + result := BuildResult{Cleanup: cleanup} + + if v, ok := opts["model"]; ok { + s, ok := v.(string) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: model must be string, got %T", v) + } + if s != "" { + args = append(args, "--model", s) + } + } + + // bypassPermissions also appends --allow-dangerously-skip-permissions: + // Claude Code 2.1.x refuses bypass while running as root without this + // opt-in flag. Sandbox pods run as root, so omitting it would fail + // every cloud-mode prompt at subprocess start. No-op for non-root. + if v, ok := opts["mode"]; ok { + s, ok := v.(string) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: mode must be string, got %T", v) + } + if s != "" { + args = append(args, "--permission-mode", s) + if s == "bypassPermissions" { + args = append(args, "--allow-dangerously-skip-permissions") + } + } + } + + // allowed_tools: --allowedTools a,b,c + if v, ok := opts["allowed_tools"]; ok { + tools, err := stringSlice(v) + if err != nil { + return result, fmt.Errorf("claudecode.BuildArgs: allowed_tools: %w", err) + } + if len(tools) > 0 { + args = append(args, "--allowedTools", strings.Join(tools, ",")) + } + } + + // system_prompt vs override_system_prompt are mutually exclusive on + // the CLI. If both are supplied, override wins and we strip the + // append we already added. + hasAppend := false + hasOverride := false + if v, ok := opts["system_prompt"]; ok { + s, ok := v.(string) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: system_prompt must be string, got %T", v) + } + if s != "" { + hasAppend = true + args = append(args, "--append-system-prompt", s) + } + } + if v, ok := opts["override_system_prompt"]; ok { + s, ok := v.(string) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: override_system_prompt must be string, got %T", v) + } + if s != "" { + if hasAppend { + // Strip the append we just added; the pair is the last + // two elements. + args = args[:len(args)-2] + hasAppend = false + } + hasOverride = true + args = append(args, "--system-prompt", s) + } + } + _ = hasOverride + + // mcp_servers: serialize the map to a 0o600 tempfile and pass + // --mcp-config . Tempfile is deleted in Cleanup. + if v, ok := opts["mcp_servers"]; ok { + mcp, ok := v.(map[string]any) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: mcp_servers must be object, got %T", v) + } + if len(mcp) > 0 { + path, err := writeMCPTempfile(mcp) + if err != nil { + return result, err + } + cleanups = append(cleanups, func() { _ = os.Remove(path) }) + result.Cleanup = func() { + for _, c := range cleanups { + c() + } + } + args = append(args, "--mcp-config", path) + } + } + + // plugin_dirs: --plugin-dir x --plugin-dir y ... + if v, ok := opts["plugin_dirs"]; ok { + dirs, err := stringSlice(v) + if err != nil { + return result, fmt.Errorf("claudecode.BuildArgs: plugin_dirs: %w", err) + } + for _, d := range dirs { + args = append(args, "--plugin-dir", d) + } + } + + if resumeSessionID != "" { + args = append(args, "--resume", resumeSessionID) + } + + // env: passthrough KEY=value pairs. + if v, ok := opts["env"]; ok { + envMap, ok := v.(map[string]any) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: env must be object, got %T", v) + } + // Sort keys so the produced env slice is deterministic. + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return result, fmt.Errorf("claudecode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + } + + result.Args = args + result.Env = env + return result, nil +} + +// stringSlice coerces a value to []string, accepting either a typed +// []string or []any with all-string elements (which is what +// json.Unmarshal produces for a JSON array into map[string]any). +func stringSlice(v any) ([]string, error) { + switch x := v.(type) { + case []string: + return x, nil + case []any: + out := make([]string, 0, len(x)) + for i, el := range x { + s, ok := el.(string) + if !ok { + return nil, fmt.Errorf("element %d must be string, got %T", i, el) + } + out = append(out, s) + } + return out, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("must be array of strings, got %T", v) + } +} + +// writeMCPTempfile serialises the mcp_servers map to JSON and writes +// it to a 0o600 file in os.TempDir. Returns the absolute path. +func writeMCPTempfile(mcp map[string]any) (string, error) { + body, err := json.MarshalIndent(map[string]any{"mcpServers": mcp}, "", " ") + if err != nil { + return "", fmt.Errorf("claudecode: marshal mcp_servers: %w", err) + } + f, err := os.CreateTemp("", "parsar-daemon-mcp-*.json") + if err != nil { + return "", fmt.Errorf("claudecode: create mcp tempfile: %w", err) + } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", fmt.Errorf("claudecode: chmod mcp tempfile: %w", err) + } + if _, err := f.Write(body); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", fmt.Errorf("claudecode: write mcp tempfile: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(f.Name()) + return "", fmt.Errorf("claudecode: close mcp tempfile: %w", err) + } + // Resolve to absolute — tests sometimes change cwd which would + // make a relative path useless for the subprocess. + abs, err := filepath.Abs(f.Name()) + if err != nil { + _ = os.Remove(f.Name()) + return "", fmt.Errorf("claudecode: resolve mcp tempfile path: %w", err) + } + return abs, nil +} + +// userMessage is the JSON shape we write to claude stdin to deliver +// the prompt. +type userMessage struct { + Type string `json:"type"` + Message userMessageContent `json:"message"` +} + +type userMessageContent struct { + Role string `json:"role"` + // Content is either a bare string (text-only path) or a + // []userContentBlock when attachments are present. Both shapes + // are accepted by claude's stdin loop; the bare-string path keeps + // log greps for prompt content working in the common case. + Content any `json:"content"` +} + +// userContentBlock is one entry of Claude Code's array-of-blocks user +// message shape. JSON tags match Anthropic's content-block schema +// verbatim so the CLI forwards them to the model without translation. +type userContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *userContentSource `json:"source,omitempty"` +} + +type userContentSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +func buildUserMessage(prompt string) ([]byte, error) { + return buildUserMessageWithAttachments(prompt, nil) +} + +// buildUserMessageWithAttachments is the multimodal-aware variant. With +// no attachments, the output is byte-identical to the bare-string +// Content path so existing log greps for prompt content keep working. +// Non-image attachments are dropped — Claude Code SDK only understands +// the image block shape on stdin. +func buildUserMessageWithAttachments(prompt string, attachments []proto.PromptAttachment) ([]byte, error) { + if prompt == "" && len(attachments) == 0 { + return nil, errors.New("claudecode: empty prompt") + } + var content any + if len(attachments) == 0 { + content = prompt + } else { + blocks := make([]userContentBlock, 0, len(attachments)+1) + if prompt != "" { + blocks = append(blocks, userContentBlock{Type: "text", Text: prompt}) + } + for _, att := range attachments { + if att.Kind != "image" || att.DataBase64 == "" { + continue + } + mime := att.MIME + if mime == "" { + mime = "image/png" + } + blocks = append(blocks, userContentBlock{ + Type: "image", + Source: &userContentSource{ + Type: "base64", + MediaType: mime, + Data: att.DataBase64, + }, + }) + } + if len(blocks) == 0 { + return nil, errors.New("claudecode: empty prompt after dropping unsupported attachments") + } + content = blocks + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(userMessage{ + Type: "user", + Message: userMessageContent{ + Role: "user", + Content: content, + }, + }); err != nil { + return nil, fmt.Errorf("claudecode: marshal user message: %w", err) + } + return buf.Bytes(), nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/options_test.go b/apps/parsar-daemon/internal/agent/claudecode/options_test.go new file mode 100644 index 000000000..4140b1ae3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/options_test.go @@ -0,0 +1,405 @@ +package claudecode_test + +import ( + "encoding/json" + "os" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestBuildArgsBaseHasStreamFlags(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + wantContains := [][2]string{ + {"--output-format", "stream-json"}, + {"--input-format", "stream-json"}, + {"--permission-prompt-tool", "stdio"}, + } + for _, w := range wantContains { + if !containsPair(res.Args, w[0], w[1]) { + t.Errorf("missing flag pair %s=%s in %v", w[0], w[1], res.Args) + } + } + if !slices.Contains(res.Args, "--verbose") { + t.Errorf("missing --verbose in %v", res.Args) + } + if !slices.Contains(res.Args, "--include-partial-messages") { + t.Errorf("missing --include-partial-messages in %v", res.Args) + } +} + +// IS_SANDBOX=1 must be in every env passthrough. Without it Claude +// Code 2.1.x's root-guard kills the subprocess before the first user +// message. +func TestBuildArgsAlwaysExportsIsSandbox(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !slices.Contains(res.Env, "IS_SANDBOX=1") { + t.Errorf("IS_SANDBOX=1 missing from env, got %v", res.Env) + } +} + +// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta fields +// (e.g. context_management.clear_thinking_*) from /v1/messages bodies; +// internal Anthropic-compatible gateways reject unknown fields with 400. +func TestBuildArgsAlwaysDisablesExperimentalBetas(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !slices.Contains(res.Env, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1") { + t.Errorf("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 missing from env, got %v", res.Env) + } +} + +func TestBuildArgsHonoursPrimaryFlags(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "model": "sonnet", + "mode": "acceptEdits", + "allowed_tools": []any{"Bash", "Read", "Write"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--model", "sonnet") { + t.Errorf("--model sonnet not in %v", res.Args) + } + if !containsPair(res.Args, "--permission-mode", "acceptEdits") { + t.Errorf("--permission-mode acceptEdits not in %v", res.Args) + } + if !containsPair(res.Args, "--allowedTools", "Bash,Read,Write") { + t.Errorf("--allowedTools join not in %v", res.Args) + } +} + +func TestBuildArgsResumeUsesExplicitSessionID(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "resume_session_id": "from-map", + }, "from-arg") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--resume", "from-arg") { + t.Errorf("explicit resume id not preferred, args=%v", res.Args) + } + if slices.Contains(res.Args, "from-map") { + t.Errorf("map key leaked into args=%v", res.Args) + } +} + +func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{"resume_session_id": "session_xyz"}, "") + defer res.Cleanup() + if slices.Contains(res.Args, "--resume") || slices.Contains(res.Args, "session_xyz") { + t.Errorf("resume_session_id option must be ignored, args=%v", res.Args) + } +} + +func TestBuildArgsAppendSystemPromptAlone(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{"system_prompt": "be terse"}, "") + defer res.Cleanup() + if !containsPair(res.Args, "--append-system-prompt", "be terse") { + t.Errorf("--append-system-prompt missing, args=%v", res.Args) + } +} + +func TestBuildArgsBypassPermissionsAddsAllowDangerouslyFlag(t *testing.T) { + // Sandbox containers run as root; without + // --allow-dangerously-skip-permissions Claude Code refuses to + // bypass permissions for root callers. + res, err := claudecode.BuildArgs(map[string]any{ + "mode": "bypassPermissions", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--permission-mode", "bypassPermissions") { + t.Errorf("--permission-mode bypassPermissions not in %v", res.Args) + } + if !slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { + t.Errorf("--allow-dangerously-skip-permissions missing for bypass mode, args=%v", res.Args) + } +} + +func TestBuildArgsNonBypassModeOmitsAllowDangerouslyFlag(t *testing.T) { + for _, mode := range []string{"acceptEdits", "default", "plan"} { + res, err := claudecode.BuildArgs(map[string]any{"mode": mode}, "") + if err != nil { + t.Fatalf("BuildArgs(mode=%s): %v", mode, err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { + t.Errorf("mode=%s should not enable allow-dangerously flag, args=%v", mode, res.Args) + } + } +} + +func TestBuildArgsOverrideSystemPromptStripAppend(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are pirate", + }, "") + defer res.Cleanup() + if slices.Contains(res.Args, "--append-system-prompt") { + t.Errorf("override should have stripped append, args=%v", res.Args) + } + if !containsPair(res.Args, "--system-prompt", "you are pirate") { + t.Errorf("--system-prompt missing, args=%v", res.Args) + } +} + +func TestBuildArgsPluginDirsRepeated(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "plugin_dirs": []any{"/a", "/b", "/c"}, + }, "") + defer res.Cleanup() + got := 0 + for i, a := range res.Args { + if a == "--plugin-dir" { + got++ + if i+1 >= len(res.Args) { + t.Fatalf("trailing --plugin-dir without value: %v", res.Args) + } + } + } + if got != 3 { + t.Errorf("expected 3 --plugin-dir flags, got %d in %v", got, res.Args) + } +} + +func TestBuildArgsMCPServersWritesTempfile(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "mcp_servers": map[string]any{ + "github": map[string]any{"command": "/usr/local/bin/mcp-github"}, + }, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + // Find the --mcp-config path + path := "" + for i, a := range res.Args { + if a == "--mcp-config" { + if i+1 < len(res.Args) { + path = res.Args[i+1] + } + } + } + if path == "" { + t.Fatalf("--mcp-config path missing, args=%v", res.Args) + } + if !strings.Contains(path, "parsar-daemon-mcp-") { + t.Errorf("mcp tempfile name unexpected: %q", path) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat mcp tempfile: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mcp tempfile perm = %o, want 0600", perm) + } + + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read mcp tempfile: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("mcp tempfile not valid json: %v", err) + } + if _, ok := parsed["mcpServers"]; !ok { + t.Errorf("mcp tempfile missing mcpServers wrapper: %s", body) + } + + // Cleanup should remove the file. + res.Cleanup() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("mcp tempfile still exists after Cleanup: stat err=%v", err) + } +} + +func TestBuildArgsEnvPassthroughIsSorted(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "env": map[string]any{ + "ZED": "1", + "ANTHROPIC_KEY": "secret", + "OTHER_FLAG": "x", + }, + }, "") + defer res.Cleanup() + want := []string{"ANTHROPIC_KEY=secret", "OTHER_FLAG=x", "ZED=1"} + // res.Env always starts with the four baseline values; pop them off + // before checking the sort order of the user-supplied tail. + tail := res.Env[4:] + if !slices.Equal(tail, want) { + t.Errorf("env tail = %v, want sorted %v", tail, want) + } +} + +func TestBuildArgsRejectsWrongShapes(t *testing.T) { + cases := []struct { + name string + opts map[string]any + }{ + {"model not string", map[string]any{"model": 7}}, + {"mode not string", map[string]any{"mode": true}}, + {"allowed_tools not array", map[string]any{"allowed_tools": "Bash"}}, + {"plugin_dirs element not string", map[string]any{"plugin_dirs": []any{"/a", 7}}}, + {"mcp_servers not object", map[string]any{"mcp_servers": "json string"}}, + {"env value not string", map[string]any{"env": map[string]any{"K": 1}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := claudecode.BuildArgs(tc.opts, "") + if err == nil { + t.Fatal("BuildArgs accepted bad shape") + } + }) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func TestBuildUserMessage_TextOnlyKeepsBareStringContent(t *testing.T) { + // Backwards compat: no attachments → Content stays a bare string. + raw, err := claudecode.BuildUserMessageForTest("hello world", nil) + if err != nil { + t.Fatalf("BuildUserMessageForTest: %v", err) + } + var msg struct { + Type string `json:"type"` + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, raw) + } + if msg.Type != "user" || msg.Message.Role != "user" { + t.Fatalf("unexpected envelope: %+v", msg) + } + if string(msg.Message.Content) != `"hello world"` { + t.Fatalf("Content not bare string: %s", msg.Message.Content) + } +} + +func TestBuildUserMessage_WithImageEmitsContentBlocks(t *testing.T) { + att := []proto.PromptAttachment{ + {Kind: "image", MIME: "image/png", DataBase64: "AAAA"}, + {Kind: "image", MIME: "image/jpeg", DataBase64: "BBBB"}, + } + raw, err := claudecode.BuildUserMessageForTest("look at this", att) + if err != nil { + t.Fatalf("BuildUserMessageForTest: %v", err) + } + var msg struct { + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Source *struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + } `json:"source"` + } `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, raw) + } + if len(msg.Message.Content) != 3 { + t.Fatalf("expected 3 blocks (text+2 images), got %d: %s", len(msg.Message.Content), raw) + } + if msg.Message.Content[0].Type != "text" || msg.Message.Content[0].Text != "look at this" { + t.Errorf("block 0 = %+v", msg.Message.Content[0]) + } + for i, want := range []string{"image/png", "image/jpeg"} { + b := msg.Message.Content[i+1] + if b.Type != "image" || b.Source == nil { + t.Errorf("block %d not image: %+v", i+1, b) + continue + } + if b.Source.Type != "base64" || b.Source.MediaType != want { + t.Errorf("block %d source = %+v", i+1, b.Source) + } + } +} + +func TestBuildUserMessage_EmptyPromptWithImageStillValid(t *testing.T) { + // Pure-image-no-caption is a valid message — user pastes a + // screenshot without typing anything. + att := []proto.PromptAttachment{ + {Kind: "image", MIME: "image/png", DataBase64: "AAAA"}, + } + raw, err := claudecode.BuildUserMessageForTest("", att) + if err != nil { + t.Fatalf("BuildUserMessageForTest: %v", err) + } + var msg struct { + Message struct { + Content []struct { + Type string `json:"type"` + } `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, raw) + } + if len(msg.Message.Content) != 1 || msg.Message.Content[0].Type != "image" { + t.Fatalf("expected single image block, got %+v", msg.Message.Content) + } +} + +func TestBuildUserMessage_UnsupportedAttachmentsDropped(t *testing.T) { + // Non-image kinds aren't representable on stdin; dropped silently. + att := []proto.PromptAttachment{ + {Kind: "file", MIME: "text/plain", DataBase64: "AAAA"}, + {Kind: "image", DataBase64: ""}, + } + raw, err := claudecode.BuildUserMessageForTest("hi", att) + if err != nil { + t.Fatalf("BuildUserMessageForTest: %v", err) + } + if !strings.Contains(string(raw), `"hi"`) { + t.Errorf("prompt text missing: %s", raw) + } +} + +func TestBuildUserMessage_EmptyPromptAndNoImagesErrors(t *testing.T) { + if _, err := claudecode.BuildUserMessageForTest("", nil); err == nil { + t.Fatal("expected error for empty prompt + no attachments") + } + att := []proto.PromptAttachment{ + {Kind: "file", DataBase64: "X"}, + } + if _, err := claudecode.BuildUserMessageForTest("", att); err == nil { + t.Fatal("expected error when all attachments dropped + empty prompt") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser.go b/apps/parsar-daemon/internal/agent/claudecode/parser.go new file mode 100644 index 000000000..489841a44 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/parser.go @@ -0,0 +1,441 @@ +package claudecode + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "sync/atomic" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// pendingRecorder is the slice of pendingTable the parser needs. +// Interface form lets parser_test.go substitute a fake. +type pendingRecorder interface { + Record(permID, ccRequestID string, input map[string]any) + LookupByCC(ccRequestID string) (string, bool) +} + +// askRecorder is the slice of pendingAskTable the parser needs. +// Mirrors pendingRecorder so tests can substitute a fake. Record covers +// the tool_use path (toolUseID), RecordControl covers the +// control_request path (ccRequestID). +type askRecorder interface { + Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) + RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) +} + +// permIDMinter generates the daemon-side permission id (perm_<8hex>). +// Replaceable in tests so envelope IDs are deterministic. +type permIDMinter func() string + +// askIDMinter generates the daemon-side ask id (ask_<8hex>). +// Replaceable in tests so envelope IDs are deterministic. +type askIDMinter func() string + +func defaultPermIDMinter() string { + var b [4]byte + // crypto/rand.Read failure would silently collide perm ids across + // pending requests, causing an approve-on-wrong-tool bug — panic + // loud so operators see it. + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("claudecode: rand.Read for perm id failed: %v", err)) + } + return "perm_" + hex.EncodeToString(b[:]) +} + +func defaultAskIDMinter() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("claudecode: rand.Read for ask id failed: %v", err)) + } + return "ask_" + hex.EncodeToString(b[:]) +} + +// translator converts one NDJSON line from claude stdout into zero or +// more proto.Envelope frames. One translator lives per session. +type translator struct { + runID string + pending pendingRecorder + askPending askRecorder + seq atomic.Uint64 + mint permIDMinter + askMint askIDMinter + partialBlocks map[int]string +} + +func newTranslator(runID string, pending pendingRecorder, askPending askRecorder, mint permIDMinter, askMint askIDMinter) *translator { + if mint == nil { + mint = defaultPermIDMinter + } + if askMint == nil { + askMint = defaultAskIDMinter + } + return &translator{runID: runID, pending: pending, askPending: askPending, mint: mint, askMint: askMint} +} + +// translation is the per-line parser output. +type translation struct { + Envelopes []proto.Envelope + // Terminal is true when this line was a `result` frame — the + // session should stop reading stdout after consuming it. + Terminal bool + // SessionID is the upstream Claude session id surfaced on a system init. + // line (and again on the result frame). session.go writes this + // into binding metadata for --resume. + SessionID string +} + +// rawEnvelope is the minimal shared head every claude stream-json line +// has. +type rawEnvelope struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` +} + +// Translate parses one NDJSON line. Unknown types return an empty +// translation with no error to stay forward-compatible with new claude +// stream variants. Errors are reserved for malformed JSON on frames we +// claim to understand; the session pump treats them as drop-and-log so +// one bad line doesn't kill an otherwise fine run. +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + + var head rawEnvelope + if err := json.Unmarshal(line, &head); err != nil { + return translation{}, fmt.Errorf("claudecode: parse stream-json head: %w", err) + } + + switch head.Type { + case "system": + return t.translateSystem(line) + case "assistant": + return t.translateAssistant(line) + case "stream_event": + return t.translateStreamEvent(line) + case "user": + return t.translateUser(line) + case "control_request": + return t.translateControlRequest(line) + case "control_cancel_request": + return t.translateControlCancel(line) + case "result": + return t.translateResult(line, head.Subtype) + default: + return translation{}, nil + } +} + +// translateStreamEvent handles the raw Anthropic events emitted by Claude +// Code with --include-partial-messages. Claude still emits a complete +// assistant frame after these events, so translateAssistant suppresses its +// text/thinking copies once a corresponding partial delta has been observed. +func (t *translator) translateStreamEvent(line []byte) (translation, error) { + var msg struct { + Event struct { + Type string `json:"type"` + Index int `json:"index"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } `json:"delta"` + } `json:"event"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse stream_event frame: %w", err) + } + if msg.Event.Type != "content_block_delta" { + return translation{}, nil + } + + switch msg.Event.Delta.Type { + case "text_delta": + if msg.Event.Delta.Text == "" { + return translation{}, nil + } + if t.partialBlocks == nil { + t.partialBlocks = make(map[int]string) + } + t.partialBlocks[msg.Event.Index] = "text" + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: msg.Event.Delta.Text, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + case "thinking_delta": + if msg.Event.Delta.Thinking == "" { + return translation{}, nil + } + if t.partialBlocks == nil { + t.partialBlocks = make(map[int]string) + } + t.partialBlocks[msg.Event.Index] = "thinking" + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: msg.Event.Delta.Thinking, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + default: + return translation{}, nil + } +} + +func (t *translator) translateSystem(line []byte) (translation, error) { + var msg struct { + SessionID string `json:"session_id"` + } + // System lines have variable shape (init / compact / etc); missing + // session_id is a no-op, not an error. + _ = json.Unmarshal(line, &msg) + return translation{SessionID: msg.SessionID}, nil +} + +func (t *translator) translateAssistant(line []byte) (translation, error) { + var msg struct { + Message struct { + Content []json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse assistant frame: %w", err) + } + + var envs []proto.Envelope + for index, raw := range msg.Message.Content { + partialIndex := index + // Claude's per-block assistant frames restart content indexes at zero. + if len(msg.Message.Content) == 1 && len(t.partialBlocks) == 1 { + for streamedIndex := range t.partialBlocks { + partialIndex = streamedIndex + } + } + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + switch head.Type { + case "text": + if t.partialBlocks[partialIndex] == "text" { + continue + } + var item struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &item); err != nil || item.Text == "" { + continue + } + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: item.Text, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + case "thinking": + if t.partialBlocks[partialIndex] == "thinking" { + continue + } + var item struct { + Thinking string `json:"thinking"` + } + if err := json.Unmarshal(raw, &item); err != nil || item.Thinking == "" { + continue + } + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: item.Thinking, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + case "tool_use": + var item struct { + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + // Note: AskUserQuestion intentionally NOT intercepted on this + // path. claude-code under --permission-prompt-tool stdio (our + // fixed mode) emits the SAME AskUserQuestion call twice — once + // here as a tool_use, then a few ms later as a control_request + // "can_use_tool" check. Intercepting both would produce two + // PromptForUserChoice envelopes / two cards. The control_request + // path is the one we control end-to-end (claude waits for a + // matching control_response), so the tool_use copy goes + // through as a regular TypeToolCall — the UI logs the call, + // the user still only sees one card from the control_request + // path. See translateControlRequest below. + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: item.ID, + Name: item.Name, + Stage: "before", + Args: item.Input, + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + } + } + // Partial flags apply only to the complete assistant frame that follows + // those stream_event deltas. Reset them so a later assistant turn that is + // delivered without partial frames is not accidentally suppressed. + clear(t.partialBlocks) + return translation{Envelopes: envs}, nil +} + +func (t *translator) translateUser(line []byte) (translation, error) { + var msg struct { + Message struct { + Content []json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse user frame: %w", err) + } + + var envs []proto.Envelope + for _, raw := range msg.Message.Content { + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + if head.Type != "tool_result" { + continue + } + var item struct { + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: item.ToolUseID, + Stage: "after", + Result: map[string]any{ + "content": decodeToolResultContent(item.Content), + "is_error": item.IsError, + }, + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + } + return translation{Envelopes: envs}, nil +} + +// decodeToolResultContent best-effort decodes claude's tool_result +// content field, declared as `string | ContentBlock[]`. Returned as +// the natural Go shape so downstream consumers don't have to re-parse. +func decodeToolResultContent(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + return v +} + +func (t *translator) translateControlRequest(line []byte) (translation, error) { + var msg struct { + RequestID string `json:"request_id"` + Request struct { + Subtype string `json:"subtype"` + ToolName string `json:"tool_name"` + Input map[string]any `json:"input"` + } `json:"request"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse control_request: %w", err) + } + if msg.RequestID == "" { + return translation{}, fmt.Errorf("claudecode: control_request missing request_id") + } + + // AskUserQuestion comes through here too when claude-code runs with + // --permission-prompt-tool stdio. The SDK wraps it as a "can_use_tool" + // permission check rather than emitting a normal tool_use frame, so + // the tool_use-branch interception in translateAssistant never sees + // it. We re-route it into the ask flow here. The CC request_id is + // stashed under askID inside ccByAsk so SubmitPromptForUserChoice can + // write a matching control_response back. + if msg.Request.ToolName == askUserQuestionToolName { + if askEnv, ok := t.interceptAskUserQuestionFromControlRequest(msg.RequestID, msg.Request.Input); ok { + return translation{Envelopes: []proto.Envelope{askEnv}}, nil + } + // Fall through to the permission path when interception can't + // build a valid payload (e.g. questions array missing). claude + // will at least see SOME response on the control_request channel + // rather than blocking; the user will get a permission card they + // can deny. + } + + permID := t.mint() + if t.pending != nil { + t.pending.Record(permID, msg.RequestID, msg.Request.Input) + } + + title := msg.Request.ToolName + if title == "" { + title = "Permission request" + } + env, err := proto.NewEnvelope(proto.TypePermissionRequest, t.runID, proto.PermissionRequestPayload{ + RequestID: permID, + Tool: msg.Request.ToolName, + Title: title, + Payload: msg.Request.Input, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateControlCancel(line []byte) (translation, error) { + var msg struct { + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse control_cancel_request: %w", err) + } + if msg.RequestID == "" || t.pending == nil { + return translation{}, nil + } + permID, ok := t.pending.LookupByCC(msg.RequestID) + if !ok { + // Approval already came through and the entry was Delete'd — + // drop silently. + return translation{}, nil + } + env, err := proto.NewEnvelope(proto.TypePermissionCancel, permID, nil) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go b/apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go new file mode 100644 index 000000000..d7f69d53c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go @@ -0,0 +1,56 @@ +package claudecode_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTranslatePerBlockAssistantPreservesStreamWithoutCopies(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_blocks", nil, counterMinter()) + frames := []string{ + `{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}}`, + `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Checking."}}}`, + `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Checking."}]}}`, + `{"type":"stream_event","event":{"type":"content_block_stop","index":0}}`, + `{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}}`, + `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"PARSAR"}}}`, + `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"-IM-OK"}}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"PARSAR-IM-OK"}]}}`, + `{"type":"stream_event","event":{"type":"content_block_stop","index":1}}`, + `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"path":"policy.md"}}]}}`, + `{"type":"stream_event","event":{"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}}`, + `{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"PARSAR-IM-OK"}}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"PARSAR-IM-OK"}]}}`, + `{"type":"stream_event","event":{"type":"content_block_stop","index":3}}`, + `{"type":"result","subtype":"success","result":"PARSAR-IM-OK"}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"complete-only"}]}}`, + } + var text, thinking string + var tools, done int + for _, frame := range frames { + out, err := tr.Translate([]byte(frame)) + if err != nil { + t.Fatal(err) + } + for _, env := range out.Envelopes { + switch env.Type { + case proto.TypeDelta: + text += mustDecode[proto.DeltaPayload](t, env.Payload).Delta + case proto.TypeThinking: + thinking += mustDecode[proto.ThinkingPayload](t, env.Payload).Text + case proto.TypeToolCall: + tools++ + case proto.TypeDone: + done++ + if got := mustDecode[proto.DonePayload](t, env.Payload).Content; got != "PARSAR-IM-OK" { + t.Fatalf("final content = %q", got) + } + } + } + } + if text != "PARSAR-IM-OKPARSAR-IM-OKcomplete-only" || thinking != "Checking." || tools != 1 || done != 1 { + t.Fatalf("text=%q thinking=%q tools=%d done=%d", text, thinking, tools, done) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_result.go b/apps/parsar-daemon/internal/agent/claudecode/parser_result.go new file mode 100644 index 000000000..e7725db05 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/parser_result.go @@ -0,0 +1,127 @@ +package claudecode + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// resultUsage is split out so we can decode usage even when the +// success/error branches differ. +type resultUsage struct { + InputTokens int32 `json:"input_tokens"` + OutputTokens int32 `json:"output_tokens"` + CacheCreationInputTokens int32 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int32 `json:"cache_read_input_tokens,omitempty"` +} + +func (t *translator) translateResult(line []byte, subtype string) (translation, error) { + defer clear(t.partialBlocks) + + var msg struct { + IsError bool `json:"is_error"` + Result string `json:"result"` + Error string `json:"error"` + Errors []string `json:"errors"` + SessionID string `json:"session_id"` + TotalCostUSD float64 `json:"total_cost_usd"` + Usage resultUsage `json:"usage"` + // ModelUsage's map KEY is the model slug — the result frame + // has no top-level `"model"` field. Single-turn chats have + // exactly one entry; multi-model orchestration would have + // more, and we take whatever the map iteration hands us first + // (the renderer's footer keys off a single model anyway). + ModelUsage map[string]json.RawMessage `json:"modelUsage"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse result frame: %w", err) + } + + var envs []proto.Envelope + + // Pick the first model the CLI reports under modelUsage. Map + // iteration order is fine for the 1-model common case; multi-model + // runs land on whichever wins the iteration. + model := "" + for k := range msg.ModelUsage { + model = k + break + } + + usage := proto.Usage{ + Provider: "claude_code", + Model: model, + InputTokens: msg.Usage.InputTokens, + OutputTokens: msg.Usage.OutputTokens, + CostUSD: msg.TotalCostUSD, + } + if msg.Usage.CacheCreationInputTokens != 0 || msg.Usage.CacheReadInputTokens != 0 { + usage.Raw = map[string]any{ + "cache_creation_input_tokens": msg.Usage.CacheCreationInputTokens, + "cache_read_input_tokens": msg.Usage.CacheReadInputTokens, + } + } + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { + usageEnv, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}) + if err != nil { + return translation{}, err + } + envs = append(envs, usageEnv) + } + + // Subtype "success" is the only success-shaped result; everything + // else (error_during_execution, error_max_turns, ...) is a failure. + isError := msg.IsError || (subtype != "" && subtype != "success" && strings.HasPrefix(subtype, "error")) + if isError { + errMsg := strings.TrimSpace(msg.Error) + // Claude Code sometimes reports provider/API failures with + // subtype="success" and is_error=true, placing the useful error in + // result instead of error. Preserve that message rather than emitting + // the misleading fallback "claude_code: success". + if errMsg == "" && msg.IsError { + errMsg = strings.TrimSpace(msg.Result) + } + if errMsg == "" { + var details []string + for _, detail := range msg.Errors { + if detail = strings.TrimSpace(detail); detail != "" { + details = append(details, detail) + } + } + errMsg = strings.Join(details, "\n") + } + if errMsg == "" { + if subtype != "" { + errMsg = "claude_code: " + subtype + } else { + errMsg = "claude_code: unspecified error" + } + } + errEnv, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) + if err != nil { + return translation{}, err + } + envs = append(envs, errEnv) + } + + var doneMeta map[string]any + if strings.TrimSpace(msg.SessionID) != "" { + doneMeta = map[string]any{ + proto.DoneMetaAgentSessionID: msg.SessionID, + proto.DoneMetaAgentSessionType: "claude_session", + } + } + doneEnv, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ + Content: msg.Result, + Usage: usage, + Metadata: doneMeta, + }) + if err != nil { + return translation{}, err + } + envs = append(envs, doneEnv) + + return translation{Envelopes: envs, Terminal: true, SessionID: msg.SessionID}, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go b/apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go new file mode 100644 index 000000000..2c11ed1a5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go @@ -0,0 +1,172 @@ +package claudecode_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTranslateResultErrorDetails(t *testing.T) { + for _, tt := range []struct { + name, fields, want string + }{ + {"missing session", `"errors":["No conversation found with session ID: missing-session"]`, "No conversation found with session ID: missing-session"}, + {"multiple details", `"errors":[" first error ","", " ","second error"]`, "first error\nsecond error"}, + {"empty details", `"errors":["", " "]`, "claude_code: error_during_execution"}, + {"legacy error", `"error":" legacy error ","errors":["array detail"]`, "legacy error"}, + {"legacy result", `"result":" legacy result ","errors":["array detail"]`, "legacy result"}, + } { + t.Run(tt.name, func(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_details", nil, counterMinter()) + line := []byte(`{"type":"result","subtype":"error_during_execution","is_error":true,` + tt.fields + `}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatal(err) + } + if !out.Terminal || len(out.Envelopes) != 2 || out.Envelopes[0].Type != proto.TypeError || out.Envelopes[1].Type != proto.TypeDone { + t.Fatalf("want terminal error then done, got %+v", out) + } + var detail proto.ErrorPayload + if err := json.Unmarshal(out.Envelopes[0].Payload, &detail); err != nil { + t.Fatal(err) + } + if detail.Error != tt.want { + t.Fatalf("error = %q, want %q", detail.Error, tt.want) + } + }) + } +} + +func TestTranslateResultSuccessEmitsUsageThenDone(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + line := []byte(`{ + "type":"result","subtype":"success","is_error":false, + "result":"final answer text","session_id":"sess_abc", + "total_cost_usd":0.01234, + "usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":7}, + "modelUsage":{"claude-opus-4-7-thinking-medium":{"inputTokens":100,"outputTokens":50,"contextWindow":200000,"costUSD":0.01234}} + }`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if !out.Terminal { + t.Error("result must be terminal") + } + if out.SessionID != "sess_abc" { + t.Errorf("SessionID = %q, want sess_abc", out.SessionID) + } + if len(out.Envelopes) != 2 { + t.Fatalf("want 2 envs (usage, done), got %d %#v", len(out.Envelopes), out.Envelopes) + } + if out.Envelopes[0].Type != "usage" || out.Envelopes[1].Type != "done" { + t.Errorf("env order wrong, got %s,%s", out.Envelopes[0].Type, out.Envelopes[1].Type) + } + usage := mustDecode[struct { + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int32 `json:"input_tokens"` + OutputTokens int32 `json:"output_tokens"` + CostUSD float64 `json:"cost_usd"` + Raw map[string]any `json:"raw"` + }](t, out.Envelopes[0].Payload) + if usage.Provider != "claude_code" { + t.Errorf("usage.Provider = %q", usage.Provider) + } + // Model flows through from modelUsage's map key — no top-level + // "model" field in the result frame. + if usage.Model != "claude-opus-4-7-thinking-medium" { + t.Errorf("usage.Model = %q, want claude-opus-4-7-thinking-medium", usage.Model) + } + if usage.InputTokens != 100 || usage.OutputTokens != 50 { + t.Errorf("usage tokens: %+v", usage) + } + if usage.CostUSD != 0.01234 { + t.Errorf("usage cost = %v", usage.CostUSD) + } + if _, ok := usage.Raw["cache_read_input_tokens"]; !ok { + t.Errorf("usage.Raw missing cache stats: %v", usage.Raw) + } + done := mustDecode[struct { + Content string `json:"content"` + Metadata map[string]any `json:"metadata"` + }](t, out.Envelopes[1].Payload) + if done.Content != "final answer text" { + t.Errorf("done.Content = %q", done.Content) + } + if done.Metadata == nil { + t.Fatalf("done.Metadata missing") + } + if got, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); got != "sess_abc" { + t.Errorf("done.Metadata.agent_session_id = %q, want sess_abc", got) + } + if got, _ := done.Metadata[proto.DoneMetaAgentSessionType].(string); got != "claude_session" { + t.Errorf("done.Metadata.agent_session_type = %q", got) + } +} + +func TestTranslateResultSuccessNoUsageOmitsUsage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + line := []byte(`{"type":"result","subtype":"success","result":"hi"}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "done" { + t.Errorf("want only done when no usage, got %#v", out.Envelopes) + } +} + +func TestTranslateResultErrorSubtypeEmitsError(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + line := []byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"boom"}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if !out.Terminal { + t.Error("result error must be terminal") + } + if len(out.Envelopes) != 2 { + t.Fatalf("want error+done, got %#v", out.Envelopes) + } + if out.Envelopes[0].Type != "error" || out.Envelopes[1].Type != "done" { + t.Errorf("env order: %s,%s", out.Envelopes[0].Type, out.Envelopes[1].Type) + } + got := mustDecode[struct { + Error string `json:"error"` + }](t, out.Envelopes[0].Payload) + if got.Error != "boom" { + t.Errorf("error text = %q", got.Error) + } +} + +func TestTranslateResultErrorWithoutMessageFallsBackToSubtype(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + line := []byte(`{"type":"result","subtype":"error_max_turns","is_error":true}`) + out, _ := tr.Translate(line) + got := mustDecode[struct { + Error string `json:"error"` + }](t, out.Envelopes[0].Payload) + if !strings.Contains(got.Error, "error_max_turns") { + t.Errorf("error fallback should mention subtype, got %q", got.Error) + } +} + +func TestTranslateResultIsErrorSuccessSubtypeUsesResultMessage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + line := []byte(`{"type":"result","subtype":"success","is_error":true,"result":"API Error: 400 content rejected"}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + got := mustDecode[struct { + Error string `json:"error"` + }](t, out.Envelopes[0].Payload) + if got.Error != "API Error: 400 content rejected" { + t.Errorf("error text = %q", got.Error) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_test.go b/apps/parsar-daemon/internal/agent/claudecode/parser_test.go new file mode 100644 index 000000000..437511419 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/parser_test.go @@ -0,0 +1,399 @@ +package claudecode_test + +import ( + "encoding/json" + "fmt" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// counterMinter emits perm_001, perm_002, ... for deterministic +// envelope IDs in tests. +func counterMinter() func() string { + var ( + mu sync.Mutex + n int + ) + return func() string { + mu.Lock() + defer mu.Unlock() + n++ + return fmt.Sprintf("perm_%03d", n) + } +} + +func mustDecode[T any](t *testing.T, raw []byte) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode %T: %v\nraw=%s", v, err, raw) + } + return v +} + +func TestTranslateSystemInitSurfacesSessionID(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_1", nil, counterMinter()) + line := []byte(`{"type":"system","subtype":"init","session_id":"sess_abc","tools":["Bash"]}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if out.SessionID != "sess_abc" { + t.Errorf("SessionID = %q, want sess_abc", out.SessionID) + } + if len(out.Envelopes) != 0 { + t.Errorf("system init must not emit envelopes, got %d", len(out.Envelopes)) + } + if out.Terminal { + t.Errorf("system init is not terminal") + } +} + +func TestTranslateAssistantTextEmitsDelta(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_42", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"hello "}, + {"type":"text","text":"world"} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 2 { + t.Fatalf("want 2 envelopes, got %d", len(out.Envelopes)) + } + for i, env := range out.Envelopes { + if env.Type != "delta" { + t.Errorf("env[%d].Type = %q, want delta", i, env.Type) + } + if env.ID != "run_42" { + t.Errorf("env[%d].ID = %q, want run_42", i, env.ID) + } + } + d1 := mustDecode[struct { + Delta string `json:"delta"` + Sequence uint64 `json:"sequence"` + }](t, out.Envelopes[0].Payload) + d2 := mustDecode[struct { + Delta string `json:"delta"` + Sequence uint64 `json:"sequence"` + }](t, out.Envelopes[1].Payload) + if d1.Delta != "hello " || d2.Delta != "world" { + t.Errorf("delta texts: %q,%q", d1.Delta, d2.Delta) + } + if d1.Sequence == 0 || d2.Sequence <= d1.Sequence { + t.Errorf("sequence not monotonic: %d,%d", d1.Sequence, d2.Sequence) + } +} + +func TestTranslateAssistantThinkingEmitsThinking(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_x", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"thinking","thinking":"let me think...","signature":"sig"} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "thinking" { + t.Fatalf("want one thinking env, got %#v", out.Envelopes) + } + got := mustDecode[struct { + Text string `json:"text"` + }](t, out.Envelopes[0].Payload) + if got.Text != "let me think..." { + t.Errorf("Text = %q", got.Text) + } +} + +func TestTranslatePartialMessagesStreamIncrementallyWithoutAssistantDuplicates(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) + frames := [][]byte{ + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}}`), + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}`), + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"checking"}}}`), + } + + var got []proto.Envelope + for _, frame := range frames { + out, err := tr.Translate(frame) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + got = append(got, out.Envelopes...) + } + if len(got) != 3 { + t.Fatalf("want 3 partial envelopes, got %d", len(got)) + } + if got[0].Type != proto.TypeDelta || got[1].Type != proto.TypeDelta || got[2].Type != proto.TypeThinking { + t.Fatalf("partial envelope types = %q, %q, %q", got[0].Type, got[1].Type, got[2].Type) + } + + full, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello world"},{"type":"thinking","thinking":"checking"}]}}`)) + if err != nil { + t.Fatalf("Translate complete assistant frame: %v", err) + } + if len(full.Envelopes) != 0 { + t.Fatalf("complete assistant frame duplicated partial content: %#v", full.Envelopes) + } + + next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn without partial frames"}]}}`)) + if err != nil { + t.Fatalf("Translate next complete assistant frame: %v", err) + } + if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("next assistant frame was suppressed by stale partial state: %#v", next.Envelopes) + } +} + +func TestTranslateStreamEventIgnoresNonTextDeltas(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 0 { + t.Fatalf("input JSON delta should not emit an envelope: %#v", out.Envelopes) + } +} + +func TestTranslatePartialMessagesSuppressOnlyMatchingContentBlock(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial_blocks", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed"}}}`)) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + + out, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed"},{"type":"text","text":"complete-only"}]}}`)) + if err != nil { + t.Fatalf("Translate complete frame: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("complete-only block should be preserved: %#v", out.Envelopes) + } + got := mustDecode[struct { + Delta string `json:"delta"` + }](t, out.Envelopes[0].Payload) + if got.Delta != "complete-only" { + t.Fatalf("delta = %q, want complete-only", got.Delta) + } +} + +func TestTranslateResultClearsPartialBlocksBeforeNextTurn(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial_error", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial before failure"}}}`)) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + + _, err = tr.Translate([]byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"provider failed"}`)) + if err != nil { + t.Fatalf("Translate error result: %v", err) + } + + next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn"}]}}`)) + if err != nil { + t.Fatalf("Translate next assistant frame: %v", err) + } + if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("next assistant frame was suppressed by result state: %#v", next.Envelopes) + } +} + +func TestTranslateAssistantToolUseEmitsBeforeStage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_t", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"ls"}} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 { + t.Fatalf("want 1 env, got %d", len(out.Envelopes)) + } + got := mustDecode[struct { + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage"` + Args map[string]any `json:"args"` + }](t, out.Envelopes[0].Payload) + if got.ID != "toolu_99" || got.Name != "Bash" || got.Stage != "before" { + t.Errorf("payload mismatch: %+v", got) + } + if got.Args["command"] != "ls" { + t.Errorf("args missing command: %v", got.Args) + } +} + +func TestTranslateAssistantMixedContentMonotonicSequence(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_seq", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"a"}, + {"type":"thinking","thinking":"b"}, + {"type":"text","text":"c"}, + {"type":"tool_use","id":"t","name":"Read","input":{}} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 4 { + t.Fatalf("want 4 envs, got %d", len(out.Envelopes)) + } + var seqs []uint64 + for _, e := range out.Envelopes { + if e.Type == "delta" || e.Type == "thinking" { + d := mustDecode[struct { + Sequence uint64 `json:"sequence"` + }](t, e.Payload) + seqs = append(seqs, d.Sequence) + } + } + for i := 1; i < len(seqs); i++ { + if seqs[i] <= seqs[i-1] { + t.Errorf("sequence not strictly increasing: %v", seqs) + } + } +} + +func TestTranslateUserToolResultEmitsAfterStage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) + line := []byte(`{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_99","content":"hello\nworld\n","is_error":false} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "tool_call" { + t.Fatalf("want one tool_call after env, got %#v", out.Envelopes) + } + got := mustDecode[struct { + ID string `json:"id"` + Stage string `json:"stage"` + Result map[string]any `json:"result"` + }](t, out.Envelopes[0].Payload) + if got.ID != "toolu_99" || got.Stage != "after" { + t.Errorf("got %+v", got) + } + if got.Result["content"] != "hello\nworld\n" { + t.Errorf("content not preserved, got %v", got.Result["content"]) + } + if got.Result["is_error"] != false { + t.Errorf("is_error not preserved: %v", got.Result["is_error"]) + } +} + +func TestTranslateUserToolResultPreservesStructuredContent(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) + line := []byte(`{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":"x"}],"is_error":true} + ]}}`) + out, _ := tr.Translate(line) + got := mustDecode[struct { + Result map[string]any `json:"result"` + }](t, out.Envelopes[0].Payload) + if _, ok := got.Result["content"].([]any); !ok { + t.Errorf("expected []any content block array, got %T %v", got.Result["content"], got.Result["content"]) + } +} + +func TestTranslateControlRequestMintsPermIDAndRecords(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + line := []byte(`{"type":"control_request","request_id":"req_001","request":{ + "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/a"} + }}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_request" { + t.Fatalf("want one permission_request env, got %#v", out.Envelopes) + } + env := out.Envelopes[0] + if env.ID != "run_z" { + t.Errorf("env.ID = %q, want run_z", env.ID) + } + pr := mustDecode[struct { + RequestID string `json:"request_id"` + Tool string `json:"tool"` + Title string `json:"title"` + Payload map[string]any `json:"payload"` + }](t, env.Payload) + if pr.RequestID != "perm_001" || pr.Tool != "Bash" || pr.Title != "Bash" { + t.Errorf("permission payload mismatch: %+v", pr) + } + if pr.Payload["command"] != "rm -rf /tmp/a" { + t.Errorf("payload not preserved: %v", pr.Payload) + } + entry, ok := pending.Resolve("perm_001") + if !ok || entry.CCRequestID != "req_001" { + t.Errorf("pending table not recorded: %+v ok=%v", entry, ok) + } +} + +func TestTranslateControlCancelLooksUpPerm(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + // Seed by translating the original control_request. + _, _ = tr.Translate([]byte(`{"type":"control_request","request_id":"req_5","request":{"subtype":"can_use_tool","tool_name":"Write","input":{}}}`)) + out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_5"}`)) + if err != nil { + t.Fatalf("Translate cancel: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_cancel" { + t.Fatalf("want one permission_cancel env, got %#v", out.Envelopes) + } + if out.Envelopes[0].ID != "perm_001" { + t.Errorf("cancel env.ID = %q, want perm_001", out.Envelopes[0].ID) + } +} + +func TestTranslateControlCancelUnknownCCIDDropped(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_nope"}`)) + if err != nil { + t.Fatalf("Translate cancel: %v", err) + } + if len(out.Envelopes) != 0 { + t.Errorf("unknown cancel should be dropped, got %#v", out.Envelopes) + } +} + +func TestTranslateUnknownTypeIsNoOp(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"some_future_thing","foo":1}`)) + if err != nil { + t.Fatalf("unknown type should not error: %v", err) + } + if len(out.Envelopes) != 0 || out.Terminal { + t.Errorf("unknown type emitted envelopes/terminal: %#v term=%v", out.Envelopes, out.Terminal) + } +} + +func TestTranslateBlankLineIsNoOp(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + for _, s := range []string{"", " ", "\n", "\t \n"} { + out, err := tr.Translate([]byte(s)) + if err != nil { + t.Errorf("blank line %q errored: %v", s, err) + } + if len(out.Envelopes) != 0 { + t.Errorf("blank line %q emitted envs", s) + } + } +} + +func TestTranslateMalformedJSONReturnsError(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"assistant", broken`)) + if err == nil { + t.Error("expected error on malformed JSON") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/permission.go b/apps/parsar-daemon/internal/agent/claudecode/permission.go new file mode 100644 index 000000000..7a3ab946d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/permission.go @@ -0,0 +1,100 @@ +package claudecode + +import "sync" + +// pendingTable maps daemon-minted perm_<8hex> ids to the originating +// Claude Code control_request.request_id. Both directions are needed: +// - SubmitPermission from the gateway looks up cc_request_id (and +// original input) so we can write a valid control_response. +// - A control_cancel_request from claude stdout needs the reverse +// translation so the gateway sees a matching permission_cancel. +type pendingTable struct { + mu sync.Mutex + byPerm map[string]pendingEntry + byCCReq map[string]string +} + +type pendingEntry struct { + CCRequestID string + Input map[string]any +} + +func newPendingTable() *pendingTable { + return &pendingTable{ + byPerm: make(map[string]pendingEntry), + byCCReq: make(map[string]string), + } +} + +// Record links a freshly minted perm_id to the originating +// cc_request_id and the tool-call input the human is being asked to +// approve. +func (p *pendingTable) Record(permID, ccRequestID string, input map[string]any) { + if permID == "" || ccRequestID == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.byPerm[permID] = pendingEntry{CCRequestID: ccRequestID, Input: input} + p.byCCReq[ccRequestID] = permID +} + +// Resolve returns the entry recorded for permID. ok is false when +// permID is unknown or already Delete-d. +func (p *pendingTable) Resolve(permID string) (pendingEntry, bool) { + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byPerm[permID] + return e, ok +} + +// Take atomically returns and removes one permission. Human submissions and +// the timeout watchdog race through this method so only one control_response +// can reach Claude Code. +func (p *pendingTable) Take(permID string) (pendingEntry, bool) { + if permID == "" { + return pendingEntry{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byPerm[permID] + if !ok { + return pendingEntry{}, false + } + delete(p.byPerm, permID) + delete(p.byCCReq, e.CCRequestID) + return e, true +} + +// LookupByCC reverses the mapping for control_cancel_request handling. +func (p *pendingTable) LookupByCC(ccRequestID string) (string, bool) { + if ccRequestID == "" { + return "", false + } + p.mu.Lock() + defer p.mu.Unlock() + permID, ok := p.byCCReq[ccRequestID] + return permID, ok +} + +// Delete removes both directions for permID. +func (p *pendingTable) Delete(permID string) { + if permID == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + e, ok := p.byPerm[permID] + if !ok { + return + } + delete(p.byPerm, permID) + delete(p.byCCReq, e.CCRequestID) +} + +// Len reports the number of outstanding permissions. +func (p *pendingTable) Len() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.byPerm) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/permission_test.go b/apps/parsar-daemon/internal/agent/claudecode/permission_test.go new file mode 100644 index 000000000..bb1633858 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/permission_test.go @@ -0,0 +1,71 @@ +package claudecode_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" +) + +func TestPendingTableRoundTrip(t *testing.T) { + tbl := claudecode.NewPendingTableForTest() + tbl.Record("perm_aabbccdd", "req_42", map[string]any{"command": "ls -la"}) + + e, ok := tbl.Resolve("perm_aabbccdd") + if !ok { + t.Fatal("Resolve missed a recorded perm id") + } + if e.CCRequestID != "req_42" { + t.Errorf("CCRequestID = %q, want req_42", e.CCRequestID) + } + if e.Input["command"] != "ls -la" { + t.Errorf("input not preserved, got %v", e.Input) + } + + permID, ok := tbl.LookupByCC("req_42") + if !ok || permID != "perm_aabbccdd" { + t.Errorf("LookupByCC returned (%q,%v), want (perm_aabbccdd,true)", permID, ok) + } +} + +func TestPendingTableDeleteIsBidirectional(t *testing.T) { + tbl := claudecode.NewPendingTableForTest() + tbl.Record("perm_1", "req_a", nil) + tbl.Record("perm_2", "req_b", nil) + + tbl.Delete("perm_1") + if _, ok := tbl.Resolve("perm_1"); ok { + t.Error("Resolve still returns deleted perm") + } + if _, ok := tbl.LookupByCC("req_a"); ok { + t.Error("LookupByCC still returns deleted cc id") + } + if tbl.Len() != 1 { + t.Errorf("Len = %d, want 1", tbl.Len()) + } + if _, ok := tbl.Resolve("perm_2"); !ok { + t.Error("untouched perm_2 disappeared") + } +} + +func TestPendingTableUnknownLookupsAreFalse(t *testing.T) { + tbl := claudecode.NewPendingTableForTest() + if _, ok := tbl.Resolve("perm_nope"); ok { + t.Error("Resolve returned ok for unknown perm") + } + if _, ok := tbl.LookupByCC("req_nope"); ok { + t.Error("LookupByCC returned ok for unknown cc") + } + tbl.Delete("perm_nope") +} + +func TestPendingTableIgnoresEmptyIDs(t *testing.T) { + tbl := claudecode.NewPendingTableForTest() + tbl.Record("", "req_a", nil) + tbl.Record("perm_a", "", nil) + if tbl.Len() != 0 { + t.Errorf("Len = %d after empty-id records, want 0", tbl.Len()) + } + if _, ok := tbl.LookupByCC(""); ok { + t.Error("LookupByCC ok for empty cc id") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins.go b/apps/parsar-daemon/internal/agent/claudecode/plugins.go new file mode 100644 index 000000000..8f963ee7c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/plugins.go @@ -0,0 +1,437 @@ +package claudecode + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// pluginDescriptor is the daemon-side view of one server-sent plugin +// entry under agent_options["plugins"]: +// +// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } +type pluginDescriptor struct { + Name string + Version string + DownloadURL string + SHA256 string +} + +// PluginInstallResult is what installPlugins returns: local directory +// paths to feed into `--plugin-dir`, plus warnings the session should +// surface. Errors that abort install bubble up through the error +// return; warnings cover the "N-1 of N installed" case. +type PluginInstallResult struct { + PluginDirs []string + Warnings []string +} + +// pluginInstallTimeout caps a single plugin's download + extract step. +const pluginInstallTimeout = 60 * time.Second + +// maxPluginZipBytes mirrors the server-side cap in +// server/internal/capability/parser/plugin_validator.go. Defense in +// depth. +const maxPluginZipBytes int64 = 32 * 1024 * 1024 + +// pluginsHTTPClient timeout is larger than pluginInstallTimeout so the +// per-call context cancel dominates. +var pluginsHTTPClient = &http.Client{ + Timeout: pluginInstallTimeout + 10*time.Second, +} + +// fetchPluginZip GETs url into dst, capping the body at +// maxPluginZipBytes. Returns an OPEN file descriptor positioned at +// offset 0; the caller closes it. Holding the FD across verify + +// extract closes the TOCTOU between hashing the on-disk bytes and +// reading them for extract — even if someone swaps the file, the open +// FD points at the original inode. +// +// Only http/https are accepted to defend against a future +// canonical_spec letting attacker-supplied download_url reach this +// code with file:// or http://internal-ip/... values. +func fetchPluginZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { + parsed, err := url.Parse(downloadURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + // Don't include downloadURL — it carries the signature query + // string. + return nil, errors.New("download_url must be http(s)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, errors.New("build request failed") + } + resp, err := pluginsHTTPClient.Do(req) + if err != nil { + // Strip embedded URL via sanitizeHTTPClientError — + // OSSAccessKeyId + Signature would otherwise leak into the + // daemon log. + return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + return nil, fmt.Errorf("get: status %d", resp.StatusCode) + } + + // O_EXCL — the per-call uuid in the path makes a collision a + // programmer error, not an attacker condition. Failing fast is + // safer than silent truncation. + f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open dst: %w", err) + } + + limited := io.LimitReader(resp.Body, maxPluginZipBytes+1) + written, err := io.Copy(f, limited) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("copy body: %w", err) + } + if written > maxPluginZipBytes { + _ = f.Close() + return nil, fmt.Errorf("zip exceeds %d byte cap", maxPluginZipBytes) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = f.Close() + return nil, fmt.Errorf("seek after write: %w", err) + } + return f, nil +} + +// sanitizeHTTPClientError strips the URL embedded by *url.Error. +// net/http returns errors that include the full request URL — for +// presigned OSS URLs that's OSSAccessKeyId + Signature + Expires. +// Without redaction those credentials land in the daemon log via +// PluginInstallResult.Warnings → session.go logger.Warn. +// +// Format is ` "": ` — keep the method + inner +// message, drop the URL. +func sanitizeHTTPClientError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + open := strings.Index(msg, `"`) + if open < 0 { + return msg + } + close := strings.Index(msg[open+1:], `"`) + if close < 0 { + return msg + } + closeAbs := open + 1 + close + if closeAbs+2 > len(msg) { + return msg + } + return msg[:open] + "" + msg[closeAbs+1:] +} + +// verifyPluginSHA256FromFD hashes the bytes the open FD points at and +// compares against want (lowercase hex). Rewinds the FD afterwards. +func verifyPluginSHA256FromFD(fd *os.File, want string) error { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" { + return errors.New("verify: empty expected sha256") + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("verify: seek: %w", err) + } + h := sha256.New() + if _, err := io.Copy(h, fd); err != nil { + return fmt.Errorf("verify: hash: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) + } + return nil +} + +// extractPluginZipFromFD reads via io.NewSectionReader rather than +// re-opening the path so the byte stream stays identical to the +// verified one (TOCTOU defense). +func extractPluginZipFromFD(fd *os.File, size int64, dst string) error { + zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) + if err != nil { + return fmt.Errorf("extract: open zip: %w", err) + } + + root := detectSingleZipRoot(zr.File) + absDst, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("extract: abs dst: %w", err) + } + + for _, f := range zr.File { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + // Skip non-regular zip entries (symlinks, devices, named + // pipes). Symlink entries flagged with Unix lrwxrwxrwx mode + // bits would otherwise be written as plain files containing + // the link target string — an exfil vector. + mode := f.Mode() + if !f.FileInfo().IsDir() && !mode.IsRegular() { + continue + } + if root != "" { + if !strings.HasPrefix(name, root) { + continue + } + name = strings.TrimPrefix(name, root) + if name == "" { + continue + } + } + + target := filepath.Join(absDst, name) + rel, err := filepath.Rel(absDst, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("extract: entry %q escapes target", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("extract: mkdir %s: %w", target, err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) + } + if err := writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +// writeZipEntry streams one zip entry into target preserving the +// entry's mode bits (executables stay executable — hook scripts need +// this). 0644 default when no mode is set. +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("extract: open entry %s: %w", f.Name, err) + } + defer rc.Close() + + mode := f.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return fmt.Errorf("extract: open target %s: %w", target, err) + } + defer out.Close() + if _, err := io.Copy(out, rc); err != nil { + return fmt.Errorf("extract: copy %s: %w", target, err) + } + return nil +} + +// detectSingleZipRoot returns the common root directory (with trailing +// slash) shared by every non-MACOSX entry, or "" when there is none. +// Hidden directories (".*") are NOT treated as wrappers because +// `.claude-plugin/` is a legitimate plugin component. +// +// `normaliseZipPath` strips trailing slashes, so a bare directory +// entry like `my-plugin/` arrives as `my-plugin` — +// indistinguishable from a top-level file. Skipping entries without an +// internal "/" lets us pick a real file path and infer the wrapper. +// Without this, `zip -r foo foo/` would short-circuit on the leading +// `foo/` directory entry and leave the manifest nested. +// (Mirrors server-side plugin_validator.detectSingleRoot — the two +// must agree.) +func detectSingleZipRoot(files []*zip.File) string { + var first string + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if !strings.Contains(name, "/") { + continue + } + first = name + break + } + if first == "" { + return "" + } + idx := strings.Index(first, "/") + if idx <= 0 { + return "" + } + root := first[:idx+1] + if strings.HasPrefix(root, ".") { + return "" + } + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + // Bare directory entries (e.g. `my-plugin`) arrive without a + // trailing slash. If the entry equals the root with the slash + // trimmed, it's the wrapping dir itself. + if name+"/" == root { + continue + } + if !strings.HasPrefix(name, root) { + return "" + } + } + return root +} + +// normaliseZipPath converts back-slashes to forward slashes (some +// Windows zip writers emit `\`) and strips trailing slashes that +// directory entries may carry. +func normaliseZipPath(name string) string { + p := strings.ReplaceAll(name, "\\", "/") + return strings.TrimSuffix(p, "/") +} + +// decodePluginDescriptors converts the raw agent_options["plugins"] +// value into a typed slice. Entries that fail to decode are dropped +// with a warning string returned alongside — the rest of the plugins +// might still be installable. +func decodePluginDescriptors(raw any) ([]pluginDescriptor, []string) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, []string{fmt.Sprintf("agent_options[plugins] must be array, got %T", raw)} + } + out := make([]pluginDescriptor, 0, len(items)) + warnings := make([]string, 0) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + warnings = append(warnings, fmt.Sprintf("plugins[%d]: not an object", i)) + continue + } + p := pluginDescriptor{ + Name: stringField(obj, "name"), + Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), + SHA256: stringField(obj, "sha256"), + } + if err := p.validate(); err != nil { + warnings = append(warnings, fmt.Sprintf("plugins[%d] (%s): %v", i, p.Name, err)) + continue + } + out = append(out, p) + } + return out, warnings +} + +func stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// validate is the daemon-side analogue of canonical.PluginSpec.Validate +// with a narrower contract — defense in depth, server-side validator +// is authoritative. +func (p pluginDescriptor) validate() error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("name is required") + } + // Block path-traversal-ish names before they hit filepath.Join. + if strings.ContainsAny(p.Name, "/\\") || p.Name == "." || p.Name == ".." { + return fmt.Errorf("name %q contains path separator or dot-ref", p.Name) + } + if strings.TrimSpace(p.DownloadURL) == "" { + return errors.New("download_url is required") + } + if len(p.SHA256) != 64 { + return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(p.SHA256)) + } + return nil +} + +// cacheKey is what we stamp into /.cache-key. Including the +// sha256 means a re-published version with the same name+version (but +// rebuilt zip content) invalidates the cache. +func (p pluginDescriptor) cacheKey() string { + return fmt.Sprintf("%s@%s", path.Clean(p.Name), strings.ToLower(p.SHA256)) +} + +// cloneAgentOptions returns a shallow copy of agent_options. Shallow +// is fine — we only overwrite the top-level "plugin_dirs" key. +func cloneAgentOptions(opts map[string]any) map[string]any { + if opts == nil { + return map[string]any{} + } + out := make(map[string]any, len(opts)) + for k, v := range opts { + out[k] = v + } + return out +} + +// mergePluginDirs combines a caller-supplied plugin_dirs override +// (accepted as []string OR []any) with the capability-resolved list, +// preserving order and deduplicating. Override wins on collision. +func mergePluginDirs(existing any, resolved []string) []string { + preset := coerceStringSlice(existing) + seen := make(map[string]bool, len(preset)+len(resolved)) + out := make([]string, 0, len(preset)+len(resolved)) + for _, d := range preset { + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + for _, d := range resolved { + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +// coerceStringSlice accepts the two wire shapes opts["plugin_dirs"] +// can take: a pre-typed []string or a JSON-decoded []any of strings. +// BuildArgs' stringSlice errors on bad shapes downstream, so a clean +// degradation here is fine. +func coerceStringSlice(v any) []string { + switch t := v.(type) { + case nil: + return nil + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins_install.go b/apps/parsar-daemon/internal/agent/claudecode/plugins_install.go new file mode 100644 index 000000000..4478e4379 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/plugins_install.go @@ -0,0 +1,153 @@ +package claudecode + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/google/uuid" +) + +// installPlugins materialises every plugin under +// /.claude/plugins// and returns the local paths. Per +// plugin: +// +// 1. Skip when /.cache-key matches name+sha256 (recurring prompts +// avoid the network round-trip). +// 2. Fetch the download URL into a temp file under .tmp/, capping at +// maxPluginZipBytes. +// 3. Verify SHA-256 against the descriptor before touching the +// extraction target — mismatch demotes to warning. +// 4. Extract to /.claude/plugins//, stripping a single +// wrapping directory and ignoring __MACOSX/. +// 5. Stamp .cache-key with "@". +// +// Errors during 2-4 demote the plugin to a warning and continue. +// Returning a hard error means we couldn't even create the parent +// directory. +func installPlugins( + ctx context.Context, + logger *slog.Logger, + workDir string, + plugins []pluginDescriptor, +) (PluginInstallResult, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(plugins) == 0 { + return PluginInstallResult{}, nil + } + if strings.TrimSpace(workDir) == "" { + return PluginInstallResult{}, errors.New("claudecode plugins: workDir is required") + } + + root := filepath.Join(workDir, ".claude", "plugins") + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return PluginInstallResult{}, err + } + defer unlock() + + result := PluginInstallResult{} + for _, p := range plugins { + if err := p.validate(); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skip plugin (invalid descriptor): %v", err)) + logger.Warn("claudecode plugins: invalid descriptor", "err", err.Error()) + continue + } + + dir := filepath.Join(root, p.Name) + cacheKey := filepath.Join(dir, ".cache-key") + expectedKey := p.cacheKey() + + if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { + logger.Info("claudecode plugins: cache hit", + "name", p.Name, "version", p.Version, "dir", dir) + result.PluginDirs = append(result.PluginDirs, dir) + continue + } + + perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) + err := installOnePlugin(perCtx, logger, root, dir, cacheKey, expectedKey, p) + cancel() + if err != nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("plugin %s@%s: %v", p.Name, p.Version, err)) + logger.Warn("claudecode plugins: install failed", + "name", p.Name, "version", p.Version, "err", err.Error()) + continue + } + result.PluginDirs = append(result.PluginDirs, dir) + logger.Info("claudecode plugins: installed", + "name", p.Name, "version", p.Version, "dir", dir) + } + return result, nil +} + +// installOnePlugin: download → verify → extract → stamp cache key. +// On error, best-effort cleanup of any partial extraction. +func installOnePlugin( + ctx context.Context, + logger *slog.Logger, + root, dir, cacheKey, expectedKey string, + p pluginDescriptor, +) error { + tmpDir := filepath.Join(root, ".tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + + // Per-call uuid in the temp path so two concurrent installs of the + // same (name, version) don't truncate each other's bytes, and so + // nothing on disk between verifyPluginSHA256 and extract can be a + // different file than the one we just hashed (TOCTOU). + zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", p.Name, p.Version, uuid.NewString())) + defer func() { + _ = os.Remove(zipPath) + }() + + fd, err := fetchPluginZip(ctx, p.DownloadURL, zipPath) + if err != nil { + return err + } + defer fd.Close() + + // Verify and extract BOTH read through the same FD (not the path). + // Unix file semantics pin the inode, so a swap on disk between + // hashing and extraction cannot change the bytes we're using. + if err := verifyPluginSHA256FromFD(fd, p.SHA256); err != nil { + return err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", err) + } + fi, err := fd.Stat() + if err != nil { + return fmt.Errorf("stat: %w", err) + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("rm old dir: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir target: %w", err) + } + if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { + _ = os.RemoveAll(dir) + return err + } + + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + // Cache miss next time is recoverable — don't fail the install. + logger.Warn("claudecode plugins: write cache key failed", + "path", cacheKey, "err", err.Error()) + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins_test.go b/apps/parsar-daemon/internal/agent/claudecode/plugins_test.go new file mode 100644 index 000000000..fe67268ab --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/plugins_test.go @@ -0,0 +1,708 @@ +package claudecode + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// pluginZipFile mirrors the server-side validator test helper. +type pluginZipFile struct { + Name string + Body string + Mode os.FileMode // 0 → default +} + +func buildPluginZipBytes(t *testing.T, files []pluginZipFile) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + hdr := &zip.FileHeader{Name: f.Name, Method: zip.Deflate} + if f.Mode != 0 { + hdr.SetMode(f.Mode) + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("zip header %q: %v", f.Name, err) + } + if _, err := w.Write([]byte(f.Body)); err != nil { + t.Fatalf("zip write %q: %v", f.Name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + return buf.Bytes() +} + +// validPluginZipBytes is the baseline fixture every install test uses +// unless it explicitly mutates the entry set. +func validPluginZipBytes(t *testing.T) []byte { + return buildPluginZipBytes(t, []pluginZipFile{ + {Name: ".claude-plugin/plugin.json", Body: `{"name":"my-plugin","version":"1.0.0"}`}, + {Name: "commands/hello.md", Body: "---\nname: hello\n---\nbody"}, + }) +} + +func sha256Hex(body []byte) string { + h := sha256.Sum256(body) + return hex.EncodeToString(h[:]) +} + +// pluginServer is a deterministic stand-in for the OSS presigned GET +// endpoint. It counts hits so cache-hit tests can verify the second +// install call did NOT round-trip. +type pluginServer struct { + *httptest.Server + hits *int + body []byte + stat int +} + +func startPluginServer(t *testing.T, body []byte) *pluginServer { + t.Helper() + var hits int + ps := &pluginServer{hits: &hits, body: body, stat: http.StatusOK} + ps.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(ps.stat) + _, _ = w.Write(ps.body) + })) + t.Cleanup(ps.Close) + return ps +} + +func (s *pluginServer) Hits() int { return *s.hits } + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestInstallPlugins_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { + t.Parallel() + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("installPlugins: %v", err) + } + if len(res.PluginDirs) != 1 { + t.Fatalf("PluginDirs = %v, want 1 entry", res.PluginDirs) + } + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } + + dir := res.PluginDirs[0] + if filepath.Base(dir) != "my-plugin" { + t.Fatalf("dir basename = %q, want my-plugin", filepath.Base(dir)) + } + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("manifest missing: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "commands", "hello.md")); err != nil { + t.Fatalf("commands entry missing: %v", err) + } + stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) + if err != nil { + t.Fatalf("read cache-key: %v", err) + } + want := "my-plugin@" + sha256Hex(body) + if string(stamped) != want { + t.Fatalf("cache-key = %q, want %q", stamped, want) + } +} + +func TestInstallPlugins_CacheHitSkipsDownload(t *testing.T) { + t.Parallel() + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + desc := []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + } + if _, err := installPlugins(context.Background(), discardLogger(), workDir, desc); err != nil { + t.Fatalf("first install: %v", err) + } + hitsAfterFirst := srv.Hits() + if hitsAfterFirst != 1 { + t.Fatalf("first install hits = %d, want 1", hitsAfterFirst) + } + + // Second install with the same descriptor — cache-key match + // short-circuits BEFORE any HTTP call. + if _, err := installPlugins(context.Background(), discardLogger(), workDir, desc); err != nil { + t.Fatalf("second install: %v", err) + } + if got := srv.Hits(); got != hitsAfterFirst { + t.Fatalf("second install made %d additional hits; cache should have prevented network", got-hitsAfterFirst) + } +} + +func TestInstallPlugins_CacheInvalidatedBySHA256Change(t *testing.T) { + t.Parallel() + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + logger := discardLogger() + + first := []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + } + if _, err := installPlugins(context.Background(), logger, workDir, first); err != nil { + t.Fatalf("first install: %v", err) + } + + // Swap the server payload + sha. Cache key contains the sha so it + // must be invalidated. + newBody := buildPluginZipBytes(t, []pluginZipFile{ + {Name: ".claude-plugin/plugin.json", Body: `{"name":"my-plugin","version":"1.0.0"}`}, + {Name: "commands/different.md", Body: "---\nname: different\n---\nbody"}, + }) + srv.body = newBody + + second := []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(newBody)}, + } + if _, err := installPlugins(context.Background(), logger, workDir, second); err != nil { + t.Fatalf("second install: %v", err) + } + dir := filepath.Join(workDir, ".claude", "plugins", "my-plugin") + if _, err := os.Stat(filepath.Join(dir, "commands", "different.md")); err != nil { + t.Fatalf("new content not extracted: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "commands", "hello.md")); err == nil { + t.Fatal("old content survived the re-install") + } +} + +func TestInstallPlugins_SHA256MismatchDemotesToWarning(t *testing.T) { + t.Parallel() + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + // Wrong sha → no install, no hard error; rest of the prompt + // continues without this plugin. + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: strings.Repeat("0", 64)}, + }) + if err != nil { + t.Fatalf("installPlugins: %v", err) + } + if len(res.PluginDirs) != 0 { + t.Fatalf("PluginDirs = %v, want empty after sha mismatch", res.PluginDirs) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning on sha mismatch") + } + if !strings.Contains(res.Warnings[0], "sha256 mismatch") { + t.Fatalf("warning text = %q, want sha256-mismatch hint", res.Warnings[0]) + } + // No .cache-key file must be stamped — would short-circuit future + // retries with the same bad sha. + if _, err := os.Stat(filepath.Join(workDir, ".claude", "plugins", "my-plugin", ".cache-key")); err == nil { + t.Fatal("cache-key stamped despite sha mismatch") + } +} + +func TestInstallPlugins_HTTPErrorDemotesToWarning(t *testing.T) { + t.Parallel() + workDir := t.TempDir() + srv := startPluginServer(t, nil) + srv.stat = http.StatusForbidden + + res, _ := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "p", Version: "1", DownloadURL: srv.URL, SHA256: strings.Repeat("a", 64)}, + }) + if len(res.PluginDirs) != 0 { + t.Fatal("expected no installed dirs on 403") + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning on 403") + } +} + +func TestInstallPlugins_StripWrappingRoot(t *testing.T) { + t.Parallel() + body := buildPluginZipBytes(t, []pluginZipFile{ + {Name: "wrapper-dir/.claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, + {Name: "wrapper-dir/commands/hi.md", Body: "---\nname: hi\n---"}, + }) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.PluginDirs) != 1 { + t.Fatalf("PluginDirs = %v", res.PluginDirs) + } + dir := res.PluginDirs[0] + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("manifest at expected path missing (wrapper not stripped?): %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "wrapper-dir")); err == nil { + t.Fatal("wrapper-dir survived the strip") + } +} + +// TestInstallPlugins_StripWrappingRootWithBareDirEntry locks in the +// production fix: real `zip -r my-plugin.zip my-plugin/` archives emit +// a bare directory entry first; naive root-inference sees no internal +// "/" and concludes "no wrapper to strip". The fix skips directory- +// only entries when picking the first candidate. +func TestInstallPlugins_StripWrappingRootWithBareDirEntry(t *testing.T) { + t.Parallel() + body := buildPluginZipBytes(t, []pluginZipFile{ + // Bare directory entry — the gotcha. + {Name: "wrapper-dir/", Body: ""}, + {Name: "wrapper-dir/.claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, + {Name: "wrapper-dir/commands/hi.md", Body: "---\nname: hi\n---"}, + }) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.PluginDirs) != 1 { + t.Fatalf("PluginDirs = %v", res.PluginDirs) + } + dir := res.PluginDirs[0] + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("manifest at expected path missing (bare dir entry confused wrapper detection?): %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "wrapper-dir")); err == nil { + t.Fatal("wrapper-dir survived the strip") + } +} + +func TestInstallPlugins_MacOSXMetadataIgnored(t *testing.T) { + t.Parallel() + body := buildPluginZipBytes(t, []pluginZipFile{ + {Name: "__MACOSX/._plugin.json", Body: "binary metadata"}, + {Name: ".claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, + }) + srv := startPluginServer(t, body) + workDir := t.TempDir() + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + dir := res.PluginDirs[0] + if _, err := os.Stat(filepath.Join(dir, "__MACOSX")); err == nil { + t.Fatal("__MACOSX dir was extracted despite filter") + } + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("manifest missing: %v", err) + } +} + +func TestInstallPlugins_PathTraversalRejected(t *testing.T) { + t.Parallel() + // "../../etc/passwd" entry must not write outside the target dir. + body := buildPluginZipBytes(t, []pluginZipFile{ + {Name: ".claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, + {Name: "../../escape", Body: "should never land outside dir"}, + }) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, _ := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if len(res.PluginDirs) != 0 { + t.Fatalf("PluginDirs = %v, want empty on path-traversal", res.PluginDirs) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning on path-traversal") + } + if !strings.Contains(res.Warnings[0], "escapes") { + t.Fatalf("warning text = %q, want escape hint", res.Warnings[0]) + } +} + +func TestInstallPlugins_SymlinkEntrySkipped(t *testing.T) { + t.Parallel() + // archive/zip's SetMode() drops file-type bits, so to test a real + // symlink entry we set ExternalAttrs by hand — upper 16 bits are + // the Unix mode (S_IFLNK | perm), which is how attacker-crafted + // zips actually mark symlinks. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + { + hdr := &zip.FileHeader{Name: ".claude-plugin/plugin.json", Method: zip.Deflate} + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("create manifest: %v", err) + } + if _, err := w.Write([]byte(`{"name":"x","version":"1"}`)); err != nil { + t.Fatalf("write manifest: %v", err) + } + } + { + hdr := &zip.FileHeader{Name: "commands/evil.md", Method: zip.Deflate} + // 0xA1ED = S_IFLNK (0xA000) | 0755. External attrs are + // (unix_mode << 16) per the zip spec. + hdr.ExternalAttrs = 0xA1ED << 16 + hdr.CreatorVersion = 3 << 8 // UNIX host system + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("create symlink: %v", err) + } + if _, err := w.Write([]byte("/etc/passwd")); err != nil { + t.Fatalf("write symlink target: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + body := buf.Bytes() + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.PluginDirs) != 1 { + t.Fatalf("PluginDirs = %v, want 1", res.PluginDirs) + } + dir := res.PluginDirs[0] + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("manifest missing: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "commands", "evil.md")); err == nil { + t.Fatal("symlink entry was extracted as a regular file; symlinks must be skipped") + } +} + +func TestInstallPlugins_ConcurrentSamePluginNoTruncation(t *testing.T) { + t.Parallel() + // Per-call uuid in the temp filename makes each install's temp + // file independent; concurrent installs of the same (name, + // version) must both succeed. + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + done := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + done <- err + return + } + if len(res.PluginDirs) != 1 { + done <- fmt.Errorf("PluginDirs = %v", res.PluginDirs) + return + } + done <- nil + }() + } + for i := 0; i < 2; i++ { + if err := <-done; err != nil { + t.Fatalf("concurrent install failed: %v", err) + } + } +} + +func TestInstallPlugins_PartialInstall(t *testing.T) { + t.Parallel() + // Good descriptor installs; bad-sha descriptor demotes to warning. + bodyOK := buildPluginZipBytes(t, []pluginZipFile{ + {Name: ".claude-plugin/plugin.json", Body: `{"name":"good","version":"1"}`}, + }) + srvOK := startPluginServer(t, bodyOK) + srvBAD := startPluginServer(t, bodyOK) + workDir := t.TempDir() + + res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + {Name: "good", Version: "1", DownloadURL: srvOK.URL, SHA256: sha256Hex(bodyOK)}, + {Name: "bad", Version: "1", DownloadURL: srvBAD.URL, SHA256: strings.Repeat("0", 64)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.PluginDirs) != 1 { + t.Fatalf("PluginDirs = %v, want 1 (only the good one)", res.PluginDirs) + } + if !strings.HasSuffix(res.PluginDirs[0], "/good") { + t.Fatalf("PluginDirs[0] = %q, want trailing /good", res.PluginDirs[0]) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning for the bad plugin") + } +} + +func TestInstallPlugins_EmptyListIsNoop(t *testing.T) { + t.Parallel() + res, err := installPlugins(context.Background(), discardLogger(), t.TempDir(), nil) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.PluginDirs) != 0 || len(res.Warnings) != 0 { + t.Fatalf("expected empty result; got %+v", res) + } +} + +func TestInstallPlugins_DescriptorValidatorRejectsBadNames(t *testing.T) { + t.Parallel() + body := validPluginZipBytes(t) + srv := startPluginServer(t, body) + res, _ := installPlugins(context.Background(), discardLogger(), t.TempDir(), []pluginDescriptor{ + {Name: "../escape", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if len(res.PluginDirs) != 0 { + t.Fatalf("PluginDirs = %v; bad name should be rejected", res.PluginDirs) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning") + } +} + +func TestDecodePluginDescriptors_ArrayShape(t *testing.T) { + t.Parallel() + raw := []any{ + map[string]any{"name": "a", "version": "1", "download_url": "https://x/a.zip", "sha256": strings.Repeat("a", 64)}, + map[string]any{"name": "", "version": "1", "download_url": "https://x/b.zip", "sha256": strings.Repeat("b", 64)}, + "not an object", + } + got, warns := decodePluginDescriptors(raw) + if len(got) != 1 || got[0].Name != "a" { + t.Fatalf("got = %v, want 1 valid entry", got) + } + if len(warns) != 2 { + t.Fatalf("warns = %v, want 2", warns) + } +} + +func TestDecodePluginDescriptors_NilAndWrongType(t *testing.T) { + t.Parallel() + got, warns := decodePluginDescriptors(nil) + if got != nil || warns != nil { + t.Fatalf("nil input should produce nil output; got=%v warns=%v", got, warns) + } + _, warns = decodePluginDescriptors("not an array") + if len(warns) != 1 { + t.Fatalf("expected 1 warning on wrong type, got %v", warns) + } +} + +func TestMergePluginDirs_OverrideWinsAndDedupes(t *testing.T) { + t.Parallel() + got := mergePluginDirs([]any{"/a", "/b"}, []string{"/b", "/c"}) + want := []string{"/a", "/b", "/c"} + if !equalStrings(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestMergePluginDirs_AcceptsTypedStringSlice(t *testing.T) { + t.Parallel() + got := mergePluginDirs([]string{"/x"}, []string{"/y"}) + want := []string{"/x", "/y"} + if !equalStrings(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestMergePluginDirs_NilExisting(t *testing.T) { + t.Parallel() + got := mergePluginDirs(nil, []string{"/x"}) + if !equalStrings(got, []string{"/x"}) { + t.Fatalf("got %v", got) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestResolveSessionWorkDir_RespectsExplicitDir locks in "caller wins": +// when req.WorkDir is set we must use exactly that path (mkdir -p if it +// doesn't exist yet) and never fall back to the conversation scratch +// dir. +func TestResolveSessionWorkDir_RespectsExplicitDir(t *testing.T) { + t.Parallel() + explicit := filepath.Join(t.TempDir(), "some-explicit-dir") + got, err := resolveSessionWorkDir(explicit, "conv-ignored") + if err != nil { + t.Fatalf("resolveSessionWorkDir: %v", err) + } + if got != explicit { + t.Fatalf("got %q, want explicit dir verbatim", got) + } + info, err := os.Stat(got) + if err != nil { + t.Fatalf("stat explicit dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("explicit dir %q is not a directory", got) + } +} + +func TestResolveSessionWorkDir_ExpandsHomeRelativeDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := resolveSessionWorkDir("~/projects/demo", "conv-ignored") + if err != nil { + t.Fatalf("resolveSessionWorkDir: %v", err) + } + want := filepath.Join(home, "projects", "demo") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + info, err := os.Stat(got) + if err != nil { + t.Fatalf("stat home-relative dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("home-relative dir %q is not a directory", got) + } +} + +// TestResolveSessionWorkDir_RejectsRelativeDir: relative paths are +// ambiguous (resolved against daemon cwd, which is not a stable anchor +// for user-facing config). The user gets a clear error instead of a +// chdir failure later. +func TestResolveSessionWorkDir_RejectsRelativeDir(t *testing.T) { + t.Parallel() + for _, rel := range []string{"foo", "./bar", "../baz", "a/b/c"} { + if _, err := resolveSessionWorkDir(rel, "conv-x"); err == nil { + t.Fatalf("relative path %q: expected error, got nil", rel) + } + } +} + +// TestResolveSessionWorkDir_ExplicitDirCreated: an absolute path whose +// parents don't exist yet still works — daemon mkdir -p's it. This is +// the "user named a fresh project root" case. +func TestResolveSessionWorkDir_ExplicitDirCreated(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + got, err := resolveSessionWorkDir(target, "conv-ignored") + if err != nil { + t.Fatalf("resolveSessionWorkDir: %v", err) + } + if got != target { + t.Fatalf("got %q, want %q", got, target) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +// TestResolveSessionWorkDir_FallbackCreatesDir: empty req.WorkDir with +// conversation_id must yield a real on-disk per-conversation directory +// under daemon HOME used for BOTH plugin install AND claude cwd. +// Overrides HOME to keep test inside t.TempDir(). +func TestResolveSessionWorkDir_FallbackCreatesDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + got, err := resolveSessionWorkDir("", "conv-abc-123") + if err != nil { + t.Fatalf("resolveSessionWorkDir: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "claudecode", "conv-conv-abc-123") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + info, err := os.Stat(got) + if err != nil { + t.Fatalf("stat fallback dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("fallback %q is not a directory", got) + } +} + +// TestResolveSessionWorkDir_BothEmptyFallsBackToCwd: when neither +// req.WorkDir nor conversation_id is provided, degrade to daemon cwd +// rather than refuse. +func TestResolveSessionWorkDir_BothEmptyFallsBackToCwd(t *testing.T) { + t.Parallel() + got, err := resolveSessionWorkDir("", "") + if err != nil { + t.Fatalf("resolveSessionWorkDir: %v", err) + } + wantCwd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd: %v", err) + } + if got != wantCwd { + t.Fatalf("got %q, want daemon cwd %q", got, wantCwd) + } + // Whitespace-only inputs must be treated as empty. + got, err = resolveSessionWorkDir(" ", " ") + if err != nil { + t.Fatalf("whitespace inputs: %v", err) + } + if got != wantCwd { + t.Fatalf("whitespace inputs: got %q, want %q", got, wantCwd) + } +} + +// TestResolveSessionWorkDir_FallbackIsIdempotent: a second call with +// the same conversation_id must succeed (MkdirAll on existing dir). +func TestResolveSessionWorkDir_FallbackIsIdempotent(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + first, err := resolveSessionWorkDir("", "conv-x") + if err != nil { + t.Fatalf("first call: %v", err) + } + second, err := resolveSessionWorkDir("", "conv-x") + if err != nil { + t.Fatalf("second call: %v", err) + } + if first != second { + t.Fatalf("non-deterministic fallback: %q vs %q", first, second) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session.go b/apps/parsar-daemon/internal/agent/claudecode/session.go new file mode 100644 index 000000000..4591e224e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/session.go @@ -0,0 +1,716 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// sessionConfig customises Factory for tests (alternative binary path, +// alternative logger, shorter SIGTERM→SIGKILL escalation). +type sessionConfig struct { + // claudeBinary defaults to binpath.ClaudeCode(): the bare name + // "claude" for a PATH lookup, or the PARSAR_CLAUDE_BIN override. + claudeBinary string + + // extraArgs are appended after BuildArgs' output. Tests use this + // for the os/exec helper-process pattern. + extraArgs []string + + // killTimeout is how long Cancel waits for SIGTERM to drain + // before SIGKILL. 3s in production; tests pin it short. + killTimeout time.Duration + + // askTimeout bounds how long the daemon waits for the human to answer a + // permission or prompt_for_user_choice (AskUserQuestion). 10 minutes in + // production; tests pin it short so timeout paths are exercisable. + // Zero disables the timer — leftover scaffolding for very early + // adapter wiring; production always picks defaultAskTimeout. + askTimeout time.Duration + + logger *slog.Logger +} + +const defaultAskTimeout = 10 * time.Minute + +func defaultConfig() sessionConfig { + return sessionConfig{ + claudeBinary: binpath.ClaudeCode(), + killTimeout: 3 * time.Second, + askTimeout: defaultAskTimeout, + logger: obslog.Bg(), + } +} + +// Factory implements agent.Factory for agent_kind="claude_code". +// Register during daemon startup: +// +// registry.Register("claude_code", claudecode.Factory) +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `claude` CLI subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + stdin io.WriteCloser + stdinMu sync.Mutex + + pending *pendingTable + askPending *pendingAskTable + translator *translator + + out chan<- proto.Envelope + closeOutOnce sync.Once + outMu sync.RWMutex + outClosed bool + + // cancelCtx is a child of parent ctx so Session.Cancel can signal + // everyone without racing router shutdown. + cancelCtx context.Context + + cancelOnce sync.Once + + // interactionTimersMu guards permission and AskUserQuestion watchdogs. + // Timeout callbacks and human responses race through atomic pending + // tables, so only one response can reach Claude Code. + interactionTimersMu sync.Mutex + interactionTimers map[string]*time.Timer + + // latestSessionID is the most recent upstream session id seen on a + // system-init / result frame. The cancel-path done envelope reads + // it back so the server can RememberSession even when claude was + // killed mid-prompt (without it, the next user message starts a + // brand-new chat with no --resume). + latestSessionIDMu sync.Mutex + latestSessionID string + + buildCleanup func() +} + +var _ agent.Session = (*Session)(nil) + +// newSession is the internal constructor; cfg lets tests inject a fake +// claude binary and a short kill timeout. +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("claudecode: nil out channel") + } + if req.Prompt == "" && len(req.Attachments) == 0 { + // A pure-image inbound (Feishu user pastes a screenshot + // without typing) is a valid prompt — Attachments alone + // drives the turn — and must not 400 here. + return nil, errors.New("claudecode: empty prompt and no attachments") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.claudeBinary == "" { + cfg.claudeBinary = binpath.ClaudeCode() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + cfg.logger.Info("claudecode: newSession start", + "run_id", req.RunID, "agent_kind", req.AgentKind, + "prompt_len", len(req.Prompt), "work_dir", req.WorkDir, + "has_agent_options", req.AgentOptions != nil, + "agent_session_id", req.AgentSessionID, + "claude_binary", cfg.claudeBinary) + + // Install plugins BEFORE BuildArgs so the resolved local paths + // can be folded into opts["plugin_dirs"]. installPlugins demotes + // individual plugins to warnings; a hard error (e.g. mkdir fail) + // aborts the session. + // + // sessionWorkDir is reused for cmd.Dir below so plugins land at + // /.claude/plugins/ and the claude subprocess sees + // them at cwd-relative paths. + sessionWorkDir, err := resolveSessionWorkDir(req.WorkDir, req.ConversationID) + if err != nil { + cfg.logger.Error("claudecode: resolveSessionWorkDir failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: resolve session workDir: %w", err) + } + if sessionWorkDir != req.WorkDir { + cfg.logger.Info("claudecode: req.WorkDir empty, using resolved session dir", + "run_id", req.RunID, "session_dir", sessionWorkDir) + } + + pluginOpts := req.AgentOptions + if rawPlugins, ok := pluginOpts["plugins"]; ok { + descriptors, decodeWarns := decodePluginDescriptors(rawPlugins) + for _, w := range decodeWarns { + cfg.logger.Warn("claudecode: plugin descriptor decode warning", + "run_id", req.RunID, "msg", w) + } + installRes, err := installPlugins(parent, cfg.logger, sessionWorkDir, descriptors) + if err != nil { + cfg.logger.Error("claudecode: installPlugins failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: install plugins: %w", err) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("claudecode: plugin install warning", + "run_id", req.RunID, "msg", w) + } + if len(installRes.PluginDirs) > 0 { + // Defensive copy so we never mutate the caller's map. + // Existing plugin_dirs (hand-configured override) wins; + // capability-resolved dirs append. + pluginOpts = cloneAgentOptions(req.AgentOptions) + pluginOpts["plugin_dirs"] = mergePluginDirs(pluginOpts["plugin_dirs"], installRes.PluginDirs) + } + cfg.logger.Info("claudecode: plugins installed", + "run_id", req.RunID, + "plugin_count", len(descriptors), + "dir_count", len(installRes.PluginDirs)) + } + + // Skills install to /.claude/skills//, which + // Claude Code auto-scans at startup. No CLI flag, no opts mutation. + if rawSkills, ok := pluginOpts["skills"]; ok { + descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) + for _, w := range decodeWarns { + cfg.logger.Warn("claudecode: skill descriptor decode warning", + "run_id", req.RunID, "msg", w) + } + installRes, err := installSkills(parent, cfg.logger, sessionWorkDir, descriptors) + if err != nil { + cfg.logger.Error("claudecode: installSkills failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: install skills: %w", err) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("claudecode: skill install warning", + "run_id", req.RunID, "msg", w) + } + cfg.logger.Info("claudecode: skills installed", + "run_id", req.RunID, + "skill_count", len(descriptors), + "warn_count", len(installRes.Warnings)) + } + + buildRes, err := BuildArgs(pluginOpts, req.AgentSessionID) + if err != nil { + cfg.logger.Error("claudecode: BuildArgs failed", "run_id", req.RunID, "err", err) + return nil, fmt.Errorf("claudecode: build args: %w", err) + } + cfg.logger.Info("claudecode: BuildArgs ok", + "run_id", req.RunID, "arg_count", len(buildRes.Args), "env_count", len(buildRes.Env)) + + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + + cfg.logger.Info("claudecode: starting subprocess", + "run_id", req.RunID, "binary", cfg.claudeBinary, + "arg_count", len(args), "dir", sessionWorkDir) + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.claudeBinary, + Args: args, + Dir: sessionWorkDir, + Env: append(os.Environ(), buildRes.Env...), + NeedStdin: true, + KillTimeout: cfg.killTimeout, + }) + if err != nil { + cfg.logger.Error("claudecode: cmd.Start failed", + "run_id", req.RunID, "binary", cfg.claudeBinary, "err", err) + buildRes.Cleanup() + return nil, fmt.Errorf("claudecode: start %q: %w", cfg.claudeBinary, err) + } + cfg.logger.Info("claudecode: subprocess started", + "run_id", req.RunID, "pid", proc.Cmd.Process.Pid) + + pending := newPendingTable() + askPending := newPendingAskTable() + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + stdin: proc.Stdin, + pending: pending, + askPending: askPending, + translator: newTranslator(req.RunID, pending, askPending, defaultPermIDMinter, defaultAskIDMinter), + out: out, + cancelCtx: proc.Context(), + interactionTimers: make(map[string]*time.Timer), + buildCleanup: buildRes.Cleanup, + } + + // Write the initial user message before launching pumps so the + // first stdout line corresponds to the prompt we just sent. Best + // effort: write failure → pump sees EOF and synthesises + // error+done. + if msg, err := buildUserMessageWithAttachments(req.Prompt, req.Attachments); err == nil { + cfg.logger.Info("claudecode: writing initial user message to stdin", + "run_id", req.RunID, "msg_bytes", len(msg), "attachments", len(req.Attachments)) + if _, werr := s.writeStdin(msg); werr != nil { + cfg.logger.Warn("claudecode: write initial user message", + "run_id", req.RunID, "err", werr) + } else { + cfg.logger.Info("claudecode: initial user message written ok", "run_id", req.RunID) + } + } else { + cfg.logger.Warn("claudecode: build user message", + "run_id", req.RunID, "err", err) + } + + cfg.logger.Info("claudecode: launching stdout/stderr pumps", "run_id", req.RunID) + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + + return s, nil +} + +// writeStdin appends to claude's stdin under a mutex (claude only +// promises NDJSON framing; concurrent writes from SubmitPermission and +// the initial user-message write must not tear). +func (s *Session) writeStdin(b []byte) (int, error) { + s.stdinMu.Lock() + defer s.stdinMu.Unlock() + return s.stdin.Write(b) +} + +// Cancel asks the subprocess to stop. SIGTERM, escalating to SIGKILL +// after cfg.killTimeout. Idempotent; the actual teardown (out chan +// close) happens asynchronously via the pump. +func (s *Session) Cancel(_ context.Context) error { + s.cancelOnce.Do(func() { + s.stopAllInteractionTimers() + s.proc.Cancel() + }) + return nil +} + +// stopAllInteractionTimers cancels every outstanding human-response +// watchdog. Called on session Cancel/terminal so timers cannot fire into a +// closed stdin. +func (s *Session) stopAllInteractionTimers() { + s.interactionTimersMu.Lock() + timers := s.interactionTimers + s.interactionTimers = make(map[string]*time.Timer) + s.interactionTimersMu.Unlock() + for _, t := range timers { + t.Stop() + } +} + +// SubmitPermission writes a control_response back to claude for the +// given perm_id. Returns agent.ErrUnknownPermission when permID isn't +// in the pending table. +func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { + entry, ok := s.pending.Take(permID) + if !ok { + return agent.ErrUnknownPermission + } + s.stopInteractionTimer(permID) + + var inner map[string]any + if decision.Approved { + updatedInput := decision.UpdatedInput + if updatedInput == nil { + updatedInput = entry.Input + } + inner = map[string]any{ + "behavior": "allow", + "updatedInput": updatedInput, + } + } else { + msg := decision.Message + if msg == "" { + msg = "denied by operator" + } + inner = map[string]any{ + "behavior": "deny", + "message": msg, + } + } + + body, err := json.Marshal(map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": entry.CCRequestID, + "response": inner, + }, + }) + if err != nil { + return fmt.Errorf("claudecode: marshal control_response: %w", err) + } + body = append(body, '\n') + + if _, err := s.writeStdin(body); err != nil { + // Restore the entry so a transient stdin write failure remains + // retryable and still has a bounded lifetime. + s.pending.Record(permID, entry.CCRequestID, entry.Input) + s.startPermissionTimer(permID) + return fmt.Errorf("claudecode: write control_response: %w", err) + } + return nil +} + +// SubmitPromptForUserChoice writes a tool_result back into claude's +// stdin for the AskUserQuestion call the daemon intercepted. Returns +// agent.ErrUnknownAsk when askID isn't in the pending ask table — +// usually a race with Cancel or a duplicate decision from the server. +// +// The reply is shaped as a normal Claude Code tool_result message; the +// model resumes its turn as if the local SDK had executed the tool +// and supplied the human's answer. +// +// Cancelled answers (timeout, operator /cancel) still write back +// is_error=false text — see plan: returning is_error=true would push +// the agent into a "retry the same tool" loop, which is worse UX than +// telling it "the user stopped, fold and report". +func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + // Take is atomic read+delete; the timer-fired cancel and a server- + // delivered answer can both reach here, but only one wins. The + // loser sees ok=false and returns ErrUnknownAsk — the router logs + // and moves on. + entry, ok := s.askPending.Take(askID) + if !ok { + return agent.ErrUnknownAsk + } + + // Drop the timer so its callback (if pending) finds the entry gone + // and returns silently. Already-fired callbacks lost the Take race + // above; stop is best-effort either way. + s.stopAskTimer(askID) + + // Pick the reply shape based on which path recorded the entry. + // control_request path (CCRequestID set) needs a control_response + // frame; tool_use path needs a user message with a tool_result block. + var ( + body []byte + buildEr error + ) + if entry.CCRequestID != "" { + body, buildEr = buildAskUserControlResponse(entry, decision) + } else { + body, buildEr = buildAskUserToolResult(entry, decision) + } + if buildEr != nil { + return fmt.Errorf("claudecode: marshal ask reply: %w", buildEr) + } + if _, err := s.writeStdin(body); err != nil { + // Entry is already gone (Take consumed it). A retry will see + // ErrUnknownAsk; the session is going to die anyway when stdin + // errors, so we don't try to restore the entry. + return fmt.Errorf("claudecode: write ask reply: %w", err) + } + return nil +} + +// startAskTimer launches a single-shot watchdog that times the human +// out after cfg.askTimeout. On fire, the watchdog submits a Cancelled +// decision against itself so the agent's tool_result lands the same +// way as if the operator had clicked "stop" — no special "timeout" +// branch in SubmitPromptForUserChoice. +func (s *Session) startAskTimer(askID string) { + if s.cfg.askTimeout <= 0 { + return + } + timer := time.AfterFunc(s.cfg.askTimeout, func() { + _ = s.SubmitPromptForUserChoice(context.Background(), askID, proto.PromptForUserChoiceDecisionPayload{ + Cancelled: true, + Reason: "timeout", + }) + }) + s.interactionTimersMu.Lock() + if prev, ok := s.interactionTimers[askID]; ok { + // Same askID seen twice: cancel the old timer so we don't fire + // two decisions. Shouldn't happen on a clean stream, but two + // stdout-pump dispatches with the same env.ID would otherwise + // race. + prev.Stop() + } + s.interactionTimers[askID] = timer + s.interactionTimersMu.Unlock() +} + +func (s *Session) stopAskTimer(askID string) { + s.stopInteractionTimer(askID) +} + +// startPermissionTimer applies the same bounded human-response window to +// tool approvals. Expiry is an explicit deny, never an implicit allow. +func (s *Session) startPermissionTimer(permID string) { + if s.cfg.askTimeout <= 0 || permID == "" { + return + } + timer := time.AfterFunc(s.cfg.askTimeout, func() { + _ = s.SubmitPermission(context.Background(), permID, proto.PermissionDecisionPayload{ + Approved: false, + Message: "permission request timed out", + }) + }) + s.interactionTimersMu.Lock() + if prev, ok := s.interactionTimers[permID]; ok { + prev.Stop() + } + s.interactionTimers[permID] = timer + s.interactionTimersMu.Unlock() +} + +func (s *Session) stopInteractionTimer(id string) { + s.interactionTimersMu.Lock() + timer, ok := s.interactionTimers[id] + if ok { + delete(s.interactionTimers, id) + } + s.interactionTimersMu.Unlock() + if ok { + timer.Stop() + } +} + +// run is the stdout pump. Owns the out channel close. +func (s *Session) run(stdout io.Reader) { + s.cfg.logger.Info("claudecode: run() stdout pump started", "run_id", s.runID) + defer s.buildCleanup() + defer s.closeOut() + + sc := bufio.NewScanner(stdout) + // Claude can emit very large tool_result lines in one frame; 16MB + // is far above any practical single-tool output. + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + lineCount := 0 + terminal := false + for sc.Scan() { + line := sc.Bytes() + lineCount++ + s.cfg.logger.Info("claudecode: stdout line", + "run_id", s.runID, "line_num", lineCount, "len", len(line), + "head", string(line[:min(len(line), 200)])) + tx, err := s.translator.Translate(line) + if err != nil { + s.cfg.logger.Warn("claudecode: translate line", + "run_id", s.runID, "err", err, "len", len(line)) + continue + } + if id := strings.TrimSpace(tx.SessionID); id != "" { + s.latestSessionIDMu.Lock() + s.latestSessionID = id + s.latestSessionIDMu.Unlock() + } + s.cfg.logger.Info("claudecode: translated", + "run_id", s.runID, "line_num", lineCount, + "envelope_count", len(tx.Envelopes), "terminal", tx.Terminal) + for _, env := range tx.Envelopes { + select { + case s.out <- env: + s.cfg.logger.Info("claudecode: envelope sent to out", + "run_id", s.runID, "type", env.Type, "env_id", env.ID) + if env.Type == proto.TypePromptForUserChoice { + // Start the human-answer watchdog AFTER the envelope + // has been handed off — counting the 10-minute window + // from "router has it" not "we're about to try". A + // slow consumer that blocked us on the send shouldn't + // also burn timeout budget the human never saw. + // + // Late-answer race: if the router somehow delivers a + // decision before we finish startAskTimer, the Take + // inside SubmitPromptForUserChoice still wins + // exclusively; the timer just becomes a no-op when it + // fires. + // + // Ask id lives on the payload now (env.ID is the run + // id so server-side dispatch can fan to the run's + // subscriber); decode just enough to seed the timer. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err == nil && p.AskID != "" { + s.startAskTimer(p.AskID) + } + } else if env.Type == proto.TypePermissionRequest { + var p proto.PermissionRequestPayload + requestID := "" + if err := env.DecodePayload(&p); err == nil { + requestID = strings.TrimSpace(p.RequestID) + } + if requestID == "" { + requestID = strings.TrimSpace(env.ID) + } + if requestID != "" { + s.startPermissionTimer(requestID) + } + } + case <-s.cancelCtx.Done(): + s.cfg.logger.Info("claudecode: cancelled during out send", "run_id", s.runID) + _ = s.proc.Wait() + return + } + } + if tx.Terminal { + terminal = true + s.stopAllInteractionTimers() + s.closeOut() + break + } + } + if err := sc.Err(); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) { + s.cfg.logger.Warn("claudecode: scan stdout", + "run_id", s.runID, "err", err) + } + s.cfg.logger.Info("claudecode: stdout pump exiting", + "run_id", s.runID, "lines_read", lineCount, "terminal", terminal) + + waitErr := s.proc.Wait() + s.cfg.logger.Info("claudecode: subprocess exited", + "run_id", s.runID, "wait_err", waitErr, + "exit_code", s.proc.Cmd.ProcessState.ExitCode()) + + if !terminal { + s.synthesizeTerminal(waitErr) + } +} + +// pumpStderr drains and logs stderr so the subprocess doesn't block +// on a full pipe. +func (s *Session) pumpStderr(stderr io.Reader) { + s.cfg.logger.Info("claudecode: pumpStderr started", "run_id", s.runID) + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + lineCount := 0 + for sc.Scan() { + lineCount++ + s.cfg.logger.Warn("claude stderr", + "run_id", s.runID, "line", sc.Text()) + } + s.cfg.logger.Info("claudecode: pumpStderr done", "run_id", s.runID, "lines", lineCount) +} + +// synthesizeTerminal emits error+done when the subprocess exited +// without a result frame. +func (s *Session) synthesizeTerminal(waitErr error) { + msg := "claude_code: subprocess exited without result" + if waitErr != nil { + msg = fmt.Sprintf("claude_code: subprocess exited: %v", waitErr) + } + if s.cancelCtx.Err() != nil { + msg = "claude_code: cancelled" + } + if errEnv, err := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: msg}); err == nil { + s.trySend(errEnv) + } + if doneEnv, err := proto.NewEnvelope(proto.TypeDone, s.runID, proto.DonePayload{Metadata: s.doneMetaForCancel()}); err == nil { + s.trySend(doneEnv) + } +} + +// doneMetaForCancel returns the metadata map attached to the cancel-path Done envelope. +func (s *Session) doneMetaForCancel() map[string]any { + s.latestSessionIDMu.Lock() + id := s.latestSessionID + s.latestSessionIDMu.Unlock() + if id == "" { + return nil + } + return map[string]any{ + proto.DoneMetaAgentSessionID: id, + proto.DoneMetaAgentSessionType: "claude_session", + } +} + +func (s *Session) trySend(env proto.Envelope) { + s.outMu.RLock() + defer s.outMu.RUnlock() + if s.outClosed { + return + } + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("claudecode: terminal send timed out", + "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { + s.closeOutOnce.Do(func() { + s.outMu.Lock() + s.outClosed = true + close(s.out) + s.outMu.Unlock() + }) +} + +// resolveSessionWorkDir returns the directory that BOTH plugin installs +// AND the claude_code subprocess cwd share for this run. Keeping them +// on the same tree prevents the bug where the subprocess ran in one +// place (sandbox image WORKDIR) while plugins sat under ~/.parsar/ +// — `--plugin-dir` still worked but the agent's own `ls .claude/ +// plugins/` self-check answered "no plugins here". +// +// Resolution order: +// +// 1. req.WorkDir wins. Local mode where the operator pinned a project +// root. Must be absolute or start with ~/ (we reject relative paths +// instead of resolving them against daemon cwd, since the daemon's cwd is +// not a meaningful anchor for user-facing config) and we mkdir -p +// so the user can name a path that doesn't exist yet. +// 2. conversationID present → per-conversation scratch dir under +// daemon HOME (~/.parsar/runtime/claudecode/conv-). +// Consecutive turns reuse the same .cache-key files. Sandbox-mode +// default, also the local fallback when work_dir is unbound. +// 3. Both empty → daemon's own cwd (os.Getwd). Backstop matching +// pre-plugin behavior. +// +// Errors propagate so the eventual "could not extract zip" gets a +// clearer message. +func resolveSessionWorkDir(workDir, conversationID string) (string, error) { + if trimmed := strings.TrimSpace(workDir); trimmed != "" { + if strings.HasPrefix(trimmed, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + trimmed = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + } else if !filepath.IsAbs(trimmed) { + return "", fmt.Errorf("work_dir must be an absolute path, got %q", trimmed) + } + if err := os.MkdirAll(trimmed, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", trimmed, err) + } + return trimmed, nil + } + if convID := strings.TrimSpace(conversationID); convID != "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("os.UserHomeDir: %w", err) + } + dir := filepath.Join(home, ".parsar", "runtime", "claudecode", "conv-"+convID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", dir, err) + } + return dir, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("os.Getwd: %w", err) + } + return cwd, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_export_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_export_test.go new file mode 100644 index 000000000..0021b8845 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/session_export_test.go @@ -0,0 +1,38 @@ +package claudecode + +// Parallel to export_test.go — exposes the subprocess session +// constructor + config knobs for session_test.go. + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + ClaudeBinary string + ExtraArgs []string + KillTimeout time.Duration + AskTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + claudeBinary: cfg.ClaudeBinary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + askTimeout: cfg.AskTimeout, + }) +} + +// SubmitPromptForUserChoiceForTest exposes the ask-decision writer so +// session_test can drive the answer-resume path without a separate +// dispatch hop. +func (s *Session) SubmitPromptForUserChoiceForTest(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + return s.SubmitPromptForUserChoice(context.Background(), askID, decision) +} + +func (s *Session) ProcessDoneForTest() <-chan struct{} { + return s.proc.Done() +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go new file mode 100644 index 000000000..25e7f7e1d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go @@ -0,0 +1,30 @@ +package claudecode + +import ( + "bytes" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestStartupLogsDoNotContainReferenceDocuments(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var output bytes.Buffer + const privateDocument = "PRIVATE-REFERENCE-9481" + _, err := newSession(t.Context(), proto.PromptRequestPayload{ + RunID: "knowledge-log-check", WorkDir: t.TempDir(), Prompt: "Answer from the reference.", + AgentOptions: map[string]any{"system_prompt": privateDocument}, + }, make(chan proto.Envelope, 8), sessionConfig{ + claudeBinary: filepath.Join(t.TempDir(), "missing-claude"), + logger: slog.New(slog.NewTextHandler(&output, nil)), + }) + if err == nil || !strings.Contains(output.String(), "starting subprocess") { + t.Fatalf("did not exercise subprocess startup: %v", err) + } + if strings.Contains(output.String(), privateDocument) { + t.Fatal("reference documents leaked into startup logs") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_test.go new file mode 100644 index 000000000..4326cf14f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/session_test.go @@ -0,0 +1,617 @@ +package claudecode_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `claude` when +// CLAUDECODE_TESTHELPER_ROLE is set, bypassing m.Run so the test +// framework's PASS line never pollutes the fake stdout. +const helperEnvKey = "CLAUDECODE_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(helperEnvKey); role != "" { + runFakeClaude(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +// runFakeClaude pretends to be the `claude` CLI in stream-json mode. +func runFakeClaude(role string) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + stdin := bufio.NewScanner(os.Stdin) + stdin.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + // Wait for the daemon's initial user message so stream-json frame + // ordering is deterministic. + _ = stdin.Scan() + + switch role { + case "echo-success": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_echo", + }) + _ = enc.Encode(map[string]any{ + "type": "assistant", + "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{ + {"type": "text", "text": "hi there"}, + }, + }, + }) + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "hi there", + "session_id": "sess_echo", + "usage": map[string]int{"input_tokens": 5, "output_tokens": 2}, + }) + + case "terminal-wait": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_terminal_wait", + }) + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "background work started", + "session_id": "sess_terminal_wait", + }) + for stdin.Scan() { + } + + case "echo-error": + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "error_during_execution", + "is_error": true, "error": "boom from fake", + }) + + case "permission": + _ = enc.Encode(map[string]any{ + "type": "control_request", "request_id": "req_cc_42", + "request": map[string]any{ + "subtype": "can_use_tool", "tool_name": "Bash", + "input": map[string]any{"command": "ls"}, + }, + }) + // Wait for the daemon's control_response. + approved := false + ccID := "" + if stdin.Scan() { + var decision struct { + Type string `json:"type"` + Response struct { + RequestID string `json:"request_id"` + Response struct { + Behavior string `json:"behavior"` + } `json:"response"` + } `json:"response"` + } + if err := json.Unmarshal(stdin.Bytes(), &decision); err == nil { + approved = decision.Response.Response.Behavior == "allow" + ccID = decision.Response.RequestID + } + } + text := "denied for " + ccID + if approved { + text = "allowed for " + ccID + } + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": text, + }) + + case "hang": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_hang", + }) + // Long sleep keeps a runtime timer alive so Go's deadlock + // detector doesn't panic to stderr. SIGTERM still kills it. + time.Sleep(10 * time.Minute) + + case "ask-question": + // Stream an AskUserQuestion as a control_request (the path + // claude-code takes under --permission-prompt-tool stdio). + // Block on stdin waiting for the daemon's control_response + // carrying the human's answer; echo it back via the final + // result frame so the test can assert the round-trip text. + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_ask", + }) + _ = enc.Encode(map[string]any{ + "type": "control_request", + "request_id": "cc_req_ask_1", + "request": map[string]any{ + "subtype": "can_use_tool", + "tool_name": "AskUserQuestion", + "input": map[string]any{ + "questions": []map[string]any{{ + "header": "Confirm delete", + "question": "Delete /tmp directory?", + "multiSelect": false, + "options": []map[string]any{ + {"label": "Confirm delete", "description": "Run rm -rf"}, + {"label": "Cancel", "description": "Do not run"}, + }, + }}, + }, + }, + }) + + // Wait for the daemon's control_response. Body shape: + // {type:"control_response", response:{subtype:"success", + // request_id:"...", response:{behavior:"deny", message:"..."}}} + answerText := "" + if stdin.Scan() { + var cr struct { + Type string `json:"type"` + Response struct { + Subtype string `json:"subtype"` + Response struct { + Behavior string `json:"behavior"` + Message string `json:"message"` + } `json:"response"` + } `json:"response"` + } + if err := json.Unmarshal(stdin.Bytes(), &cr); err == nil { + answerText = cr.Response.Response.Message + } + } + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "echoed:" + answerText, + "session_id": "sess_ask", + }) + } +} + +func helperConfig() claudecode.SessionConfigForTest { + return claudecode.SessionConfigForTest{ + ClaudeBinary: os.Args[0], + // belt-and-braces: -test.run=^$ stops any tests from running + // if TestMain forgets to short-circuit. + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +// helperReq points the helper at a specific role via env passthrough. +func helperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Prompt: prompt, + AgentOptions: map[string]any{ + "env": map[string]any{ + helperEnvKey: role, + }, + }, + } +} + +// drain reads envelopes until out closes or dl fires. Second return +// is true on a clean close, false on timeout. +func drain(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionEndToEndSuccess(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_s", "hello", "echo-success"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + if len(got) == 0 { + t.Fatal("got no envelopes") + } + if got[len(got)-1].Type != "done" { + t.Errorf("last env type = %q, want done", got[len(got)-1].Type) + } + + types := envTypes(got) + mustContain(t, types, "delta") + mustContain(t, types, "usage") + mustContain(t, types, "done") + for _, e := range got { + if e.ID != "run_s" { + t.Errorf("env type=%s ID=%q, want run_s", e.Type, e.ID) + } + } +} + +func TestTerminalResultKeepsProcessAliveUntilCancel(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_terminal_wait", "start background work", "terminal-wait"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envelopes", len(got)) + } + mustContain(t, envTypes(got), proto.TypeDone) + + select { + case <-sess.ProcessDoneForTest(): + t.Fatal("terminal result killed the CLI process before the idle timeout") + case <-time.After(50 * time.Millisecond): + } + + if err := sess.Cancel(context.Background()); err != nil { + t.Fatalf("Cancel: %v", err) + } + select { + case <-sess.ProcessDoneForTest(): + case <-time.After(2 * time.Second): + t.Fatal("CLI process did not exit after explicit cancel") + } +} + +func TestSessionEndToEndError(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_e", "hello", "echo-error"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + mustContain(t, types, "error") + mustContain(t, types, "done") + if got[len(got)-1].Type != "done" { + t.Errorf("last env not done: %s", got[len(got)-1].Type) + } +} + +func TestSessionCancelClosesOut(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_c", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + // Give the helper a moment to emit the system init line. + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := envTypes(got) + // Synthesised cancel terminal: error + done. + mustContain(t, types, "done") +} + +func TestSessionCancelIsIdempotent(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_c2", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + _ = sess.Cancel(context.Background()) + _ = sess.Cancel(context.Background()) + _ = sess.Cancel(context.Background()) + _, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after redundant Cancels") + } +} + +func TestSessionSubmitPermissionUnknownReturnsErrUnknown(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_p0", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + err = sess.SubmitPermission(context.Background(), "perm_neverseen", + proto.PermissionDecisionPayload{Approved: true}) + if !errors.Is(err, agent.ErrUnknownPermission) { + t.Errorf("SubmitPermission for unknown id err = %v, want ErrUnknownPermission", err) + } +} + +func TestSessionPermissionRoundTrip(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_p", "approve me", "permission"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + // Drain until we see the permission_request, then approve it. + permID := "" + deadline := time.After(5 * time.Second) + var collected []proto.Envelope + for permID == "" { + select { + case env, ok := <-out: + if !ok { + t.Fatalf("out closed before permission_request; collected %d", len(collected)) + } + collected = append(collected, env) + if env.Type == "permission_request" { + if env.ID != "run_p" { + t.Fatalf("permission env.ID = %q, want run_p", env.ID) + } + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission request: %v", err) + } + permID = request.RequestID + } + case <-deadline: + t.Fatalf("timeout waiting for permission_request; collected %d", len(collected)) + } + } + if !strings.HasPrefix(permID, "perm_") { + t.Errorf("perm id wrong shape: %q", permID) + } + + if err := sess.SubmitPermission(context.Background(), permID, + proto.PermissionDecisionPayload{Approved: true}); err != nil { + t.Fatalf("SubmitPermission: %v", err) + } + + // Drain the rest. Expect to land at done with "allowed" content. + rest, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after approval") + } + all := append(collected, rest...) + final := all[len(all)-1] + if final.Type != "done" { + t.Errorf("final env type = %q, want done", final.Type) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.HasPrefix(done.Content, "allowed for ") { + t.Errorf("done.content = %q, want 'allowed for ...'", done.Content) + } + + // After resolution the perm id should no longer be known. + err = sess.SubmitPermission(context.Background(), permID, + proto.PermissionDecisionPayload{Approved: true}) + if !errors.Is(err, agent.ErrUnknownPermission) { + t.Errorf("second SubmitPermission err = %v, want ErrUnknownPermission", err) + } +} + +func TestSessionPermissionTimeoutDeniesInsteadOfHanging(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 150 * time.Millisecond + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_permission_timeout", "approve me", "permission"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + envs, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after permission timeout") + } + final := envs[len(envs)-1] + if final.Type != proto.TypeDone { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) + } + var done proto.DonePayload + if err := final.DecodePayload(&done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "denied for req_cc_42") { + t.Fatalf("timeout did not deny the request: %q", done.Content) + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + // Pure-image inbound (empty Prompt, non-empty Attachments) is a + // valid prompt today and must NOT be rejected. + out := make(chan proto.Envelope, 4) + _, err := claudecode.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "r0", Prompt: ""}, + out, helperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt + no attachments") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := claudecode.NewSessionForTest(context.Background(), + helperReq("r0", "hi", "echo-success"), + nil, helperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := helperConfig() + cfg.ClaudeBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := claudecode.NewSessionForTest(context.Background(), + helperReq("r0", "hi", "echo-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func envTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, e := range envs { + out[i] = e.Type + } + return out +} + +func mustContain(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Errorf("expected %q in %v", needle, haystack) + } +} + +// TestSessionAskUserQuestionRoundTrip drives the full intercept → +// answer → tool_result loop through a real subprocess, so the test +// also catches stdin write / NDJSON-framing regressions the unit +// tests can't see. +func TestSessionAskUserQuestionRoundTrip(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 30 * time.Second + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_a", "ask me", "ask-question"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + askID := "" + deadline := time.After(5 * time.Second) + var collected []proto.Envelope + for askID == "" { + select { + case env, ok := <-out: + if !ok { + t.Fatalf("out closed before prompt_for_user_choice; collected %d", len(collected)) + } + collected = append(collected, env) + if env.Type == proto.TypePromptForUserChoice { + // env.ID is the run id; the ask id rides on the payload. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err != nil { + t.Fatalf("decode prompt_for_user_choice payload: %v", err) + } + askID = p.AskID + } + case <-deadline: + t.Fatalf("timeout waiting for prompt_for_user_choice; collected %d", len(collected)) + } + } + if !strings.HasPrefix(askID, "ask_") { + t.Errorf("ask id wrong shape: %q", askID) + } + + if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ + Answers: []string{"Confirm delete"}, + }); err != nil { + t.Fatalf("SubmitPromptForUserChoice: %v", err) + } + + rest, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after ask answer") + } + all := append(collected, rest...) + final := all[len(all)-1] + if final.Type != "done" { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(all)) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "Confirm delete") { + t.Errorf("answer not echoed back through the fake claude loop: %q", done.Content) + } + + // Second submit must look unknown. + err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"x"}}) + if !errors.Is(err, agent.ErrUnknownAsk) { + t.Errorf("second SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) + } +} + +// TestSessionAskUserQuestionTimeoutSubmitsCancelled exercises the +// daemon-side timer. AskTimeout is set short so the watchdog fires +// before the human responds; the test then asserts the fake claude +// resumed with the canned "timeout" message (i.e. the timer wrote a +// successful tool_result, not an error). +func TestSessionAskUserQuestionTimeoutSubmitsCancelled(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 150 * time.Millisecond + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_to", "ask me", "ask-question"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + envs, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after timer fired") + } + final := envs[len(envs)-1] + if final.Type != "done" { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "10 minutes") { + t.Errorf("timeout sentence not echoed: %q", done.Content) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills.go b/apps/parsar-daemon/internal/agent/claudecode/skills.go new file mode 100644 index 000000000..9305d23e3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/skills.go @@ -0,0 +1,276 @@ +package claudecode + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/google/uuid" +) + +// skillDescriptor is the daemon-side view of one server-sent skill entry +// under agent_options["skills"]. Wire-identical to pluginDescriptor. +type skillDescriptor struct { + Name string + Version string + DownloadURL string + SHA256 string +} + +// SkillInstallResult carries installed directories and warnings. Claude Code +// auto-scans its project root; other adapters register the returned root. +type SkillInstallResult struct { + SkillDirs []string + Warnings []string +} + +// installSkills materialises every skill under +// /.claude/skills//. Pipeline mirrors installPlugins; +// only the target subdir differs (Claude Code auto-registers skills +// from that path). +func installSkills( + ctx context.Context, + logger *slog.Logger, + workDir string, + skills []skillDescriptor, +) (SkillInstallResult, error) { + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + if strings.TrimSpace(workDir) == "" { + return SkillInstallResult{}, errors.New("claudecode skills: workDir is required") + } + return installSkillsAtRoot(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills, "claudecode skills") +} + +// InstallManagedSkills decodes the portable agent_options["skills"] payload, +// materializes it below root, and removes entries that are no longer active. +func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { + if strings.TrimSpace(root) == "" { + return SkillInstallResult{}, errors.New("managed skills: root is required") + } + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return SkillInstallResult{}, err + } + defer unlock() + skills, decodeWarnings := decodeSkillDescriptors(raw) + result, err := installSkillsAtRootLocked(ctx, logger, root, skills, "managed skills") + result.Warnings = append(decodeWarnings, result.Warnings...) + if err != nil { + return result, err + } + if err := pruneManagedSkills(root, result.SkillDirs); err != nil { + return result, err + } + return result, nil +} + +func installSkillsAtRoot( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, + logLabel string, +) (SkillInstallResult, error) { + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return SkillInstallResult{}, err + } + defer unlock() + return installSkillsAtRootLocked(ctx, logger, root, skills, logLabel) +} + +func installSkillsAtRootLocked( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, + logLabel string, +) (SkillInstallResult, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + + result := SkillInstallResult{} + for _, s := range skills { + if err := s.validate(); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) + logger.Warn(logLabel+": invalid descriptor", "err", err.Error()) + continue + } + + dir := filepath.Join(root, s.Name) + cacheKey := filepath.Join(dir, ".cache-key") + expectedKey := s.cacheKey() + + if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { + logger.Info(logLabel+": cache hit", + "name", s.Name, "version", s.Version, "dir", dir) + result.SkillDirs = append(result.SkillDirs, dir) + continue + } + + // Same timeout / cap as plugins — they share the install pipeline. + perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) + err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s, logLabel) + cancel() + if err != nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) + logger.Warn(logLabel+": install failed", + "name", s.Name, "version", s.Version, "err", err.Error()) + continue + } + result.SkillDirs = append(result.SkillDirs, dir) + logger.Info(logLabel+": installed", + "name", s.Name, "version", s.Version, "dir", dir) + } + return result, nil +} + +func pruneManagedSkills(root string, activeDirs []string) error { + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("managed skills: read root %s: %w", root, err) + } + active := make(map[string]struct{}, len(activeDirs)) + for _, dir := range activeDirs { + active[filepath.Base(dir)] = struct{}{} + } + for _, entry := range entries { + if entry.Name() == ".tmp" { + continue + } + if _, ok := active[entry.Name()]; ok { + continue + } + path := filepath.Join(root, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("managed skills: remove stale entry %s: %w", path, err) + } + } + return nil +} + +// installOneSkill: same shape as installOnePlugin, only target dir differs. +// Reuses fetchPluginZip / verifyPluginSHA256FromFD / extractPluginZipFromFD +// — the helpers are skill-agnostic and applying them to skill zips keeps +// the path-traversal / TOCTOU / SHA256 defences identical. +func installOneSkill( + ctx context.Context, + logger *slog.Logger, + root, dir, cacheKey, expectedKey string, + s skillDescriptor, + logLabel string, +) error { + tmpDir := filepath.Join(root, ".tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + + zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) + defer func() { + _ = os.Remove(zipPath) + }() + + fd, err := fetchPluginZip(ctx, s.DownloadURL, zipPath) + if err != nil { + return err + } + defer fd.Close() + + if err := verifyPluginSHA256FromFD(fd, s.SHA256); err != nil { + return err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", err) + } + fi, err := fd.Stat() + if err != nil { + return fmt.Errorf("stat: %w", err) + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("rm old dir: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir target: %w", err) + } + if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { + _ = os.RemoveAll(dir) + return err + } + + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + logger.Warn(logLabel+": write cache key failed", + "path", cacheKey, "err", err.Error()) + } + return nil +} + +// decodeSkillDescriptors converts agent_options["skills"] into typed +// descriptors. Mirrors decodePluginDescriptors. +func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} + } + out := make([]skillDescriptor, 0, len(items)) + warnings := make([]string, 0) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) + continue + } + s := skillDescriptor{ + Name: stringField(obj, "name"), + Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), + SHA256: stringField(obj, "sha256"), + } + if err := s.validate(); err != nil { + warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) + continue + } + out = append(out, s) + } + return out, warnings +} + +func (s skillDescriptor) validate() error { + if strings.TrimSpace(s.Name) == "" { + return errors.New("name is required") + } + if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { + return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) + } + if strings.TrimSpace(s.DownloadURL) == "" { + return errors.New("download_url is required") + } + if len(s.SHA256) != 64 { + return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) + } + return nil +} + +func (s skillDescriptor) cacheKey() string { + return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go new file mode 100644 index 000000000..58cbcbe80 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go @@ -0,0 +1,165 @@ +package claudecode + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// validSkillZipBytes is a minimal SKILL.md-rooted zip. +func validSkillZipBytes(t *testing.T) []byte { + return buildPluginZipBytes(t, []pluginZipFile{ + {Name: "SKILL.md", Body: "---\nname: code-review\ndescription: Review code\n---\nBody"}, + }) +} + +func TestInstallSkills_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { + t.Parallel() + body := validSkillZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), workDir, []skillDescriptor{ + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("installSkills: %v", err) + } + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } + + dir := filepath.Join(workDir, ".claude", "skills", "code-review") + if len(res.SkillDirs) != 1 || res.SkillDirs[0] != dir { + t.Fatalf("skill dirs = %v, want [%s]", res.SkillDirs, dir) + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Fatalf("SKILL.md missing: %v", err) + } + stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) + if err != nil { + t.Fatalf("read cache-key: %v", err) + } + want := "code-review@" + sha256Hex(body) + if string(stamped) != want { + t.Fatalf("cache-key = %q, want %q", stamped, want) + } +} + +func TestInstallSkills_CacheHitSkipsDownload(t *testing.T) { + t.Parallel() + body := validSkillZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + desc := []skillDescriptor{ + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + } + if _, err := installSkills(context.Background(), discardLogger(), workDir, desc); err != nil { + t.Fatalf("first install: %v", err) + } + hitsAfterFirst := srv.Hits() + if hitsAfterFirst != 1 { + t.Fatalf("first install hits = %d, want 1", hitsAfterFirst) + } + second, err := installSkills(context.Background(), discardLogger(), workDir, desc) + if err != nil { + t.Fatalf("second install: %v", err) + } + if len(second.SkillDirs) != 1 { + t.Fatalf("cache hit skill dirs = %v", second.SkillDirs) + } + if got := srv.Hits(); got != hitsAfterFirst { + t.Fatalf("cache should prevent second download; got %d extra hits", got-hitsAfterFirst) + } +} + +func TestInstallManagedSkillsPrunesInactiveEntries(t *testing.T) { + body := validSkillZipBytes(t) + srv := startPluginServer(t, body) + root := t.TempDir() + stale := filepath.Join(root, "old-skill") + if err := os.MkdirAll(stale, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := InstallManagedSkills(context.Background(), discardLogger(), root, []any{ + map[string]any{ + "name": "code-review", "version": "1.0.0", + "download_url": srv.URL, "sha256": sha256Hex(body), + }, + }) + if err != nil { + t.Fatalf("InstallManagedSkills: %v", err) + } + if len(res.SkillDirs) != 1 || res.SkillDirs[0] != filepath.Join(root, "code-review") { + t.Fatalf("skill dirs = %v", res.SkillDirs) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale skill still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "code-review", "SKILL.md")); err != nil { + t.Fatalf("active skill missing: %v", err) + } +} + +func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { + t.Parallel() + body := validSkillZipBytes(t) + srv := startPluginServer(t, body) + workDir := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), workDir, []skillDescriptor{ + // Wrong sha256 (all-zero pattern is 64 hex chars, never matches body) + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: "0000000000000000000000000000000000000000000000000000000000000000"}, + }) + if err != nil { + t.Fatalf("installSkills should not hard-error on sha mismatch: %v", err) + } + if len(res.Warnings) != 1 { + t.Fatalf("want 1 warning, got %d: %v", len(res.Warnings), res.Warnings) + } + if _, err := os.Stat(filepath.Join(workDir, ".claude", "skills", "code-review", "SKILL.md")); err == nil { + t.Fatal("SKILL.md should not exist after sha mismatch") + } +} + +func TestInstallSkills_EmptyListIsNoop(t *testing.T) { + t.Parallel() + workDir := t.TempDir() + res, err := installSkills(context.Background(), discardLogger(), workDir, nil) + if err != nil { + t.Fatalf("empty install: %v", err) + } + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } +} + +func TestDecodeSkillDescriptors_ArrayShape(t *testing.T) { + raw := []any{ + map[string]any{ + "name": "code-review", + "version": "1.0.0", + "download_url": "https://x", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + }, + } + got, warns := decodeSkillDescriptors(raw) + if len(got) != 1 || len(warns) != 0 { + t.Fatalf("got=%+v warns=%v", got, warns) + } +} + +func TestDecodeSkillDescriptors_NilAndWrongType(t *testing.T) { + if got, warns := decodeSkillDescriptors(nil); got != nil || warns != nil { + t.Fatalf("nil raw should be (nil, nil), got (%v, %v)", got, warns) + } + if got, warns := decodeSkillDescriptors("not-array"); got != nil || len(warns) != 1 { + t.Fatalf("string raw should warn, got got=%v warns=%v", got, warns) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/version.go b/apps/parsar-daemon/internal/agent/claudecode/version.go new file mode 100644 index 000000000..0dbcd0606 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/version.go @@ -0,0 +1,32 @@ +package claudecode + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +// InstallURL points to the official Claude Code install instructions. +// Surfaced by `parsar-daemon connect` when the CLI is missing so the user +// has a clear next step instead of an opaque "exec: no such file". +const InstallURL = "https://docs.anthropic.com/claude/docs/claude-code" + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish +// "install Claude Code" from "Claude Code is broken". +var ErrCLINotFound = errors.New("claude CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. Empty binary defaults to binpath.ClaudeCode() — the same +// resolver the session spawn uses, so probe and spawn always agree. On +// missing binary the error wraps ErrCLINotFound; on other failures the +// wrapped error keeps the raw stderr. +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "claude", + DefaultBinary: binpath.ClaudeCode(), + MissingError: ErrCLINotFound, + }) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/version_test.go b/apps/parsar-daemon/internal/agent/claudecode/version_test.go new file mode 100644 index 000000000..304f6a1a8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudecode/version_test.go @@ -0,0 +1,17 @@ +package claudecode_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "claude", + DefaultBinary: "claude", + MissingError: claudecode.ErrCLINotFound, + Check: claudecode.CheckCLIAvailable, + }) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go b/apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go new file mode 100644 index 000000000..6101240af --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go @@ -0,0 +1,32 @@ +package claudesdk + +import "bufio" + +type bridgeOutput struct { + frames chan []byte + current []byte + err error +} + +func (s *session) bridgeOutput() *bridgeOutput { + output := &bridgeOutput{frames: make(chan []byte, 1)} + go func() { + defer close(output.frames) + scanner := bufio.NewScanner(s.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) + for scanner.Scan() { + raw := append([]byte(nil), scanner.Bytes()...) + if !s.receiveWorkspaceRead(raw) && !s.receiveWorkspaceDirectory(raw) { + output.frames <- raw + } + } + output.err = scanner.Err() + if output.err != nil { + s.process.Cancel() + } + }() + return output +} +func (o *bridgeOutput) Scan() bool { var ok bool; o.current, ok = <-o.frames; return ok } +func (o *bridgeOutput) Bytes() []byte { return o.current } +func (o *bridgeOutput) Err() error { return o.err } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go new file mode 100644 index 000000000..b2c432aa8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go @@ -0,0 +1,42 @@ +package claudesdk + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Cancel confirms process exit and output drain, independently of terminal delivery. +func (s *session) Cancel(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-s.settled: + return nil + default: + } + s.process.Cancel() + select { + case <-s.settled: + return nil + case <-ctx.Done(): + select { + case <-s.settled: + return nil + default: + return ctx.Err() + } + } +} + +// CancellationOutcome is available after successful Cancel or terminal publication. +// Closing settled publishes the immutable snapshot; an unsettled result is unknown. +func (s *session) CancellationOutcome() proto.DonePayload { + select { + case <-s.settled: + return s.outcome + default: + return proto.DonePayload{} + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go new file mode 100644 index 000000000..64860c7cf --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -0,0 +1,161 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestLiveClaudeSDKCancelResume(t *testing.T) { + entrypoint := os.Getenv("PARSAR_CLAUDE_SDK_ENTRYPOINT") + keyFile := os.Getenv("PARSAR_CLAUDE_SDK_MINIMAX_KEY_FILE") + if entrypoint == "" || keyFile == "" { + t.Skip("real cancellation acceptance requires explicit SDK entrypoint and private key file") + } + proofRoot := os.Getenv("PARSAR_CLAUDE_SDK_PROOF_DIR") + if !filepath.IsAbs(proofRoot) { + t.Fatal("PARSAR_CLAUDE_SDK_PROOF_DIR must be an absolute managed directory") + } + root, err := os.MkdirTemp(proofRoot, "claude-cancel-") + if err != nil { + t.Fatal(err) + } + t.Logf("real cancellation evidence: %s", root) + t.Setenv("PARSAR_HOME", root) + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), + "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", + "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", + }} + readiness, err := CheckRuntime(context.Background(), config) + if err != nil { + t.Fatal("real runtime readiness failed", err) + } + readinessJSON, _ := json.MarshalIndent(readiness, "", " ") + if err := os.WriteFile(filepath.Join(root, "readiness.json"), readinessJSON, 0o600); err != nil { + t.Fatal(err) + } + type evidence struct { + NodePID int `json:"node_pid"` + NativePIDs []int `json:"native_pids"` + CancelMS int64 `json:"cancel_milliseconds,omitempty"` + Cancelled bool `json:"cancelled"` + Failure string `json:"failure,omitempty"` + Outcome proto.DonePayload `json:"outcome"` + Events []proto.Envelope `json:"events"` + } + run := func(prompt, resume string, cancelOnText bool) evidence { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + out := make(chan proto.Envelope, 64) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Prompt: prompt, AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."}} + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + s := running.(*session) + defer s.Cancel(ctx) + proof := evidence{NodePID: s.process.Cmd.Process.Pid} + done := false + for event := range out { + proof.Events = append(proof.Events, event) + if event.Type == proto.TypeDelta && len(proof.NativePIDs) == 0 { + raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) + for _, value := range strings.Fields(string(raw)) { + pid, _ := strconv.Atoi(value) + args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { + proof.NativePIDs = append(proof.NativePIDs, pid) + } + } + } + switch event.Type { + case proto.TypeDelta: + if cancelOnText && !proof.Cancelled { + select { + case <-s.process.Done(): + t.Fatal("native execution ended before cancellation") + default: + } + started := time.Now() + if err := s.Cancel(ctx); err != nil { + t.Fatal("live cancellation failed", err) + } + proof.CancelMS = time.Since(started).Milliseconds() + proof.Cancelled = true + proof.Outcome = s.CancellationOutcome() + } + case proto.TypeError: + var payload proto.ErrorPayload + _ = event.DecodePayload(&payload) + proof.Failure = payload.Error + case proto.TypeDone: + done = true + var payload proto.DonePayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + if proof.Cancelled { + expected, _ := json.Marshal(proof.Outcome) + actual, _ := json.Marshal(payload) + if !bytes.Equal(expected, actual) { + t.Fatal("live cancellation outcome differs from Done") + } + } + proof.Outcome = payload + } + } + data, _ := json.MarshalIndent(proof, "", " ") + if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { + t.Fatal(err) + } + if !done || len(proof.NativePIDs) == 0 || proof.Cancelled != cancelOnText { + t.Fatal("missing real execution/completion/cancellation evidence") + } + select { + case <-s.process.Done(): + default: + t.Fatal("completion preceded owned process release") + } + for _, pid := range append([]int{proof.NodePID}, proof.NativePIDs...) { + if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) == 0 || fields[0] != "Z" { + t.Fatalf("execution process %d remains alive", pid) + } + } + } + return proof + } + nonce := "cancel-history-" + uuid.NewString() + first := run("Remember this exact verification value: "+nonce+". First repeat it, then write two hundred numbered sentences about trees. Do not use tools.", "", true) + id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) + if id == "" || first.Outcome.Content == "" || first.Failure == "" { + t.Fatal("live cancellation lost identity, partial output or interruption evidence") + } + second := run("Return only the exact cancel-history verification value in the earlier user request. Ignore the earlier request for numbered sentences.", id, false) + if second.Failure != "" || second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Outcome.Content, nonce) || first.NodePID == second.NodePID { + t.Fatalf("cold continuation did not preserve identity/history; evidence %s", root) + } + data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> maintained SDK/native -> real MiniMax cancellation and cold continuation; public admission remains separate", "verification_value": nonce, "executions": []evidence{first, second}}, "", " ") + if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go new file mode 100644 index 000000000..63c13936b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go @@ -0,0 +1,204 @@ +//go:build unix + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/signal" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := cancellationConfig(root, "wait") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // A stopped consumer must not prevent native output draining or cancellation. + out := make(chan proto.Envelope) + running, err := NewFactory(config)(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("missing native readiness barrier") + } + short, stop := context.WithTimeout(ctx, 50*time.Millisecond) + err = running.Cancel(short) + stop() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unsettled cancellation reported %v", err) + } + provider, ok := running.(interface{ CancellationOutcome() proto.DonePayload }) + if !ok { + t.Fatal("missing cancellation outcome provider") + } + if got := provider.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { + t.Fatal("unsettled outcome was exposed", got) + } + if err := running.(*session).Steer(ctx, proto.PromptSteerPayload{InputID: "later", Text: "later"}); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal("cancelled execution accepted steering", err) + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := running.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case <-running.(*session).process.Done(): + default: + t.Fatal("successful cancellation preceded owned process release") + } + got := provider.CancellationOutcome() + if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil || got.Usage.Tokens != nil { + t.Fatalf("lost drained cancellation outcome: %+v", got) + } + var done proto.DonePayload + for event := range out { + if event.Type == proto.TypeDone { + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + // Router cleanup calls Cancel while it is still handling Done. + if err := running.Cancel(ctx); err != nil { + t.Fatal("completion cleanup waited on terminal delivery", err) + } + } + } + gotJSON, _ := json.Marshal(got) + doneJSON, _ := json.Marshal(done) + if !bytes.Equal(gotJSON, doneJSON) { + t.Fatal("Done differs from cancellation outcome", done) + } +} + +func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { + for _, mode := range []string{"failure", "wrong-identity", "before-identity"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 8) + running, err := NewFactory(cancellationConfig(root, mode))(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + failed := false + var done proto.DonePayload + for event := range out { + if event.Type == proto.TypeError { + failed = true + } + if event.Type == proto.TypeDone { + _ = event.DecodePayload(&done) + } + } + if !failed { + t.Fatal("native failure was not reported") + } + if mode == "failure" { + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Content != "partial" || done.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("verified failure outcome was lost", done) + } + } else if done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("requested or mismatched native identity was exposed", done) + } + }) + } +} + +func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := cancellationConfig(root, "wait") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("missing native readiness barrier") + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := running.Cancel(ctx); err != nil { + t.Fatal(err) + } + var text string + usage := 0 + for event := range out { + if event.Type == proto.TypeDelta { + var delta proto.DeltaPayload + if err := event.DecodePayload(&delta); err != nil { + t.Fatal(err) + } + text += delta.Delta + } + if event.Type == proto.TypeUsage { + usage++ + } + } + if text != "taildrained" || usage != 1 { + t.Fatalf("ready consumer lost drained observations: text %q, usage %d", text, usage) + } +} + +func cancellationConfig(root, mode string) Config { + return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} +} + +func cancellationRequest() proto.PromptRequestPayload { + return proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} +} + +func runCancellationHelper(request startRequest, mode string, emit func(bridgeEvent)) { + if mode == "cancellation-wait" { + stopped := make(chan os.Signal, 1) + signal.Notify(stopped, syscall.SIGTERM) + emit(bridgeEvent{Type: "delta", Delta: "partial"}) + <-stopped + // These valid observations were in flight when cancellation started. + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) + emit(bridgeEvent{Type: "delta", Delta: "tail"}) + for { + if _, err := os.Stat(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "release")); err == nil { + break + } + time.Sleep(time.Millisecond) + } + _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) + emit(bridgeEvent{Type: "delta", Delta: "drained"}) + return + } + if mode != "cancellation-before-identity" { + id := request.Resume + if mode == "cancellation-wrong-identity" { + id = "wrong-session" + } + emit(bridgeEvent{Type: "input_ready", SessionID: id}) + emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: json.RawMessage(usageFixture)}) + emit(bridgeEvent{Type: "delta", Delta: "partial"}) + } + emit(bridgeEvent{Type: "error", Code: "execution_failed"}) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands.go b/apps/parsar-daemon/internal/agent/claudesdk/commands.go new file mode 100644 index 000000000..d7afe2f01 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands.go @@ -0,0 +1,62 @@ +package claudesdk + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type commandState struct { + calls map[string]proto.ToolObservation +} + +func (c *commandState) receive(event bridgeEvent, start startRequest, sessionID string, emit func(string, any)) error { + n := event.Observation + if start.Workspace == nil || sessionID == "" || event.SessionID != sessionID || event.ID == "" || n == nil || + n.Kind != "command" || strings.TrimSpace(n.Command) == "" || n.Name != "" || n.Cwd != nil || n.ExitCode != nil || n.DurationMS != nil || + n.Server != "" || len(n.Arguments) != 0 || len(n.Error) != 0 || n.Content != nil || n.Action != nil { + return fmt.Errorf("claudesdk: invalid command observation") + } + if len(n.Output) > 0 { + var output *string + if json.Unmarshal(n.Output, &output) != nil { + return fmt.Errorf("claudesdk: invalid command output") + } + } + previous, exists := c.calls[event.ID] + if (event.Stage == "before" && (exists || n.Status != "in_progress" || len(n.Output) != 0)) || + (event.Stage == "after" && (!exists || previous.Status != "in_progress" || previous.Command != n.Command || + (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete"))) || + (event.Stage != "before" && event.Stage != "after") { + return fmt.Errorf("claudesdk: inconsistent command observation") + } + c.calls[event.ID] = *n + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "Bash", Stage: event.Stage, Observation: n}) + } + return nil +} + +func (c *commandState) complete() bool { + for _, call := range c.calls { + if call.Status == "in_progress" { + return false + } + } + return true +} + +func (c *commandState) close(start startRequest, emit func(string, any)) { + for id, call := range c.calls { + if call.Status != "in_progress" { + continue + } + call.Status = "incomplete" + c.calls[id] = call + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "Bash", Stage: "after", Observation: &call}) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go new file mode 100644 index 000000000..10df4cee6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go @@ -0,0 +1,201 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) { + for _, observed := range []bool{false, true} { + config := preparationFixture(t, "old-command-runtime") + req := preparationRequest() + req.ObserveToolObservations = observed + resource, err := NewPreparationFactory(config)(t.Context(), req) + if observed { + if err == nil || !strings.Contains(err.Error(), "workspace command observations") { + t.Fatal("old bridge accepted requested command observations", err) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + t.Fatal("old bridge started execution before rejection") + } + } else { + if err != nil { + t.Fatal("old bridge changed opt-out behavior", err) + } + if err := resource.Close(); err != nil { + t.Fatal(err) + } + } + } +} + +func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) { + for _, observed := range []bool{false, true} { + config := preparationFixture(t, "commands-success") + req := preparationRequest() + req.ObserveToolObservations = observed + resource, err := NewPreparationFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer resource.Close() + if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + t.Fatal("preparation submitted a command") + } + out := make(chan proto.Envelope, 16) + s, err := resource.Start(t.Context(), "actual-command-run", "hello", out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + var frames []proto.ToolCallPayload + done := 0 + for event := range out { + if event.ID != "actual-command-run" || event.Type == proto.TypeError || event.Type == proto.TypeCommandOutput { + t.Fatal("execution identity or final-only command behavior changed", event.Type) + } + if event.Type == proto.TypeToolCall { + var payload proto.ToolCallPayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + frames = append(frames, payload) + } + if event.Type == proto.TypeDone { + done++ + } + } + if done != 1 || observed && len(frames) != 2 || !observed && len(frames) != 0 { + t.Fatal("completion or observation opt-in changed", done, frames) + } + if observed && (frames[0].ID != "observed" || frames[1].ID != "observed" || frames[1].Observation.Status != "failed" || + string(frames[1].Observation.Output) != `"Exit code 7\nretained"`) { + t.Fatal("native failure output was not retained", frames) + } + } +} + +func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *testing.T) { + for _, mode := range []string{"commands-cancel", "commands-drained", "commands-killed", "commands-missing", "commands-unknown", "commands-before-ready", "commands-wrong-session"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + config := preparationFixture(t, mode) + req := workspaceRequest() + req.AgentSessionID, req.ObserveToolObservations = "native-session", true + out := make(chan proto.Envelope, 32) + s, err := NewFactory(config)(ctx, req, out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + frames := map[string][]proto.ToolCallPayload{} + failed, done := false, 0 + for event := range out { + if event.ID != req.RunID || event.Type == proto.TypeCommandOutput { + t.Fatal("command frame changed execution identity or fabricated deltas") + } + switch event.Type { + case proto.TypeToolCall: + var payload proto.ToolCallPayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + frames[payload.ID] = append(frames[payload.ID], payload) + if payload.ID == "pending" && payload.Stage == "before" { + if mode == "commands-killed" { + if err := s.(*session).process.Cmd.Process.Signal(syscall.SIGKILL); err != nil { + t.Fatal(err) + } + } else if mode == "commands-cancel" || mode == "commands-drained" { + if err := s.Cancel(ctx); err != nil { + t.Fatal(err) + } + } + } + case proto.TypeError: + failed = true + case proto.TypeDone: + done++ + } + } + if !failed || done != 1 { + t.Fatal("invalid completion after command failure", failed, done) + } + if mode == "commands-before-ready" || mode == "commands-wrong-session" { + if len(frames) != 0 { + t.Fatal("unverified execution identity emitted commands", frames) + } + return + } + observed, pending := frames["observed"], frames["pending"] + if len(observed) != 2 || observed[1].Observation.Status != "failed" || string(observed[1].Observation.Output) != `"Exit code 7\nretained"` || + len(pending) != 2 || pending[1].Observation.Status != "incomplete" { + t.Fatal("shutdown lost observed output or did not close only pending calls", frames) + } + if mode == "commands-drained" { + if string(pending[1].Observation.Output) != `"observed while draining"` { + t.Fatal("valid interruption output was lost during drain") + } + } else if len(pending[1].Observation.Output) != 0 { + t.Fatal("missing native result acquired output") + } + }) + } +} + +func runCommandsHelper(request startRequest, mode string, emit func(bridgeEvent)) { + var stopping chan os.Signal + if mode == "commands-cancel" || mode == "commands-drained" { + stopping = make(chan os.Signal, 1) + signal.Notify(stopping, syscall.SIGTERM) + defer signal.Stop(stopping) + } + if mode != "commands-before-ready" { + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + } + before := commandEvent("observed", "before", "in_progress", "printf 'retained\\n'; exit 7") + if mode == "commands-wrong-session" { + before.SessionID = "other" + } + emit(before) + if mode == "commands-before-ready" || mode == "commands-wrong-session" { + return + } + after := commandEvent("observed", "after", "failed", before.Observation.Command) + after.Observation.Output = json.RawMessage(`"Exit code 7\nretained"`) + emit(after) + if mode != "commands-success" { + emit(commandEvent("pending", "before", "in_progress", "sleep 30")) + } + switch mode { + case "commands-cancel", "commands-drained": + <-stopping + if mode == "commands-drained" { + interrupted := commandEvent("pending", "after", "incomplete", "sleep 30") + interrupted.Observation.Output = json.RawMessage(`"observed while draining"`) + emit(interrupted) + } + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + return + case "commands-killed": + time.Sleep(time.Hour) + return + case "commands-unknown": + emit(bridgeEvent{Type: "unexpected-command-event"}) + return + } + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "completed"}) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_test.go b/apps/parsar-daemon/internal/agent/claudesdk/commands_test.go new file mode 100644 index 000000000..d377b20eb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands_test.go @@ -0,0 +1,143 @@ +package claudesdk + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func commandEvent(id, stage, status, command string) bridgeEvent { + return bridgeEvent{Type: "command_observation", SessionID: "native-session", ID: id, Stage: stage, + Observation: &proto.ToolObservation{Kind: "command", Status: status, Command: command}} +} + +func TestCommandObservationLifecycleAndOptIn(t *testing.T) { + for _, observed := range []bool{false, true} { + state := commandState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: observed} + var events []proto.ToolCallPayload + emit := func(kind string, payload any) { + if kind != proto.TypeToolCall { + t.Fatal(kind) + } + events = append(events, payload.(proto.ToolCallPayload)) + } + const command = " printf 'failure\\n'; exit 7 " + before := commandEvent("native-call", "before", "in_progress", command) + if err := state.receive(before, start, "native-session", emit); err != nil || state.complete() { + t.Fatal("call was not pending", err) + } + if err := state.receive(before, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge call accepted") + } + after := commandEvent("native-call", "after", "failed", command) + after.Observation.Output = json.RawMessage(`"Exit code 7\nfailure"`) + if err := state.receive(after, start, "native-session", emit); err != nil || !state.complete() { + t.Fatal("native result did not complete the call", err) + } + if err := state.receive(after, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge result accepted") + } + if err := state.receive(commandEvent("pending", "before", "in_progress", "sleep 30"), start, "native-session", emit); err != nil { + t.Fatal(err) + } + state.close(start, emit) + state.close(start, emit) + if !state.complete() || state.calls["pending"].Status != "incomplete" || state.calls["native-call"].Status != "failed" { + t.Fatal("closure changed an observed result or lost an unfinished call") + } + if !observed { + if len(events) != 0 { + t.Fatal("opt-out emitted observations") + } + continue + } + if len(events) != 4 || events[0].ID != "native-call" || events[0].Name != "Bash" || events[0].Observation.Command != command || + string(events[1].Observation.Output) != `"Exit code 7\nfailure"` || events[1].Observation.ExitCode != nil || + events[1].Observation.Cwd != nil || events[1].Observation.DurationMS != nil || events[3].ID != "pending" || + events[3].Observation.Status != "incomplete" || len(events[3].Observation.Output) != 0 { + t.Fatal("observation identity, output or unknown metadata changed", events) + } + } +} + +func TestCommandObservationsRejectUnqualifiedOrInconsistentEvents(t *testing.T) { + for _, mode := range []string{"profile", "uninitialized", "session", "id", "nil", "kind", "empty-command", "name", "cwd", "exit", "duration", "arguments", "error", "output-object", "before-output", "before-status", "after-before", "changed-command", "after-status", "stage"} { + t.Run(mode, func(t *testing.T) { + state := commandState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: true} + sessionID := "native-session" + event := commandEvent("call", "before", "in_progress", "pwd") + if strings.HasPrefix(mode, "after-") || mode == "changed-command" { + if mode != "after-before" { + state.calls[event.ID] = *event.Observation + } + event.Stage, event.Observation.Status = "after", "completed" + } + switch mode { + case "profile": + start.Workspace = nil + case "uninitialized": + sessionID = "" + case "session": + event.SessionID = "other" + case "id": + event.ID = "" + case "nil": + event.Observation = nil + case "kind": + event.Observation.Kind = "mcp" + case "empty-command": + event.Observation.Command = " " + case "name": + event.Observation.Name = "unexpected" + case "cwd": + value := "/inferred" + event.Observation.Cwd = &value + case "exit": + value := int64(0) + event.Observation.ExitCode = &value + case "duration": + value := int64(10) + event.Observation.DurationMS = &value + case "arguments": + event.Observation.Arguments = json.RawMessage(`{}`) + case "error": + event.Observation.Error = json.RawMessage(`"failure"`) + case "output-object": + event.Observation.Output = json.RawMessage(`{"stdout":"output"}`) + case "before-output": + event.Observation.Output = json.RawMessage(`"early"`) + case "before-status": + event.Observation.Status = "completed" + case "changed-command": + event.Observation.Command = "different" + case "after-status": + event.Observation.Status = "in_progress" + case "stage": + event.Stage = "unknown" + } + if err := state.receive(event, start, sessionID, func(string, any) { t.Fatal("invalid event was emitted") }); err == nil { + t.Fatal("invalid command observation was accepted") + } + }) + } +} + +func TestCommandObservationUnknownAndEmptyOutputRemainDistinct(t *testing.T) { + for _, output := range []json.RawMessage{nil, json.RawMessage(`null`), json.RawMessage(`""`)} { + state := commandState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}} + emit := func(string, any) { t.Fatal("opt-out emitted an observation") } + if err := state.receive(commandEvent("call", "before", "in_progress", "pwd"), start, "native-session", emit); err != nil { + t.Fatal(err) + } + event := commandEvent("call", "after", "completed", "pwd") + event.Observation.Output = output + if err := state.receive(event, start, "native-session", emit); err != nil || string(state.calls["call"].Output) != string(output) { + t.Fatal("output availability changed", err) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go new file mode 100644 index 000000000..b65e22933 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go @@ -0,0 +1,77 @@ +//go:build unix + +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "Original input.", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "native-model", "system_prompt": "Keep these exact instructions.\nDo not replace them."}} + ordinary, _, err := prepare(config, request) + if err != nil { + t.Fatal(err) + } + request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + before, _ := json.Marshal(request) + controlled, _, err := prepare(config, request) + if err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(request) + if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) { + t.Fatal("default controls changed native input, instructions, continuation or caller options") + } +} + +func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { + cases := map[string]proto.ExecutionControls{ + "empty": {}, "missing-search": {TextVerbosity: "medium"}, "missing-verbosity": {WebSearch: "disabled"}, + "cached-search": {WebSearch: "cached", TextVerbosity: "medium"}, + "live-search": {WebSearch: "live", TextVerbosity: "medium"}, + "unknown-search": {WebSearch: "invalid", TextVerbosity: "medium"}, + "low-verbosity": {WebSearch: "disabled", TextVerbosity: "low"}, + "high-verbosity": {WebSearch: "disabled", TextVerbosity: "high"}, + "unknown-verbosity": {WebSearch: "disabled", TextVerbosity: "invalid"}, + } + for name, controls := range cases { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "Original input.", ExecutionControls: &controls, AgentOptions: map[string]any{"model": "native-model"}} + _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.Contains(err.Error(), "execution controls require") { + t.Fatal("unsupported controls did not fail at admission", err) + } + if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + t.Fatal("unsupported controls reached native setup", err) + } + }) + } +} + +func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + servers := []proto.MCPHTTPServer{} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "Input", MCPHTTPServers: &servers} + _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.Contains(err.Error(), "HTTP MCP requires environment:none") { + t.Fatal("MCP reached an unsupported environment", err) + } + if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + t.Fatal("MCP reached native setup", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions.go b/apps/parsar-daemon/internal/agent/claudesdk/functions.go new file mode 100644 index 000000000..35ea91186 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions.go @@ -0,0 +1,181 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type pendingFunction struct { + call proto.FunctionCallPayload + result *proto.FunctionResultPayload + receipt chan error + applied bool +} + +type functionState struct { + mu sync.Mutex + calls map[string]*pendingFunction + closed bool +} + +func validateFunctions(tools []proto.FunctionTool) error { + names := map[string]bool{} + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if strings.TrimSpace(tool.Name) == "" || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return fmt.Errorf("claudesdk: functions require unique names and object-root JSON schemas") + } + names[tool.Name] = true + } + return nil +} + +func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit func(string, any)) error { + var call *proto.FunctionCallPayload + var observation *proto.ToolObservation + var receipt chan error + err := func() error { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return agent.ErrUnknownFunctionCall + } + switch event.Type { + case "function_call": + call = event.Call + declared := false + if call != nil { + for _, tool := range start.Functions { + if tool.Name == call.Name { + declared = true + break + } + } + } + if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil { + return fmt.Errorf("claudesdk: invalid function call") + } + s.functions.calls[call.CallID] = &pendingFunction{call: *call, receipt: make(chan error, 1)} + observation = &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: call.Name, Arguments: call.Arguments} + case "function_applied": + pending := s.functions.calls[event.CallID] + if pending == nil || pending.result == nil || pending.applied || pending.result.DeliveryID != event.DeliveryID { + return fmt.Errorf("claudesdk: invalid native function receipt") + } + pending.applied = true + receipt = pending.receipt + status := "completed" + if !pending.result.Success { + status = "failed" + } + observation = &proto.ToolObservation{Kind: "function", Status: status, Name: pending.call.Name, Arguments: pending.call.Arguments, Content: &pending.result.Content} + } + return nil + }() + if err != nil { + return err + } + if start.observeFunctions { + id, stage := event.CallID, "after" + if call != nil { + id, stage = call.CallID, "before" + } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + } + if call != nil { + emit(proto.TypeFunctionCall, call) + } else { + receipt <- nil + } + return nil +} + +func (s *session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if result.CallID == "" || result.DeliveryID == "" { + return fmt.Errorf("claudesdk: function result identities are required") + } + if err := result.ValidateContent(); err != nil { + return err + } + for _, part := range result.Content { + if part.Type != "input_text" { + return fmt.Errorf("claudesdk: image function results are not supported") + } + } + data, err := json.Marshal(struct { + Type string `json:"type"` + proto.FunctionResultPayload + }{Type: "function_result", FunctionResultPayload: result}) + if err != nil { + return err + } + if len(data) > 1024*1024 { + return fmt.Errorf("claudesdk: function result exceeds bridge input limit") + } + var owned proto.FunctionResultPayload + if err := json.Unmarshal(data, &owned); err != nil { + return err + } + s.functions.mu.Lock() + pending := s.functions.calls[result.CallID] + if s.functions.closed || s.process.Context().Err() != nil || pending == nil || pending.result != nil { + s.functions.mu.Unlock() + return agent.ErrUnknownFunctionCall + } + pending.result = &owned + s.functions.mu.Unlock() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + // A lost receipt has an unknown outcome; terminate this execution instead of resending. + stop := context.AfterFunc(ctx, s.process.Cancel) + defer stop() + s.writeMu.Lock() + if err = ctx.Err(); err == nil { + _, err = s.process.Stdin.Write(append(data, '\n')) + } + s.writeMu.Unlock() + if err != nil { + s.process.Cancel() + return fmt.Errorf("claudesdk: function result transport failed") + } + select { + case err := <-pending.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) functionsComplete() bool { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + for _, pending := range s.functions.calls { + if !pending.applied { + return false + } + } + return true +} + +func (s *session) stopFunctions() { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return + } + s.functions.closed = true + for _, pending := range s.functions.calls { + if !pending.applied { + pending.receipt <- agent.ErrUnknownFunctionCall + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go new file mode 100644 index 000000000..c0b92f809 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go @@ -0,0 +1,144 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestFunctionFactoryNativeReceipts(t *testing.T) { + for _, mode := range []string{"functions-success", "functions-wrong-receipt", "functions-no-receipt", "functions-cancel"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentSessionID: "native-session", ObserveToolObservations: true, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + submitter := running.(agent.FunctionResultSubmitter) + submissions := make(chan error, 2) + calls := 0 + failed := false + complete := map[string]proto.ToolObservation{} + for event := range out { + if event.ID != "run" { + t.Fatal("wrong run") + } + switch event.Type { + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + calls++ + invalid := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: true} + if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { + t.Fatal("missing content consumed call") + } + image := "https://example.invalid/image" + invalid.Content = []proto.FunctionResultContent{{Type: "input_image", ImageURL: &image}} + if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { + t.Fatal("image should fail before delivery") + } + first, second := "first-"+call.CallID, "second-"+call.CallID + value := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: call.CallID == "b", Content: []proto.FunctionResultContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} + go func() { submissions <- submitter.SubmitFunctionResult(ctx, value) }() + case proto.TypeToolCall: + var tool proto.ToolCallPayload + if err := event.DecodePayload(&tool); err != nil { + t.Fatal(err) + } + if tool.NativeItem != nil || tool.Observation == nil || tool.Observation.Kind != "function" { + t.Fatal("expected neutral observation") + } + if tool.Stage != "before" && tool.Stage != "after" { + t.Fatal("invalid shared tool stage") + } + if tool.Stage == "after" { + complete[tool.ID] = *tool.Observation + } + case proto.TypeDelta: + if mode == "functions-cancel" { + if err := running.Cancel(ctx); err != nil { + t.Fatal(err) + } + } + case proto.TypeError: + failed = true + } + } + if calls != 2 || failed != (mode != "functions-success") { + t.Fatalf("calls=%d failed=%v", calls, failed) + } + for range calls { + if err := <-submissions; (err == nil) != (mode == "functions-success") { + t.Fatalf("unexpected receipt: %v", err) + } + } + if mode == "functions-success" { + if len(complete) != 2 || complete["a"].Status != "failed" || complete["b"].Status != "completed" { + t.Fatalf("bad observations: %+v", complete) + } + for id, value := range complete { + if value.Content == nil || len(*value.Content) != 2 || *(*value.Content)[0].Text != "first-"+id || *(*value.Content)[1].Text != "second-"+id { + t.Fatal("lost ordered result") + } + } + } else if len(complete) != 0 { + t.Fatal("unconfirmed result acquired a completed observation") + } + }) + } +} + +func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { + if len(request.Functions) != 1 || request.Functions[0].Name != "lookup" || !strings.Contains(string(request.Functions[0].Parameters), `"items":{"type":"string"}`) { + os.Exit(4) + } + for _, id := range []string{"a", "b"} { + emit(bridgeEvent{Type: "function_call", Call: &proto.FunctionCallPayload{CallID: id, Name: "lookup", Arguments: json.RawMessage(`{"ids":["same"]}`)}}) + } + results := map[string]proto.FunctionResultPayload{} + for range 2 { + if !scanner.Scan() { + os.Exit(5) + } + var value proto.FunctionResultPayload + if json.Unmarshal(scanner.Bytes(), &value) != nil || value.ValidateContent() != nil || len(value.Content) != 2 { + os.Exit(6) + } + results[value.CallID] = value + } + if mode == "functions-cancel" { + emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) + time.Sleep(time.Hour) + return + } + if mode == "functions-no-receipt" { + return + } + for _, id := range []string{"b", "a"} { + delivery := results[id].DeliveryID + if mode == "functions-wrong-receipt" { + delivery = "wrong" + } + emit(bridgeEvent{Type: "function_applied", CallID: id, DeliveryID: delivery}) + } + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go new file mode 100644 index 000000000..6b9124feb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go @@ -0,0 +1,399 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestLiveClaudeSDKTextResume(t *testing.T) { + entrypoint := os.Getenv("PARSAR_CLAUDE_SDK_ENTRYPOINT") + keyFile := os.Getenv("PARSAR_CLAUDE_SDK_MINIMAX_KEY_FILE") + if entrypoint == "" || keyFile == "" { + t.Skip("real SDK/provider acceptance requires explicit entrypoint and private key file") + } + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + proofRoot := os.Getenv("PARSAR_CLAUDE_SDK_PROOF_DIR") + if !filepath.IsAbs(proofRoot) { + t.Fatal("PARSAR_CLAUDE_SDK_PROOF_DIR must be an absolute managed proof directory") + } + if err := os.MkdirAll(proofRoot, 0o700); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(proofRoot, "claude-adapter-") + if err != nil { + t.Fatal(err) + } + t.Setenv("PARSAR_HOME", root) + target, _ := url.Parse("https://api.minimax.cn/anthropic") + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(req *http.Request) { director(req); req.Host = target.Host } + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { + http.Error(w, "provider transport failed", http.StatusBadGateway) + } + var mu sync.Mutex + type providerRequest struct { + Model string `json:"model"` + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + var requests []providerRequest + var delayNext atomic.Bool + var delayedRequests atomic.Int32 + forwarder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") { + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, "request read failed", 400) + return + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + var value providerRequest + _ = json.Unmarshal(body, &value) + mu.Lock() + requests = append(requests, value) + mu.Unlock() + } + if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") && delayNext.Swap(false) { + delayedRequests.Add(1) + select { + case <-time.After(31 * time.Second): + case <-req.Context().Done(): + return + } + } + proxy.ServeHTTP(w, req) + })) + defer forwarder.Close() + config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=" + forwarder.URL, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), + "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", + "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", + }} + + // Ambient project configuration must not add a model tool or start a server. + work := filepath.Join(config.StateDir, "work") + if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o700); err != nil { + t.Fatal(err) + } + canary := filepath.Join(root, "ambient-mcp-started") + ambient, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"ambient": map[string]any{ + "command": "node", "args": []string{"-e", "require('node:fs').writeFileSync(process.argv[1], 'unexpected')", canary}, + }}}) + if err := os.WriteFile(filepath.Join(work, ".mcp.json"), ambient, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, ".claude", "settings.json"), []byte(`{"enableAllProjectMcpServers":true,"permissions":{"allow":["Bash","Read","Agent","WebSearch"]}}`), 0o600); err != nil { + t.Fatal(err) + } + type evidence struct { + ExecutionControls *proto.ExecutionControls `json:"execution_controls"` + SteeringText string `json:"steering_text,omitempty"` + SteeringWritten bool `json:"steering_written,omitempty"` + SteeringConfirmed bool `json:"steering_confirmed,omitempty"` + SteeringMilliseconds int64 `json:"steering_milliseconds,omitempty"` + SessionID string `json:"session_id"` + NodePID int `json:"node_pid"` + NativePIDs []int `json:"native_pids"` + ChildPIDs []int `json:"child_pids"` + Text string `json:"text"` + Failure string `json:"failure,omitempty"` + Events []proto.Envelope `json:"events"` + FunctionCalls int `json:"function_calls"` + AppliedResults int `json:"applied_results"` + ProviderRequests []providerRequest `json:"provider_requests"` + } + functionNonce := "function-" + uuid.NewString() + run := func(prompt, resume string, success *bool, steering ...string) evidence { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + mu.Lock() + requestStart := len(requests) + mu.Unlock() + out := make(chan proto.Envelope, 64) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Prompt: prompt, AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Answer briefly and preserve the exact verification value in the conversation. Use no tools."}} + if success != nil { + request.ObserveToolObservations = true + request.AgentOptions["system_prompt"] = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." + request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} + } + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + s := running.(*session) + defer running.Cancel(context.Background()) + proof := evidence{NodePID: s.process.Cmd.Process.Pid, ExecutionControls: request.ExecutionControls} + if len(steering) > 0 { + proof.SteeringText = steering[0] + } + type steeringResult struct { + err error + elapsed int64 + written bool + } + steeringReply := make(chan steeringResult, 1) + var steeringAt time.Time + beginSteering := func() { + if proof.SteeringText == "" || !steeringAt.IsZero() { + return + } + steeringAt = time.Now() + if success != nil { + delayNext.Store(true) + } + go func() { + callCtx, cancel := context.WithCancel(ctx) + defer cancel() + timer := time.AfterFunc(10*time.Second, cancel) + defer timer.Stop() + written := false + err := s.SteerWithReceipt(callCtx, proto.PromptSteerPayload{InputID: uuid.NewString(), Text: proof.SteeringText}, func() { written = timer.Stop() }) + steeringReply <- steeringResult{err: err, elapsed: time.Since(steeringAt).Milliseconds(), written: written} + }() + } + type children struct{ all, native []int } + observed := make(chan children, 1) + go func() { + pids := map[int]bool{} + native := map[int]bool{} + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) + for _, value := range strings.Fields(string(raw)) { + if pid, err := strconv.Atoi(value); err == nil { + pids[pid] = true + // SDK history lookup may also spawn Git helpers; identify the execution transport. + args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && + bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { + native[pid] = true + } + } + } + select { + case <-s.process.Done(): + var result children + for pid := range pids { + result.all = append(result.all, pid) + } + for pid := range native { + result.native = append(result.native, pid) + } + observed <- result + return + case <-ticker.C: + } + } + }() + done := false + for event := range out { + proof.Events = append(proof.Events, event) + switch event.Type { + case proto.TypeDelta: + if success == nil { + beginSteering() + } + case proto.TypeToolCall: + var tool proto.ToolCallPayload + if err := event.DecodePayload(&tool); err != nil { + t.Fatal(err) + } + if tool.Observation == nil || tool.Observation.Kind != "function" || tool.NativeItem != nil || (tool.Stage != "before" && tool.Stage != "after") { + t.Fatal("invalid live neutral function observation") + } + if tool.Stage == "after" { + expectedStatus := "completed" + if success != nil && !*success { + expectedStatus = "failed" + } + if tool.Observation.Status != expectedStatus || tool.Observation.Content == nil || len(*tool.Observation.Content) != 2 { + t.Fatal("live result observation lost status or content") + } + } + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + proof.FunctionCalls++ + beginSteering() + if success == nil || proof.FunctionCalls != 1 || call.Name != "lookup" { + t.Fatal("unexpected live function call") + } + first, second := functionNonce, "ordered-second-part" + if !*success { + first, second = "synthetic-current-failure", "do-not-retry" + } + value := proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: uuid.NewString(), Success: *success, Content: []proto.FunctionResultContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} + if err := s.SubmitFunctionResult(ctx, value); err != nil { + t.Fatalf("live native result receipt failed: %v; proof root %s", err, root) + } + proof.AppliedResults++ + case proto.TypeError: + var payload proto.ErrorPayload + _ = json.Unmarshal(event.Payload, &payload) + proof.Failure = payload.Error + case proto.TypeDone: + done = true + var payload proto.DonePayload + _ = json.Unmarshal(event.Payload, &payload) + proof.Text = payload.Content + proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) + select { + case <-s.process.Done(): + default: + t.Fatal("daemon Done preceded process release") + } + } + } + if !steeringAt.IsZero() { + receipt := <-steeringReply + if receipt.err != nil { + proof.Failure = "steering receipt: " + receipt.err.Error() + } else { + proof.SteeringConfirmed = true + } + proof.SteeringMilliseconds = receipt.elapsed + proof.SteeringWritten = receipt.written + } + released := <-observed + proof.NativePIDs, proof.ChildPIDs = released.native, released.all + if !done { + t.Fatal("no daemon completion before timeout") + } + for _, pid := range proof.ChildPIDs { + if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) == 0 || fields[0] != "Z" { + t.Fatalf("SDK child %d remains alive after Done", pid) + } + } + } + + mu.Lock() + proof.ProviderRequests = append([]providerRequest{}, requests[requestStart:]...) + mu.Unlock() + for _, sent := range proof.ProviderRequests { + if sent.Model != "MiniMax-M3" { + t.Fatal("unexpected provider model", sent.Model) + } + want := 0 + if success != nil { + want = 1 + } + if len(sent.Tools) != want { + t.Fatal("native tool inventory widened", sent.Tools) + } + for _, tool := range sent.Tools { + if tool.Name != "mcp__functions__lookup" { + t.Fatal("undeclared native tool", tool.Name) + } + } + } + if _, err := os.Stat(canary); !os.IsNotExist(err) { + t.Fatal("ambient MCP configuration was not excluded", err) + } + data, err := json.MarshalIndent(proof, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { + t.Fatal(err) + } + return proof + } + nonce := "sdk-adapter-" + uuid.NewString() + first := run("Remember this exact verification value and reply with it: "+nonce, "", nil) + if first.Failure != "" || first.SessionID == "" || !strings.Contains(first.Text, nonce) || len(first.NativePIDs) == 0 { + t.Fatalf("first execution failed: %+v; evidence root %s", first, root) + } + verifyMessageEvents(t, first.Events, first.Text) + second := run("Return only the exact verification value from the previous user message.", first.SessionID, nil) + if second.Failure != "" || second.SessionID != first.SessionID || !strings.Contains(second.Text, nonce) || first.NodePID == second.NodePID || len(second.NativePIDs) == 0 { + t.Fatalf("cold resume failed: %+v; evidence root %s", second, root) + } + verifyMessageEvents(t, second.Events, second.Text) + for _, a := range first.NativePIDs { + for _, b := range second.NativePIDs { + if a == b { + t.Fatal("native process was reused") + } + } + } + accepted, rejected := true, false + functionFirst := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted) + if functionFirst.Failure != "" || functionFirst.FunctionCalls != 1 || functionFirst.AppliedResults != 1 || !strings.Contains(functionFirst.Text, functionNonce) || !strings.Contains(functionFirst.Text, "ordered-second-part") { + t.Fatalf("live function failed: %+v", functionFirst) + } + functionSecond := run("Call lookup once with id 42 as a string. Report the prior verification value and both current result parts. Do not retry.", functionFirst.SessionID, &rejected) + if functionSecond.Failure != "" || functionSecond.FunctionCalls != 1 || functionSecond.AppliedResults != 1 || functionSecond.SessionID != functionFirst.SessionID || !strings.Contains(functionSecond.Text, functionNonce) || !strings.Contains(functionSecond.Text, "synthetic-current-failure") || !strings.Contains(functionSecond.Text, "do-not-retry") || functionSecond.NodePID == functionFirst.NodePID { + t.Fatalf("live function resume failed: %+v", functionSecond) + } + steeringNonce := "live-steering-" + uuid.NewString() + steered := run("Write twelve short numbered observations about trees. Use no tools.", "", nil, "Remember this additional verification value and return it verbatim: "+steeringNonce) + if steered.Failure != "" || !steered.SteeringConfirmed || !steered.SteeringWritten || !strings.Contains(steered.Text, steeringNonce) { + t.Fatalf("live steering failed: %+v", steered) + } + steeredResume := run("Return only the exact live-steering verification value from the previous conversation.", steered.SessionID, nil) + if steeredResume.Failure != "" || steeredResume.SessionID != steered.SessionID || !strings.Contains(steeredResume.Text, steeringNonce) || steeredResume.NodePID == steered.NodePID { + t.Fatalf("steered cold continuation failed: %+v", steeredResume) + } + functionSteered := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted, "Also remember and report this value: "+steeringNonce) + if functionSteered.Failure != "" || !functionSteered.SteeringConfirmed || !functionSteered.SteeringWritten || functionSteered.SteeringMilliseconds < 31000 || delayedRequests.Load() != 1 || functionSteered.FunctionCalls != 1 || functionSteered.AppliedResults != 1 || !strings.Contains(functionSteered.Text, steeringNonce) || !strings.Contains(functionSteered.Text, functionNonce) { + t.Fatalf("live function steering failed: %+v", functionSteered) + } + for _, completed := range []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered} { + verifyLiveUsageEvents(t, completed.Events) + } + mu.Lock() + before := len(requests) + mu.Unlock() + missing := run("Say hello.", uuid.NewString(), nil) + mu.Lock() + measured := append([]providerRequest{}, requests...) + mu.Unlock() + if !strings.Contains(missing.Failure, "history_unavailable") || missing.SessionID != "" || len(missing.NativePIDs) != 0 || len(measured) != before { + t.Fatalf("missing history did not fail before native/model start: %+v", missing) + } + if len(measured) < 2 { + t.Fatal("expected real model requests") + } + for _, request := range measured { + if request.Model != "MiniMax-M3" { + t.Fatalf("unexpected requested model %q", request.Model) + } + } + data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> official SDK -> real MiniMax with default typed execution controls, active input, native continuation and disabled environment/subagent tools; public API not enabled; no filesystem isolation claim", "turns": []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered}, "missing_history": missing, "model_requests": measured, "controlled_provider_delay_seconds": 31, "delayed_requests": delayedRequests.Load()}, "", " ") + if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { + t.Fatal(err) + } + t.Logf("real adapter proof: %s", filepath.Join(root, "proof.json")) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go new file mode 100644 index 000000000..e119a826c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -0,0 +1,38 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ConfigureLocal selects the qualified, dedicated Runtime layout. The shared +// localworkspace binding still authorizes every request against its Session. +func ConfigureLocal(config Config, root, workspace, network, staging string) (Config, error) { + config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") + config.Workspace = &WorkspaceConfig{ + Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network, + HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), + ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), + ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), staging}, + DependencyPath: "/usr/local/bin:/usr/bin:/bin", + } + if network != "enabled" && network != "disabled" { + return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") + } + for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { + if err := os.MkdirAll(dir, 0700); err != nil { + return Config{}, err + } + } + config.Env = nil + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if workspaceEnvName(name) { + config.Env = append(config.Env, entry) + } + } + _, _, err := workspaceEnvironment(config) + return config, err +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go new file mode 100644 index 000000000..9ad724151 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -0,0 +1,66 @@ +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.PublicDirectory = config.Workspace.Directory + config.Workspace.NetworkAccess = "enabled" + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} + req.RequireExistingNativeSession = true + start, _, err := prepare(config, req) + if err != nil || !start.RequireHistory || start.Workspace.NetworkAccess != "enabled" { + t.Fatal(start, err) + } + req.LocalEnvironment.NetworkAccess = "disabled" + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted different Runtime network policy") + } + req.LocalEnvironment.NetworkAccess = "enabled" + config.Workspace.PublicDirectory = config.Workspace.HomeDir + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted a different public workspace") + } + alias := filepath.Join(filepath.Dir(config.Workspace.Directory), "alias") + if err := os.Symlink(config.Workspace.Directory, alias); err != nil { + t.Fatal(err) + } + config.Workspace.PublicDirectory = alias + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted mutable workspace alias") + } +} + +func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { + config := workspaceFixture(t) + original := slices.Clone(config.Env) + req := workspaceRequest() + req.AgentOptions["claude_provider"] = map[string]any{"base_url": "https://provider.example/anthropic", "bearer_token": "selected-secret"} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(start) + if strings.Contains(string(raw), "selected-secret") || !slices.Equal(original, config.Env) { + t.Fatal("provider leaked or mutated shared configuration") + } + if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") { + t.Fatal("provider selection was not exclusive") + } + for _, value := range []any{nil, "secret", map[string]any{"base_url": "http://provider.example", "bearer_token": "secret"}, map[string]any{"base_url": "https://user:pass@provider.example", "bearer_token": "secret"}} { + req.AgentOptions["claude_provider"] = value + if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("unsafe provider accepted or disclosed") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go new file mode 100644 index 000000000..351803250 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go @@ -0,0 +1,133 @@ +package claudesdk + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) + +func validateMCP(req proto.PromptRequestPayload) error { + if req.MCPHTTPServers == nil { + return nil + } + if !req.DisableExecutionEnvironment || req.RemoteEnvironment != nil { + return fmt.Errorf("claudesdk: HTTP MCP requires environment:none") + } + labels := map[string]bool{} + for _, server := range *req.MCPHTTPServers { + endpoint, err := url.Parse(server.ServerURL) + if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || + err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || + strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { + return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") + } + if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { + return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") + } + labels[server.ServerLabel] = true + if server.AllowedTools != nil { + for _, name := range *server.AllowedTools { + if !mcpTool.MatchString(name) { + return fmt.Errorf("claudesdk: unsupported MCP tool allowlist") + } + } + } + } + return nil +} + +// Only generated references cross the private bridge; secrets stay in the owned +// process environment and are expanded by the native HTTP client. +type mcpHTTPServer struct { + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` +} + +func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { + if declarations == nil { + return nil, nil + } + servers := make([]mcpHTTPServer, len(*declarations)) + var env []string + for i, declaration := range *declarations { + server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} + if declaration.AllowedTools != nil { + tools := append([]string{}, (*declaration.AllowedTools)...) + server.AllowedTools = &tools + } + if declaration.BearerToken != nil { + server.BearerTokenEnvVar = "PARSAR_MCP_BEARER_" + rand.Text() + env = append(env, server.BearerTokenEnvVar+"="+*declaration.BearerToken) + } + servers[i] = server + } + return &servers, env +} + +type mcpState struct { + calls map[string]proto.ToolObservation +} + +func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(string, any)) error { + n := event.Observation + if start.MCPHTTPServers == nil || n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) || + !json.Valid(n.Output) || !json.Valid(n.Error) { + return fmt.Errorf("claudesdk: invalid MCP observation") + } + declared := false + for _, server := range *start.MCPHTTPServers { + if server.ServerLabel == n.Server && n.Name != "" && (server.AllowedTools == nil || slices.Contains(*server.AllowedTools, n.Name)) { + declared = true + break + } + } + previous, exists := m.calls[event.ID] + if !declared || (event.Stage == "before" && (exists || n.Status != "in_progress")) || + (event.Stage == "after" && (!exists || previous.Status != "in_progress" || + (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete") || + previous.Server != n.Server || previous.Name != n.Name || !bytes.Equal(previous.Arguments, n.Arguments))) || + (event.Stage != "before" && event.Stage != "after") { + return fmt.Errorf("claudesdk: inconsistent MCP observation") + } + m.calls[event.ID] = *n + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) + } + return nil +} + +func (m *mcpState) complete() bool { + for _, call := range m.calls { + if call.Status == "in_progress" { + return false + } + } + return true +} + +func (m *mcpState) close(start startRequest, emit func(string, any)) { + for id, call := range m.calls { + if call.Status != "in_progress" { + continue + } + call.Status = "incomplete" + m.calls[id] = call + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go new file mode 100644 index 000000000..b0d71e183 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -0,0 +1,85 @@ +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + tokens := []string{"first.synthetic+/==", "second-synthetic_token~"} + tools := []string{"echo.v1"} + servers := []proto.MCPHTTPServer{ + {ServerLabel: "first", ServerURL: "https://first.example/mcp", AllowedTools: &tools, BearerToken: &tokens[0]}, + {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, + {ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, + } + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + seen := map[string]bool{} + for range 2 { + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(start) + if err != nil { + t.Fatal(err) + } + for i, token := range tokens { + reference := (*start.MCPHTTPServers)[i].BearerTokenEnvVar + if !strings.HasPrefix(reference, "PARSAR_MCP_BEARER_") || seen[reference] || !slices.Contains(env, reference+"="+token) { + t.Fatal("missing exact isolated credential or reused environment reference") + } + seen[reference] = true + if _, exists := os.LookupEnv(reference); exists { + t.Fatal("credential entered parent process environment") + } + if strings.Contains(string(raw), token) || !strings.Contains(string(raw), reference) { + t.Fatal("bridge serialization contains a secret or omitted its reference") + } + } + if (*start.MCPHTTPServers)[2].BearerTokenEnvVar != "" || strings.Contains(string(raw), `"bearer_token":`) { + t.Fatal("anonymous declaration or bridge secret boundary changed") + } + tools[0] = "changed" + if (*(*start.MCPHTTPServers)[0].AllowedTools)[0] != "echo.v1" { + t.Fatal("tool selection was not copied") + } + tools[0] = "echo.v1" + if servers[0].BearerToken != &tokens[0] || *servers[0].BearerToken != tokens[0] { + t.Fatal("caller credential changed") + } + } +} + +func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { + for _, token := range []string{"", "=", " space", "space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + if _, _, err := prepare(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { + t.Fatal("invalid bearer accepted or unsafe error returned") + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatal("invalid credential wrote execution state", err) + } + } + for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://example.invalid/mcp?", "https://user:secret@example.invalid/mcp"} { + token := "synthetic-token" + servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: url, BearerToken: &token}} + if err := validateMCP(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil { + t.Fatal("unsafe authenticated endpoint accepted") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go new file mode 100644 index 000000000..2653e4401 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go @@ -0,0 +1,117 @@ +package claudesdk + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestHTTPMCPDeclaration(t *testing.T) { + for _, mode := range []string{"unrestricted", "selected", "empty", "nil-slice", "required", "auth", "url-auth", "query", "wildcard", "reserved", "duplicate", "environment"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + tools := []string{"echo"} + switch mode { + case "selected": + servers[0].AllowedTools = &tools + case "empty": + tools = []string{} + servers[0].AllowedTools = &tools + case "nil-slice": + tools = nil + servers[0].AllowedTools = &tools + case "required": + servers[0].Required = true + case "auth": + token := "private" + servers[0].BearerToken = &token + case "url-auth": + servers[0].ServerURL = "https://user:secret@example.invalid/mcp" + case "query": + servers[0].ServerURL += "?" + case "wildcard": + tools = []string{"*"} + servers[0].AllowedTools = &tools + case "reserved": + servers[0].ServerLabel = "functions" + case "duplicate": + servers = append(servers, servers[0]) + case "environment": + req.DisableExecutionEnvironment = false + } + start, _, err := prepare(config, req) + valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" + if (err == nil) != valid { + t.Fatalf("unexpected admission: %v", err) + } + if !valid { + return + } + raw, _ := json.Marshal(start) + if mode == "required" && !strings.Contains(string(raw), `"required":true`) { + t.Fatal("required initialization was discarded") + } + if (mode == "empty" || mode == "nil-slice") && !strings.Contains(string(raw), `"allowed_tools":[]`) { + t.Fatal("empty selection widened") + } + if mode == "selected" { + tools[0] = "changed" + if (*(*start.MCPHTTPServers)[0].AllowedTools)[0] != "echo" { + t.Fatal("request did not snapshot tool selection") + } + } + }) + } +} + +func TestMCPObservationLifecycle(t *testing.T) { + start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}, observeFunctions: true} + state := mcpState{calls: map[string]proto.ToolObservation{}} + var observations []proto.ToolCallPayload + emit := func(kind string, payload any) { + if kind != proto.TypeToolCall { + t.Fatal(kind) + } + observations = append(observations, payload.(proto.ToolCallPayload)) + } + before := bridgeEvent{Type: "mcp_observation", ID: "native", Stage: "before", Observation: &proto.ToolObservation{Kind: "mcp", Status: "in_progress", Name: "echo", Server: "fixture", Arguments: json.RawMessage(`{"value":7}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)}} + if err := state.receive(before, start, emit); err != nil { + t.Fatal(err) + } + if state.complete() { + t.Fatal("unfinished call completed") + } + if err := state.receive(before, start, emit); err == nil { + t.Fatal("duplicate call accepted") + } + after := before + after.Stage = "after" + n := *before.Observation + after.Observation = &n + n.Status = "completed" + n.Output = json.RawMessage(`{"content":"value","structuredContent":{"number":7}}`) + if err := state.receive(after, start, emit); err != nil { + t.Fatal(err) + } + if !state.complete() || string(observations[1].Observation.Output) != string(n.Output) { + t.Fatal("native result changed") + } + before.ID = "unfinished" + if err := state.receive(before, start, emit); err != nil { + t.Fatal(err) + } + state.close(start, emit) + if !state.complete() || observations[len(observations)-1].Observation.Status != "incomplete" { + t.Fatal("cancellation lost pending call") + } + if err := state.receive(after, start, emit); err == nil { + t.Fatal("terminal call reopened") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go b/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go new file mode 100644 index 000000000..6a3b10f7f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go @@ -0,0 +1,161 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMessageObservations(t *testing.T) { + for _, mode := range []string{"messages-success", "messages-partial", "messages-unrequested", "messages-missing-id", "messages-invalid-snapshot"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", ObserveMessages: mode != "messages-unrequested", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + var events []proto.Envelope + var failure bool + var done *proto.DonePayload + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeError: + failure = true + case proto.TypeDone: + done = &proto.DonePayload{} + if err := json.Unmarshal(event.Payload, done); err != nil { + t.Fatal(err) + } + } + } + if done == nil || failure != (mode != "messages-success") { + t.Fatalf("unexpected outcome: %+v", events) + } + switch mode { + case "messages-success": + verifyMessageEvents(t, events, "partialfinal") + if done.Content != "partialfinal" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { + t.Fatalf("bad Done: %+v", done) + } + case "messages-partial": + if done.Content != "partial" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatalf("bad partial Done: %+v", done) + } + if len(events) != 4 || events[0].Type != proto.TypeOutputMessage || events[1].Type != proto.TypeDelta { + t.Fatalf("lost partial output: %+v", events) + } + case "messages-unrequested", "messages-missing-id": + if len(events) != 2 { + t.Fatalf("invalid bridge observation escaped: %+v", events) + } + } + }) + } +} + +func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) { + message := func(id, status string, text *string) { + emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: id, Status: status, Text: text}}) + } + if mode == "messages-missing-id" { + emit(bridgeEvent{Type: "delta", Delta: "unidentified"}) + return + } + if mode != "messages-unrequested" && !request.ObserveMessages { + os.Exit(4) + } + message("message-1", "in_progress", nil) + if mode == "messages-unrequested" { + return + } + emit(bridgeEvent{Type: "delta", ItemID: "message-1", Delta: "partial"}) + if mode == "messages-partial" { + emit(bridgeEvent{Type: "error", Code: "execution_failed"}) + return + } + if mode == "messages-invalid-snapshot" { + message("message-1", "completed", nil) + return + } + text := "partial" + message("message-1", "completed", &text) + message("message-2", "in_progress", nil) + emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "fi"}) + emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "nal"}) + text = "final" + message("message-2", "completed", &text) + emit(bridgeEvent{Type: "result", Text: "partialfinal", SessionID: request.Resume}) +} + +func verifyMessageEvents(t *testing.T, events []proto.Envelope, want string) { + t.Helper() + type observation struct { + text string + chunks int + completed bool + } + messages := map[string]*observation{} + var completed []string + var sequence uint64 + for _, event := range events { + switch event.Type { + case proto.TypeOutputMessage: + var value proto.OutputMessagePayload + if err := json.Unmarshal(event.Payload, &value); err != nil { + t.Fatal(err) + } + if value.ID == "" { + t.Fatal("missing message identity") + } + if value.Status == "in_progress" { + if messages[value.ID] != nil || value.Text != nil { + t.Fatal("duplicate or invalid message start") + } + messages[value.ID] = &observation{} + } else { + state := messages[value.ID] + if value.Status != "completed" || state == nil || state.completed || value.Text == nil || state.text != *value.Text { + t.Fatalf("incorrect completion snapshot: %+v, state %+v", value, state) + } + state.completed = true + completed = append(completed, *value.Text) + } + case proto.TypeDelta: + var value proto.DeltaPayload + if err := json.Unmarshal(event.Payload, &value); err != nil { + t.Fatal(err) + } + state := messages[value.ItemID] + if state == nil || state.completed || value.Sequence != sequence+1 { + t.Fatalf("unmatched/out-of-order delta: %+v", value) + } + sequence = value.Sequence + state.text += value.Delta + state.chunks++ + } + } + if len(messages) == 0 || strings.Join(completed, "") != want || sequence == 0 { + t.Fatalf("incomplete message stream: chunks=%d completed=%q want=%q", sequence, strings.Join(completed, ""), want) + } + for id, state := range messages { + if !state.completed { + t.Fatalf("message %s did not complete", id) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go new file mode 100644 index 000000000..b0e15ad8d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/options.go @@ -0,0 +1,147 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type Config struct { + Node string + Entrypoint string + StateDir string + Env []string + Workspace *WorkspaceConfig +} + +type startRequest struct { + Type string `json:"type"` + Prompt string `json:"prompt,omitempty"` + Model string `json:"model"` + SystemPrompt string `json:"system_prompt"` + Cwd string `json:"cwd"` + Resume string `json:"resume,omitempty"` + ObserveMessages bool `json:"observe_messages,omitempty"` + Functions []proto.FunctionTool `json:"functions,omitempty"` + MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` + Workspace *workspaceProfile `json:"workspace,omitempty"` + RequireHistory bool `json:"require_history,omitempty"` + observeFunctions bool +} + +func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { + if req.RunID == "" || strings.TrimSpace(req.Prompt) == "" { + return startRequest{}, nil, fmt.Errorf("claudesdk: run id and prompt are required") + } + start, env, err := prepareConfiguration(config, req) + if err != nil { + return startRequest{}, nil, err + } + start.Prompt = req.Prompt + return start, env, nil +} + +func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { + start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} + fail := func(reason string) (startRequest, []string, error) { + return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) + } + if len(req.Attachments) > 0 || req.WorkspaceAuthoring || req.ObserveTools { + return fail("requested capability is not available in the private SDK adapter") + } + if err := validateMCP(req); err != nil { + return startRequest{}, nil, err + } + // Search is disabled by the fixed native tool profile. Medium selects the + // SDK's default text generation; it has no native verbosity-level option. + if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { + return fail("execution controls require disabled web search and medium text verbosity") + } + // The fixed SDK profile already excludes all built-in tools and subagents. + // Both restriction flags are supported; omitting them does not widen the profile. + if err := validateFunctions(req.FunctionTools); err != nil { + return startRequest{}, nil, err + } + var provider []string + for name, raw := range req.AgentOptions { + if name == "claude_provider" { + var err error + provider, err = providerEnvironment(raw) + if err != nil { + return startRequest{}, nil, err + } + continue + } + if name == "system_prompt" && raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return fail("model and system_prompt options must be strings") + } + switch name { + case "model": + start.Model = value + case "system_prompt": + start.SystemPrompt = value + default: + return fail("unsupported option: " + name) + } + } + if strings.TrimSpace(start.Model) == "" { + return fail("model is required") + } + if !filepath.IsAbs(config.Entrypoint) { + return fail("SDK entrypoint must be absolute") + } + config.Env = withProvider(config.Env, provider) + if config.Workspace != nil { + profile, env, err := prepareWorkspace(config, req) + if err != nil { + return startRequest{}, nil, err + } + start.Workspace = profile + start.Cwd = workspaceCwd(config.Workspace) + return start, env, nil + } + if req.LocalEnvironment != nil || req.RequireExistingNativeSession { + return fail("local execution and history recovery require a dedicated workspace") + } + root, err := paths.Root() + if err != nil { + return startRequest{}, nil, err + } + relative, err := filepath.Rel(root, config.StateDir) + if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fail("SDK state must be in a managed runtime subdirectory") + } + start.Cwd = req.WorkDir + if start.Cwd == "" { + start.Cwd = filepath.Join(config.StateDir, "work") + } + if strings.HasPrefix(start.Cwd, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return startRequest{}, nil, err + } + start.Cwd = filepath.Join(homeDir, strings.TrimPrefix(start.Cwd, "~/")) + } + if !filepath.IsAbs(start.Cwd) { + return fail("work_dir must be absolute or start with ~/") + } + for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return startRequest{}, nil, err + } + } + env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) + env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") + var mcpEnv []string + start.MCPHTTPServers, mcpEnv = prepareMCPHTTP(req.MCPHTTPServers) + env = append(env, mcpEnv...) + return start, env, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options_test.go b/apps/parsar-daemon/internal/agent/claudesdk/options_test.go new file mode 100644 index 000000000..8607300d5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/options_test.go @@ -0,0 +1,34 @@ +package claudesdk + +import ( + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestNullableSystemPrompt(t *testing.T) { + for _, tc := range []struct { + name string + options map[string]any + want string + reject bool + }{ + {"omitted", map[string]any{"model": "test-model"}, "", false}, + {"null", map[string]any{"model": "test-model", "system_prompt": nil}, "", false}, + {"empty", map[string]any{"model": "test-model", "system_prompt": ""}, "", false}, + {"text", map[string]any{"model": "test-model", "system_prompt": "instructions"}, "instructions", false}, + {"null-model", map[string]any{"model": nil}, "", true}, + {"null-unknown", map[string]any{"model": "test-model", "unsupported": nil}, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + start, _, err := prepare(config, proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentOptions: tc.options}) + if (err != nil) != tc.reject || (!tc.reject && start.SystemPrompt != tc.want) { + t.Fatalf("system prompt %q, error %v", start.SystemPrompt, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go new file mode 100644 index 000000000..1a7eab652 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go @@ -0,0 +1,177 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type prepared struct { + mu sync.Mutex + session *session + ready chan struct{} + started chan struct{} + binding *preparedStart + closed bool + failure error +} + +type preparedStart struct { + runID string + prompt string + out chan<- proto.Envelope +} + +var _ agent.PreparedCancellation = (*prepared)(nil) + +// NewPreparationFactory retains a trusted workspace process without submitting model input. +func NewPreparationFactory(config Config) agent.PreparationFactory { + return func(owner context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if owner == nil { + owner = context.Background() + } + if config.Workspace == nil || req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.ObserveSubagentIdentities { + return nil, fmt.Errorf("claudesdk: preparation requires workspace configuration without input, conversation or subagents") + } + snapshot := config + snapshot.Env = slices.Clone(config.Env) + workspace := *config.Workspace + workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) + snapshot.Workspace = &workspace + start, env, err := prepareConfiguration(snapshot, req) + if err != nil { + return nil, err + } + start.Type = "prepare" + info, err := CheckRuntime(owner, snapshot) + if err != nil || !info.supportsWorkspacePreparation() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace preparation") + } + if start.observeFunctions && !info.supportsWorkspaceCommands() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace command observations") + } + if len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace functions") + } + s, err := launch(owner, snapshot, start, env) + if err != nil { + return nil, err + } + s.reads.supported = slices.Contains(info.Features, "workspace_read") + s.directories.supported = slices.Contains(info.Features, "workspace_directory") + p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} + go s.run(owner, "", start, nil, p) + select { + case <-p.ready: + if owner.Err() == nil { + return p, nil + } + _ = p.Close() + return nil, owner.Err() + case <-s.settled: + return nil, p.failure + } + } +} + +// Start transfers ownership once; ctx bounds this operation, not the Session lifetime. +func (p *prepared) Start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (agent.Session, error) { + if ctx == nil { + ctx = context.Background() + } + p.mu.Lock() + if p.closed || p.binding != nil { + p.mu.Unlock() + return nil, fmt.Errorf("claudesdk: preparation is no longer available") + } + var err error + if strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" || out == nil { + err = fmt.Errorf("claudesdk: start requires a run identity, prompt and output channel") + } else if ctx.Err() != nil { + err = ctx.Err() + } else if p.session.process.Context().Err() != nil { + err = p.session.process.Context().Err() + } else { + select { + case <-p.session.process.Done(): + err = fmt.Errorf("claudesdk: prepared process has exited") + case <-p.session.settled: + err = fmt.Errorf("claudesdk: preparation has ended") + default: + } + } + if err != nil { + p.closed = true + p.mu.Unlock() + _ = p.session.Cancel(context.Background()) + return nil, err + } + p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} + close(p.started) + p.mu.Unlock() + return p.session, nil +} + +// Close settles unused ownership and is inert after transfer. +func (p *prepared) Close() error { + p.mu.Lock() + if p.binding != nil { + p.mu.Unlock() + return nil + } + p.closed = true + p.mu.Unlock() + return p.session.Cancel(context.Background()) +} + +// Cancel follows the resource across transfer and waits for the existing output settlement. +func (p *prepared) Cancel(ctx context.Context) error { + p.mu.Lock() + p.closed = true + p.mu.Unlock() + return p.session.Cancel(ctx) +} + +func (p *prepared) CancellationOutcome() proto.DonePayload { + return p.session.CancellationOutcome() +} + +func (p *prepared) awaitStart(scanner *bridgeOutput, failure error) (*preparedStart, error) { + if failure == nil { + if scanner.Scan() { + var event bridgeEvent + if json.Unmarshal(scanner.Bytes(), &event) != nil { + failure = fmt.Errorf("claudesdk: invalid SDK bridge output") + } else if event.Type == "error" { + failure = bridgeFailure(event.Code) + } else if event.Type != "prepared" { + failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") + } + } else { + failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") + } + } + if failure != nil { + p.session.process.Cancel() + return nil, failure + } + close(p.ready) + select { + case <-p.started: + case <-p.session.process.Context().Done(): + case <-p.session.process.Done(): + } + p.mu.Lock() + defer p.mu.Unlock() + if p.binding != nil { + return p.binding, nil + } + p.closed = true + return nil, fmt.Errorf("claudesdk: preparation ended before start") +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go new file mode 100644 index 000000000..fd39af809 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -0,0 +1,131 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func preparationFixture(t *testing.T, mode string) Config { + t.Helper() + config := workspaceFixture(t) + binary, err := filepath.EvalSymlinks(os.Args[0]) + if err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\nexport GO_CLAUDE_PREPARATION_HELPER=1 SDK_HELPER_MODE='" + mode + "' GORACE=atexit_sleep_ms=0\nexec '" + strings.ReplaceAll(binary, "'", "'\\''") + "' \"$@\"\n" + if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return config +} + +func preparationRequest() proto.PromptRequestPayload { + req := workspaceRequest() + req.RunID, req.Prompt = "", "" + req.AgentSessionID = "native-session" + return req +} + +func runPreparationHelper() { + mode := os.Getenv("SDK_HELPER_MODE") + if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { + features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "workspace_read", "workspace_directory"} + if mode == "old-runtime" { + features = []string{"workspace_tools"} + } else if mode == "old-command-runtime" { + features = []string{"workspace_tools", "workspace_prepare"} + } + _ = json.NewEncoder(os.Stdout).Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 1, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture", Features: features}) + return + } + state := os.Getenv("CLAUDE_CONFIG_DIR") + _ = os.WriteFile(filepath.Join(state, "launched"), nil, 0o600) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + os.Exit(2) + } + raw := append([]byte{}, scanner.Bytes()...) + _ = os.WriteFile(filepath.Join(state, "prepare.json"), raw, 0o600) + var fields map[string]json.RawMessage + var request startRequest + if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(raw, &request) != nil || request.Type != "prepare" || fields["prompt"] != nil || fields["run_id"] != nil || request.Workspace == nil { + os.Exit(3) + } + emit := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + if mode == "history-missing" { + emit(bridgeEvent{Type: "error", Code: "history_unavailable"}) + return + } + if mode == "invalid-receipt" { + emit(bridgeEvent{Type: "delta", Delta: "unexpected model work"}) + time.Sleep(time.Hour) + return + } + if mode == "delayed-ready" { + for { + if _, err := os.Stat(filepath.Join(state, "ready")); err == nil { + break + } + time.Sleep(time.Millisecond) + } + } + emit(bridgeEvent{Type: "prepared"}) + if strings.HasPrefix(mode, "directory-") { + runWorkspaceDirectoryHelper(scanner, state, mode) + return + } + if strings.HasPrefix(mode, "read-") { + runWorkspaceReadHelper(scanner, state, mode) + return + } + if !scanner.Scan() { + return + } + raw = append([]byte{}, scanner.Bytes()...) + _ = os.WriteFile(filepath.Join(state, "start.json"), raw, 0o600) + fields = nil + if json.Unmarshal(raw, &fields) != nil || len(fields) != 2 || string(fields["type"]) != `"start"` || string(fields["prompt"]) != `"hello"` { + os.Exit(4) + } + if mode == "cancellation" { + runCancellationHelper(request, "cancellation-wait", emit) + return + } + if strings.HasPrefix(mode, "commands") { + runCommandsHelper(request, mode, emit) + return + } + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + emit(bridgeEvent{Type: "delta", Delta: "partial"}) + emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "completed"}) + _ = os.WriteFile(filepath.Join(state, "released"), nil, 0o600) +} + +func waitPreparationFile(t *testing.T, path string) []byte { + t.Helper() + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + if raw, err := os.ReadFile(path); err == nil && json.Valid(raw) { + return raw + } + select { + case <-deadline.C: + t.Fatalf("timed out waiting for %s", filepath.Base(path)) + case <-ticker.C: + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go new file mode 100644 index 000000000..ae25a7991 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -0,0 +1,321 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { + config := preparationFixture(t, "delayed-ready") + req := preparationRequest() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + result := make(chan agent.Prepared, 1) + failed := make(chan error, 1) + go func() { + p, err := NewPreparationFactory(config)(ctx, req) + if err != nil { + failed <- err + return + } + result <- p + }() + raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) + select { + case <-result: + t.Fatal("preparation returned before its native receipt") + case err := <-failed: + t.Fatal(err) + default: + } + if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { + t.Fatal(err) + } + var preparedResource agent.Prepared + select { + case preparedResource = <-result: + case err := <-failed: + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + p := preparedResource.(*prepared) + defer p.Cancel(context.Background()) + pid := p.session.process.Cmd.Process.Pid + if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + t.Fatal("preparation submitted input") + } + var frozen startRequest + if err := json.Unmarshal(raw, &frozen); err != nil { + t.Fatal(err) + } + if frozen.Model != "fixture" || frozen.Resume != "native-session" || frozen.Workspace == nil || frozen.Prompt != "" { + t.Fatal("configuration-only request was not retained") + } + config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" + config.Workspace.Directory = "/changed" + config.Workspace.ProtectedDirs[0] = "/changed" + req.AgentOptions["model"] = "changed" + req.AgentSessionID = "changed" + out := make(chan proto.Envelope, 16) + operation, stopOperation := context.WithCancel(ctx) + s, err := p.Start(operation, "actual-run", "hello", out) + stopOperation() + if err != nil { + t.Fatal(err) + } + if s != p.session || s.(*session).process.Cmd.Process.Pid != pid { + t.Fatal("Start replaced the prepared native process") + } + if err := p.Close(); err != nil { + t.Fatal(err) + } + if _, err := p.Start(ctx, "duplicate", "hello", make(chan proto.Envelope, 8)); err == nil { + t.Fatal("duplicate Start was accepted") + } + var done proto.DonePayload + for event := range out { + if event.ID != "actual-run" || event.Type == proto.TypeError { + t.Fatal("lost execution identity or owner lifetime", event.Type) + } + if event.Type == proto.TypeDone { + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + if done.Content != "completed" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("prepared execution lost ordinary output or frozen resume", done) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + t.Fatal("completion preceded process release", err) + } +} + +func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { + for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "none", "functions", "mcp", "tools", "controls", "old-runtime"} { + t.Run(name, func(t *testing.T) { + config := preparationFixture(t, name) + req := preparationRequest() + switch name { + case "run": + req.RunID = "unexpected" + case "prompt": + req.Prompt = "unexpected" + case "conversation": + req.ConversationID = "product" + case "attachments": + req.Attachments = []proto.PromptAttachment{{Kind: "image"}} + case "authoring": + req.WorkspaceAuthoring = true + case "subagents": + req.ObserveSubagentIdentities = true + case "workspace-missing": + config.Workspace = nil + case "none": + req.DisableExecutionEnvironment = true + case "functions": + req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} + case "mcp": + req.MCPHTTPServers = &[]proto.MCPHTTPServer{} + case "tools": + req.ObserveTools = true + case "controls": + req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} + } + if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { + t.Fatal("invalid preparation was accepted") + } + if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + t.Fatal("rejection launched native preparation") + } + }) + } +} + +func TestPreparationFailureAndUnusedRelease(t *testing.T) { + for _, mode := range []string{"history-missing", "invalid-receipt", "close", "owner-cancel", "native-exit", "invalid-start", "cancelled-start"} { + t.Run(mode, func(t *testing.T) { + config := preparationFixture(t, mode) + owner, stop := context.WithCancel(t.Context()) + defer stop() + resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + if mode == "history-missing" || mode == "invalid-receipt" { + if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { + t.Fatal("preparation failure was lost", err) + } + return + } + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + switch mode { + case "close": + err = p.Close() + case "owner-cancel": + stop() + case "native-exit": + err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) + case "invalid-start", "cancelled-start": + operation, cancel := context.WithCancel(t.Context()) + prompt := "" + if mode == "cancelled-start" { + prompt = "hello" + cancel() + } + _, startErr := p.Start(operation, "run", prompt, make(chan proto.Envelope, 8)) + cancel() + if startErr == nil { + t.Fatal("invalid or cancelled Start succeeded") + } + } + if err != nil { + t.Fatal(err) + } + select { + case <-p.session.settled: + case <-time.After(5 * time.Second): + t.Fatal("unused process was not settled") + } + if _, err := p.Start(t.Context(), "late", "hello", make(chan proto.Envelope, 8)); err == nil { + t.Fatal("released preparation was reusable") + } + if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { + t.Fatal("unstarted process fabricated an execution outcome", got) + } + }) + } +} + +func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { + config := preparationFixture(t, "cancellation") + owner, stop := context.WithTimeout(t.Context(), 10*time.Second) + defer stop() + resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + out := make(chan proto.Envelope) + operation, stopOperation := context.WithCancel(owner) + if _, err := p.Start(operation, "run", "hello", out); err != nil { + t.Fatal(err) + } + stopOperation() + if err := p.Close(); err != nil { + t.Fatal(err) + } + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("operation cancellation or Close cancelled the Session") + } + short, cancel := context.WithTimeout(owner, 30*time.Millisecond) + err = p.Cancel(short) + cancel() + if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { + t.Fatal("pending output drain reported settled ownership", err) + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := p.Cancel(owner); err != nil { + t.Fatal(err) + } + got := p.CancellationOutcome() + if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("cancellation across transfer lost observed output", got) + } + for range out { + } +} + +func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { + for _, cancelResource := range []bool{false, true} { + for range 6 { + config := preparationFixture(t, "success") + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + out := make(chan proto.Envelope, 16) + var running agent.Session + var startErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); running, startErr = p.Start(t.Context(), "run", "hello", out) }() + go func() { + defer wg.Done() + if cancelResource { + _ = p.Cancel(t.Context()) + } else { + _ = p.Close() + } + }() + wg.Wait() + if startErr == nil { + if running == nil { + t.Fatal("successful transfer lost its Session") + } + for range out { + } + } + if err := p.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + select { + case <-p.session.process.Done(): + default: + t.Fatal("race left the owned process alive") + } + } + } +} + +func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { + config := preparationFixture(t, "success") + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + results := make(chan bool, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + out := make(chan proto.Envelope, 16) + _, err := p.Start(t.Context(), "run", "hello", out) + results <- err == nil + if err == nil { + for event := range out { + if event.Type == proto.TypeError { + t.Error("losing Start cancelled the transferred Session") + } + } + } + }() + } + wg.Wait() + if first, second := <-results, <-results; first == second { + t.Fatal("concurrent Start did not produce exactly one owner") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go new file mode 100644 index 000000000..45f6b3621 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/provider.go @@ -0,0 +1,44 @@ +package claudesdk + +import ( + "fmt" + "net/url" + "strings" +) + +// Provider options are transient operator input, never public Agent fields or +// native tool environment. The workspace sandbox removes these variables. +func providerEnvironment(value any) ([]string, error) { + fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") } + options, ok := value.(map[string]any) + if !ok || len(options) != 2 { + return fail() + } + base, ok := options["base_url"].(string) + if !ok { + return fail() + } + token, ok := options["bearer_token"].(string) + if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\x00\r\n") { + return fail() + } + u, err := url.Parse(base) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fail() + } + return []string{"ANTHROPIC_BASE_URL=" + base, "ANTHROPIC_AUTH_TOKEN=" + token}, nil +} + +func withProvider(env, provider []string) []string { + if provider == nil { + return env + } + out := make([]string, 0, len(env)+len(provider)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" { + out = append(out, entry) + } + } + return append(out, provider...) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go new file mode 100644 index 000000000..30be6bd2b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -0,0 +1,116 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" +) + +// RuntimeInfo describes a successful local probe, not provider authentication or +// execution capability. Versions are checked against the installed pinned manifest. +type RuntimeInfo struct { + Type string `json:"type"` + Protocol int `json:"protocol"` + Node string `json:"node"` + SDK string `json:"sdk"` + MCP string `json:"mcp"` + Native string `json:"native"` + Features []string `json:"features"` +} + +func (info RuntimeInfo) SupportsHTTPMCP() bool { + return slices.Contains(info.Features, "mcp_http_tools") +} + +func (info RuntimeInfo) SupportsHTTPMCPBearer() bool { + return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_bearer_auth") +} + +func (info RuntimeInfo) SupportsHTTPMCPRequired() bool { + return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_required") +} + +func (info RuntimeInfo) supportsWorkspace() bool { + return slices.Contains(info.Features, "workspace_tools") +} + +func (info RuntimeInfo) supportsWorkspacePreparation() bool { + return info.supportsWorkspace() && slices.Contains(info.Features, "workspace_prepare") +} + +func (info RuntimeInfo) supportsWorkspaceCommands() bool { + return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "workspace_command_observations") +} + +func (info RuntimeInfo) SupportsLocalRuntime() bool { + return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1") +} + +func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { + return info.SupportsLocalRuntime() && slices.Contains(info.Features, "workspace_functions") +} + +// CheckRuntime checks the packaged companion and exact execution entrypoint. +// It does not create Session state, register an engine or make a model request. +func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { + if !filepath.IsAbs(config.Entrypoint) { + return RuntimeInfo{}, fmt.Errorf("claudesdk: SDK entrypoint must be absolute") + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + binary := config.Node + if binary == "" { + binary = "node" + } + env := append(append([]string{}, os.Environ()...), config.Env...) + if config.Workspace != nil { + var err error + _, env, err = workspaceEnvironment(config) + if err != nil { + return RuntimeInfo{}, err + } + } + process, err := clirunner.Start(clirunner.StartOptions{ + Parent: ctx, Binary: binary, + Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, + Dir: filepath.Dir(config.Entrypoint), + Env: env, + OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + }) + if err != nil { + return RuntimeInfo{}, fmt.Errorf("claudesdk: cannot start runtime check: %w", err) + } + defer process.Cancel() + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, process.Stderr); close(stderrDone) }() + const maxReport = 16 * 1024 + raw, readErr := io.ReadAll(io.LimitReader(process.Stdout, maxReport+1)) + if readErr != nil || len(raw) > maxReport { + process.Cancel() + } + _, _ = io.Copy(io.Discard, process.Stdout) + <-stderrDone + waitErr := process.Wait() + if ctx.Err() != nil { + return RuntimeInfo{}, fmt.Errorf("claudesdk: runtime check: %w", ctx.Err()) + } + if readErr != nil || len(raw) > maxReport || waitErr != nil { + return RuntimeInfo{}, fmt.Errorf("claudesdk: runtime check failed") + } + var info RuntimeInfo + if json.Unmarshal(raw, &info) != nil || info.Type != "runtime_ready" || info.Protocol != 1 || + info.Node == "" || info.SDK == "" || info.MCP == "" || info.Native == "" { + return RuntimeInfo{}, fmt.Errorf("claudesdk: invalid runtime readiness report") + } + return info, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go new file mode 100644 index 000000000..7301aa6d7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go @@ -0,0 +1,151 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const readyReport = `{"type":"runtime_ready","protocol":1,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` + +func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", + }} + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} + if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { + t.Fatalf("unqualified runtime executed required MCP: %v", err) + } +} + +func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready", "GORACE=atexit_sleep_ms=0", + }} + info, err := CheckRuntime(t.Context(), config) + if err != nil || info.SupportsHTTPMCP() { + t.Fatal("old runtime acquired MCP support", err) + } + info.Features = []string{"mcp_http_tools"} + if !info.SupportsHTTPMCP() { + t.Fatal("runtime feature not recognized") + } + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} + if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { + t.Fatalf("old runtime was not rejected before execution: %v", err) + } +} + +func TestRuntimeReadiness(t *testing.T) { + for _, mode := range []string{"ready", "malformed", "wrong-protocol", "missing-version", "multiple", "failed", "oversized"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=" + mode, "GORACE=atexit_sleep_ms=0", + }} + result, err := CheckRuntime(context.Background(), config) + if mode == "ready" { + if err != nil || result.SDK != "0.3.269" || result.Native != "2.1.269 (Claude Code)" { + t.Fatalf("unexpected readiness: %+v, %v", result, err) + } + } else if err == nil || strings.Contains(err.Error(), "private-diagnostic") { + t.Fatalf("failure was accepted or leaked diagnostics: %v", err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatal("probe created execution state") + } + }) + } +} + +func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", + }} + token := "private-fixture-token" + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} + if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { + t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) + } +} + +func TestRuntimeReadinessRejectsPathsAndMissingNode(t *testing.T) { + for _, config := range []Config{ + {Node: "must-not-start", Entrypoint: "relative/main.js"}, + {Node: filepath.Join(t.TempDir(), "missing-node"), Entrypoint: filepath.Join(t.TempDir(), "main.js")}, + } { + if _, err := CheckRuntime(context.Background(), config); err == nil { + t.Fatal("accepted invalid installation") + } + } +} + +func TestRuntimeReadinessCancellationReleasesProbe(t *testing.T) { + root := t.TempDir() + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=wait", "GORACE=atexit_sleep_ms=0", + }} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + started := time.Now() + _, err := CheckRuntime(ctx, config) + if !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > 2*time.Second { + t.Fatalf("probe did not release on deadline: %v", err) + } +} + +func runReadinessHelper() { + if len(os.Args) != 3 || filepath.Base(os.Args[1]) != "runtime_check.js" || filepath.Base(os.Args[2]) != "main.js" { + os.Exit(4) + } + // Drain a large stderr stream without returning any of it to the caller. + _, _ = fmt.Fprint(os.Stderr, strings.Repeat("private-diagnostic", 8192)) + switch os.Getenv("READINESS_MODE") { + case "ready": + _, _ = fmt.Fprintln(os.Stdout, readyReport) + case "ready-http-mcp": + for _, value := range os.Environ() { + if strings.HasPrefix(value, "PARSAR_MCP_BEARER_") { + os.Exit(5) + } + } + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":1`, `"protocol":1,"features":["mcp_http_tools"]`, 1)) + case "malformed": + _, _ = fmt.Fprintln(os.Stdout, "not-json") + case "wrong-protocol": + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":1`, `"protocol":2`, 1)) + case "missing-version": + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"mcp":"1.30.0"`, `"mcp":""`, 1)) + case "multiple": + _, _ = fmt.Fprintln(os.Stdout, readyReport+"\n"+readyReport) + case "failed": + _, _ = fmt.Fprintln(os.Stdout, readyReport) + os.Exit(2) + case "oversized": + _, _ = fmt.Fprintln(os.Stdout, strings.Repeat("x", 32*1024)) + time.Sleep(time.Minute) + case "wait": + time.Sleep(time.Minute) + default: + os.Exit(3) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go new file mode 100644 index 000000000..0d621380a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go @@ -0,0 +1,55 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { + for _, test := range []struct { + name string + environment, subagents bool + controls *proto.ExecutionControls + }{ + {"environment", true, false, nil}, {"subagents", false, true, nil}, {"both", true, true, nil}, + {"execution-controls", true, true, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "restricted-run", Prompt: "hello", AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + done := false + for event := range out { + if event.Type == proto.TypeError { + t.Fatal("restricted execution failed", string(event.Payload)) + } + if event.Type == proto.TypeDone { + var payload proto.DonePayload + if err := event.DecodePayload(&payload); err != nil || payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { + t.Fatal(payload, err) + } + done = true + } + } + if !done { + t.Fatal("restricted execution did not complete") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go new file mode 100644 index 000000000..64d54e01b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -0,0 +1,307 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type session struct { + reads workspaceReadState + directories workspaceDirectoryState + process *clirunner.Process + writeMu sync.Mutex + functions functionState + steering steeringState + settled chan struct{} + outcome proto.DonePayload +} + +func NewFactory(config Config) agent.Factory { + return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + if ctx == nil { + ctx = context.Background() + } + if out == nil { + return nil, fmt.Errorf("claudesdk: output channel is required") + } + if config.Workspace != nil { + runID, prompt := req.RunID, req.Prompt + if strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" { + return nil, fmt.Errorf("claudesdk: run id and prompt are required") + } + req.RunID, req.Prompt = "", "" + prepared, err := NewPreparationFactory(config)(ctx, req) + if err != nil { + return nil, err + } + defer prepared.Close() + return prepared.Start(ctx, runID, prompt, out) + } + start, env, err := prepare(config, req) + if err != nil { + return nil, err + } + if start.MCPHTTPServers != nil { + info, err := CheckRuntime(ctx, config) + if err != nil || !info.SupportsHTTPMCP() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support HTTP MCP") + } + for _, server := range *start.MCPHTTPServers { + if server.Required && !info.SupportsHTTPMCPRequired() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") + } + if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") + } + } + } + s, err := launch(ctx, config, start, env) + if err != nil { + return nil, err + } + go s.run(ctx, req.RunID, start, out, nil) + return s, nil + } +} + +type bridgeEvent struct { + InputID string `json:"input_id"` + ResultID string `json:"result_id"` + Usage json.RawMessage `json:"usage,omitempty"` + Type string `json:"type"` + Delta string `json:"delta"` + SessionID string `json:"session_id"` + Text string `json:"text"` + Code string `json:"code"` + ItemID string `json:"item_id"` + Message *proto.OutputMessagePayload `json:"message"` + Call *proto.FunctionCallPayload `json:"call"` + CallID string `json:"call_id"` + DeliveryID string `json:"delivery_id"` + ID string `json:"id"` + Stage string `json:"stage"` + Observation *proto.ToolObservation `json:"observation"` +} + +func (s *session) run(ctx context.Context, runID string, start startRequest, out chan<- proto.Envelope, prepared *prepared) { + defer func() { + if out != nil { + close(out) + } + }() + defer s.stopFunctions() + defer s.stopSteering() + defer s.stopWorkspaceReads() + defer s.stopWorkspaceDirectories() + emit := func(kind string, payload any) { + event, err := proto.NewEnvelope(kind, runID, payload) + if err != nil { + return + } + // Cancellation must drain native output even if the event consumer stops. + // Terminal publication follows settlement and cannot hold up Cancel. + var stopping <-chan struct{} + if kind != proto.TypeError && kind != proto.TypeDone { + stopping = s.process.Context().Done() + } + // Preserve drained observations when the consumer can accept them immediately. + select { + case out <- event: + return + default: + } + select { + case out <- event: + case <-ctx.Done(): + case <-stopping: + } + } + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, s.process.Stderr); close(stderrDone) }() + var failure error + if err := json.NewEncoder(s.process.Stdin).Encode(start); err != nil { + failure = fmt.Errorf("claudesdk: cannot submit SDK input") + s.process.Cancel() + } + scanner := s.bridgeOutput() + if prepared != nil { + binding, err := prepared.awaitStart(scanner, failure) + if binding == nil { + prepared.failure = s.drain(scanner, stderrDone, err) + close(s.settled) + return + } + runID, out = binding.runID, binding.out + s.writeMu.Lock() + err = json.NewEncoder(s.process.Stdin).Encode(struct { + Type string `json:"type"` + Prompt string `json:"prompt"` + }{Type: "start", Prompt: binding.prompt}) + s.writeMu.Unlock() + if err != nil { + failure = fmt.Errorf("claudesdk: cannot submit SDK input") + s.process.Cancel() + } + } + var content strings.Builder + var result *bridgeEvent + var usage proto.Usage + var usageSession string + usageIDs := map[string]bool{} + var sequence uint64 + terminal := false + mcp := mcpState{calls: map[string]proto.ToolObservation{}} + commands := commandState{calls: map[string]proto.ToolObservation{}} + for scanner.Scan() { + var event bridgeEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || terminal { + failure = fmt.Errorf("claudesdk: invalid SDK bridge output") + s.process.Cancel() + break + } + switch event.Type { + case "command_observation": + if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { + failure = err + s.process.Cancel() + } + case "mcp_observation": + if err := mcp.receive(event, start, emit); err != nil { + failure = err + s.process.Cancel() + } + case "delta": + if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { + failure = fmt.Errorf("claudesdk: invalid message delta identity") + s.process.Cancel() + break + } + content.WriteString(event.Delta) + sequence++ + emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) + case "output_message": + message := event.Message + if !start.ObserveMessages || message == nil || message.ID == "" || + (message.Status != "in_progress" && message.Status != "completed") || + (message.Status == "completed") != (message.Text != nil) { + failure = fmt.Errorf("claudesdk: invalid message observation") + s.process.Cancel() + break + } + emit(proto.TypeOutputMessage, message) + case "function_call", "function_applied": + if err := s.receiveFunction(event, start, emit); err != nil { + failure = err + s.process.Cancel() + } + case "input_ready", "input_closed", "input_applied", "input_rejected": + if err := s.receiveInput(event, start); err != nil { + failure = err + s.process.Cancel() + } + case "usage": + if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || + (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { + failure = fmt.Errorf("claudesdk: invalid usage identity or duplicate result") + s.process.Cancel() + break + } + nextUsage, err := appendNativeUsage(usage, event.Usage) + failure = err + if failure != nil { + s.process.Cancel() + break + } + usage = nextUsage + usageIDs[event.ResultID] = true + usageSession = event.SessionID + emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) + case "result": + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") + s.process.Cancel() + } else { + result = &event + } + terminal = true + case "error": + failure = bridgeFailure(event.Code) + terminal = true + default: + failure = fmt.Errorf("claudesdk: unknown SDK bridge event") + s.process.Cancel() + } + if failure != nil && !terminal { + break + } + } + failure = s.drain(scanner, stderrDone, failure) + if result == nil && failure == nil { + failure = fmt.Errorf("claudesdk: SDK result is missing") + } + mcp.close(start, emit) + commands.close(start, emit) + s.stopFunctions() + s.stopSteering() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} + if id := s.inputSessionID(); id != "" { + metadata[proto.DoneMetaAgentSessionID] = id + } + if failure == nil { + content.Reset() + content.WriteString(result.Text) + metadata[proto.DoneMetaAgentSessionID] = result.SessionID + } + s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} + // Router completion cleanup calls Cancel while consuming Done. Settle first. + close(s.settled) + if failure != nil { + emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error()}) + } + emit(proto.TypeDone, s.outcome) +} + +func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { + binary := config.Node + if binary == "" { + binary = "node" + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + if err != nil { + return nil, err + } + return &session{process: process, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil +} + +func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) error { + for scanner.Scan() { + } + if scanner.Err() != nil { + failure = fmt.Errorf("claudesdk: SDK bridge output read failed") + s.process.Cancel() + } + <-stderrDone + s.stopWorkspaceReads() + s.stopWorkspaceDirectories() + if err := s.process.Wait(); err != nil && failure == nil { + failure = fmt.Errorf("claudesdk: SDK process failed") + } + return failure +} + +func bridgeFailure(code string) error { + switch code { + case "invalid_request", "history_unavailable", "execution_failed", "cancelled": + return fmt.Errorf("claudesdk: %s", code) + default: + return fmt.Errorf("claudesdk: unknown SDK bridge failure") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go new file mode 100644 index 000000000..94a964076 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go @@ -0,0 +1,176 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTextFactoryCompletionAndFailures(t *testing.T) { + for _, mode := range []string{"success", "wrong-resume", "missing", "malformed", "process-failed", "after-result", "bridge-error"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + s, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + failed := false + done := false + deltas := "" + for event := range out { + if event.ID != "run" { + t.Fatal("wrong run identity") + } + switch event.Type { + case proto.TypeDelta: + var payload proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &payload) + if payload.ItemID != "" { + t.Fatal("ordinary deltas acquired message identity") + } + deltas += payload.Delta + case proto.TypeOutputMessage: + t.Fatal("ordinary requests acquired message observations") + case proto.TypeError: + failed = true + case proto.TypeDone: + done = true + var payload proto.DonePayload + _ = json.Unmarshal(event.Payload, &payload) + if mode == "success" { + if payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { + t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + t.Fatal("Done preceded process release") + } + } else if payload.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("failed result exposed a successful continuity id") + } + } + } + if !done || failed != (mode != "success") { + t.Fatalf("done=%t failed=%t", done, failed) + } + }) + } +} + +func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { + for _, kind := range []string{"execution-controls", "tool", "option", "relative", "outside"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentOptions: map[string]any{"model": "fake"}} + switch kind { + case "execution-controls": + request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} + case "tool": + request.FunctionTools = []proto.FunctionTool{{}} + case "option": + request.AgentOptions["allowed_tools"] = "anything" + case "relative": + request.WorkDir = "relative" + case "outside": + config.StateDir = filepath.Dir(root) + } + _, err := NewFactory(config)(context.Background(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { + t.Fatalf("expected pre-launch rejection, got %v", err) + } + }) + } +} + +func TestMain(m *testing.M) { + if os.Getenv("GO_CLAUDE_PREPARATION_HELPER") == "1" { + runPreparationHelper() + os.Exit(0) + } + if os.Getenv("GO_CLAUDE_READINESS_HELPER") == "1" { + runReadinessHelper() + os.Exit(0) + } + if os.Getenv("GO_CLAUDE_SDK_HELPER") == "1" { + runSDKHelper() + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runSDKHelper() { + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + os.Exit(2) + } + var request startRequest + if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "start" || request.Prompt != "hello" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { + os.Exit(3) + } + encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + mode := os.Getenv("SDK_HELPER_MODE") + if strings.HasPrefix(mode, "cancellation-") { + runCancellationHelper(request, mode, encode) + return + } + if strings.HasPrefix(mode, "steering-") { + runSteeringHelper(request, mode, scanner, encode) + return + } + if strings.HasPrefix(mode, "usage-") { + runUsageHelper(request, mode, encode) + return + } + if strings.HasPrefix(mode, "functions-") { + runFunctionHelper(request, mode, scanner, encode) + return + } + if strings.HasPrefix(mode, "messages-") { + runMessageHelper(request, mode, encode) + return + } + switch mode { + case "missing": + return + case "malformed": + fmt.Fprintln(os.Stdout, "malformed") + time.Sleep(time.Hour) + return + case "bridge-error": + encode(bridgeEvent{Type: "error", Code: "execution_failed"}) + return + } + encode(bridgeEvent{Type: "delta", Delta: "partial"}) + id := request.Resume + if mode == "wrong-resume" { + id = "different-session" + } + encode(bridgeEvent{Type: "result", Text: "final", SessionID: id}) + if mode == "process-failed" { + os.Exit(7) + } + if mode == "after-result" { + encode(bridgeEvent{Type: "delta", Delta: "too late"}) + return + } + time.Sleep(50 * time.Millisecond) + _ = os.WriteFile(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "released"), nil, 0o600) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering.go b/apps/parsar-daemon/internal/agent/claudesdk/steering.go new file mode 100644 index 000000000..bf7ceea9e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering.go @@ -0,0 +1,154 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type pendingInput struct { + id string + receipt chan error +} + +type steeringState struct { + mu sync.Mutex + sessionID string + closed bool + pending *pendingInput + seen map[string]bool +} + +var _ agent.Steerer = (*session)(nil) + +// Steer waits for native consumption, which may occur in a later native turn +// within this one SDK query. A completed stdin write is not a receipt. +func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, input, nil) +} + +// SteerWithReceipt separates a complete bridge write from native consumption. +func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || strings.TrimSpace(input.Text) == "" { + return fmt.Errorf("%w: input identity and text are required", agent.ErrSteeringRejected) + } + data, err := json.Marshal(struct { + Type string `json:"type"` + InputID string `json:"input_id"` + Text string `json:"text"` + }{Type: "steer", InputID: input.InputID, Text: input.Text}) + if err != nil || len(data) > 1024*1024 { + return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) + } + s.steering.mu.Lock() + if s.steering.closed || s.process.Context().Err() != nil { + s.steering.mu.Unlock() + return agent.ErrSteeringInactive + } + if s.steering.sessionID == "" { + s.steering.mu.Unlock() + return agent.ErrSteeringNotReady + } + if s.steering.pending != nil || s.steering.seen[input.InputID] || len(s.steering.seen) >= 63 { + s.steering.mu.Unlock() + return fmt.Errorf("%w: input is pending, repeated or over capacity", agent.ErrSteeringRejected) + } + if err := ctx.Err(); err != nil { + s.steering.mu.Unlock() + return err + } + if s.steering.seen == nil { + s.steering.seen = map[string]bool{} + } + pending := &pendingInput{id: input.InputID, receipt: make(chan error, 1)} + s.steering.seen[input.InputID] = true + s.steering.pending = pending + s.steering.mu.Unlock() + // Cancellation must release a blocked write, but a lost receipt after a full + // write preserves the process and unknown outcome without automatic redelivery. + stop := context.AfterFunc(ctx, s.process.Cancel) + s.writeMu.Lock() + if err = ctx.Err(); err == nil { + _, err = s.process.Stdin.Write(append(data, '\n')) + } + s.writeMu.Unlock() + stop() + if err != nil { + s.process.Cancel() + return fmt.Errorf("claudesdk: input transport failed") + } + if written != nil { + written() + } + select { + case err := <-pending.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) receiveInput(event bridgeEvent, start startRequest) error { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + switch event.Type { + case "input_ready": + if s.steering.closed || s.steering.sessionID != "" || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume { + return fmt.Errorf("claudesdk: invalid input session identity") + } + s.steering.sessionID = event.SessionID + case "input_closed": + if s.steering.closed || s.steering.sessionID == "" || event.SessionID != s.steering.sessionID { + return fmt.Errorf("claudesdk: invalid input closure") + } + s.steering.closed = true + case "input_applied", "input_rejected": + pending := s.steering.pending + if pending == nil || pending.id != event.InputID { + return fmt.Errorf("claudesdk: invalid input receipt") + } + var err error + if event.Type == "input_rejected" { + err = agent.ErrSteeringRejected + } + pending.receipt <- err + s.steering.pending = nil + } + return nil +} + +func (s *session) steeringComplete() bool { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.pending == nil +} + +func (s *session) stopSteering() { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + s.steering.closed = true + if s.steering.pending != nil { + s.steering.pending.receipt <- fmt.Errorf("claudesdk: execution ended with unknown input outcome") + s.steering.pending = nil + } +} + +func (s *session) matchesInputSession(id string) bool { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.sessionID == "" || s.steering.sessionID == id +} + +func (s *session) inputSessionID() string { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.sessionID +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go new file mode 100644 index 000000000..fc9c5ea47 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go @@ -0,0 +1,209 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSteeringReceiptsAndLifecycle(t *testing.T) { + for _, mode := range []string{"success", "phased", "timeout", "wrong-receipt", "duplicate-usage", "cancel", "blocked-write"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} + if mode == "phased" { + config.Env[1] = "SDK_HELPER_MODE=steering-timeout" + } + request := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", AgentSessionID: "native", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + s := running.(*session) + defer s.Cancel(context.Background()) + if frame := <-out; frame.Type != proto.TypeDelta { + t.Fatal("missing ready barrier", frame.Type) + } + input := proto.PromptSteerPayload{InputID: "extra", Text: "additional"} + if mode == "blocked-write" { + input.Text = strings.Repeat("x", 512*1024) + } + receiptCtx, receiptCancel := context.WithTimeout(ctx, time.Second) + if mode == "timeout" { + receiptCancel() + receiptCtx, receiptCancel = context.WithTimeout(ctx, 30*time.Millisecond) + } + if mode == "blocked-write" { + receiptCancel() + receiptCtx, receiptCancel = context.WithCancel(ctx) + } + defer receiptCancel() + reply := make(chan error, 1) + go func() { + if mode == "phased" { + callCtx, cancel := context.WithCancel(ctx) + defer cancel() + timer := time.AfterFunc(30*time.Millisecond, cancel) + defer timer.Stop() + reply <- s.SteerWithReceipt(callCtx, input, func() { + if !timer.Stop() { + t.Error("write phase exceeded deadline") + } + }) + } else { + reply <- s.Steer(receiptCtx, input) + } + }() + if mode == "blocked-write" { + // Serialization can exceed a short deadline under race instrumentation. + // Cancel only after admission so this exercises transport cancellation. + for { + s.steering.mu.Lock() + admitted := s.steering.pending != nil + s.steering.mu.Unlock() + if admitted { + receiptCancel() + break + } + select { + case <-ctx.Done(): + t.Fatal("input was not admitted before test deadline") + case <-time.After(time.Millisecond): + } + } + } + if mode == "cancel" { + time.Sleep(30 * time.Millisecond) + _ = s.Cancel(context.Background()) + } + receipt := <-reply + if mode == "success" || mode == "phased" || mode == "duplicate-usage" { + if receipt != nil { + t.Fatal(receipt) + } + } else if receipt == nil { + t.Fatal("missing failure/unknown receipt") + } + if mode == "timeout" { + if !errors.Is(receipt, context.DeadlineExceeded) { + t.Fatal(receipt) + } + select { + case <-s.process.Done(): + t.Fatal("receipt timeout killed native process") + default: + } + } + var done proto.DonePayload + failed := false + measurements := 0 + for frame := range out { + switch frame.Type { + case proto.TypeError: + failed = true + case proto.TypeUsage: + measurements++ + case proto.TypeDone: + if err := frame.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + wantSuccess := mode == "success" || mode == "phased" || mode == "timeout" + if failed == wantSuccess { + t.Fatalf("unexpected terminal failure=%v", failed) + } + if wantSuccess { + if measurements != 2 || done.Content != "final" || done.Metadata[proto.DoneMetaAgentSessionID] != "native" { + t.Fatalf("bad completion: %+v, measurements=%d", done, measurements) + } + snapshots, ok := done.Usage.Raw["claude_sdk_results"].([]any) + if !ok || len(snapshots) != 2 { + t.Fatal("lost native-turn usage snapshots", done.Usage.Raw) + } + if snapshots[0].(map[string]any)["total_cost_usd"] != float64(0.1) || snapshots[1].(map[string]any)["total_cost_usd"] != float64(0.3) { + t.Fatal("native cumulative counters changed", snapshots) + } + } + if mode == "duplicate-usage" && measurements != 1 { + t.Fatal("duplicate measurement was published") + } + if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal("completed execution accepted input", err) + } + select { + case <-s.process.Done(): + default: + t.Fatal("completion preceded process release") + } + }) + } +} + +func TestSteeringDoesNotSendBeforeReadiness(t *testing.T) { + s := &session{process: &clirunner.Process{}} + if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Text: "hello"}); !errors.Is(err, agent.ErrSteeringNotReady) { + t.Fatal(err) + } + s.stopSteering() + if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Text: "hello"}); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal(err) + } +} + +func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + emit(bridgeEvent{Type: "delta", Delta: "ready"}) + if mode == "steering-blocked-write" { + time.Sleep(time.Hour) + return + } + if !scanner.Scan() { + os.Exit(2) + } + var input struct { + Type, Text string + InputID string `json:"input_id"` + } + if json.Unmarshal(scanner.Bytes(), &input) != nil || input.Type != "steer" || input.Text != "additional" || input.InputID != "extra" { + os.Exit(3) + } + if mode == "steering-cancel" { + time.Sleep(time.Hour) + return + } + if mode == "steering-timeout" { + time.Sleep(100 * time.Millisecond) + } + if mode == "steering-wrong-receipt" { + emit(bridgeEvent{Type: "input_applied", InputID: "unknown"}) + time.Sleep(time.Hour) + return + } + emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: "first", Usage: json.RawMessage(`{"usage":{"input_tokens":4},"modelUsage":{"model":{"inputTokens":4}},"total_cost_usd":0.1}`)}) + emit(bridgeEvent{Type: "input_applied", InputID: input.InputID}) + time.Sleep(30 * time.Millisecond) + id := "second" + if mode == "steering-duplicate-usage" { + id = "first" + } + emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: id, Usage: json.RawMessage(`{"usage":{"input_tokens":7},"modelUsage":{"model":{"inputTokens":11}},"total_cost_usd":0.3}`)}) + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "final"}) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/usage.go b/apps/parsar-daemon/internal/agent/claudesdk/usage.go new file mode 100644 index 000000000..5460e3391 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/usage.go @@ -0,0 +1,37 @@ +package claudesdk + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func nativeUsage(raw json.RawMessage) (proto.Usage, error) { + var snapshot map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&snapshot); err != nil || snapshot == nil { + return proto.Usage{}, fmt.Errorf("claudesdk: invalid native usage snapshot") + } + // The SDK owns native counter scopes and cost estimates. Do not expose an + // incomplete public token breakdown or a model selected from an unordered map. + return proto.Usage{Provider: "claude_code", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil +} + +// Keep every native measurement when a query spans multiple native turns. Each +// main-loop usage is per native turn; modelUsage and cost are cumulative snapshots. +func appendNativeUsage(previous proto.Usage, raw json.RawMessage) (proto.Usage, error) { + current, err := nativeUsage(raw) + if err != nil || previous.Raw == nil { + return current, err + } + snapshots, ok := previous.Raw["claude_sdk_results"].([]any) + if !ok { + snapshots = []any{previous.Raw["claude_sdk_result"]} + } + retained := append([]any{}, snapshots...) + current.Raw["claude_sdk_results"] = append(retained, current.Raw["claude_sdk_result"]) + return current, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/usage_test.go b/apps/parsar-daemon/internal/agent/claudesdk/usage_test.go new file mode 100644 index 000000000..06af3e84a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/usage_test.go @@ -0,0 +1,165 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const usageFixture = `{"subtype":"success","is_error":false,"usage":{"input_tokens":12,"output_tokens":4},"modelUsage":{"first":{"inputTokens":12,"outputTokens":4,"costUSD":0.1,"costBasis":"unknown"},"second":{"inputTokens":25,"outputTokens":5}},"total_cost_usd":0.1}` + +func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { + for _, mode := range []string{"success", "native-error", "process-error", "missing", "malformed", "duplicate", "wrong-session", "changed-result"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "usage-run", Prompt: "hello", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + s, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + var observed proto.Usage + var done proto.DonePayload + var kinds []string + for event := range out { + if event.ID != request.RunID { + t.Fatal("usage escaped run identity") + } + kinds = append(kinds, event.Type) + switch event.Type { + case proto.TypeUsage: + var value proto.UsagePayload + if err := event.DecodePayload(&value); err != nil { + t.Fatal(err) + } + observed = value.Usage + case proto.TypeDone: + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + expected := []string{proto.TypeUsage, proto.TypeDone} + if mode == "missing" { + expected = []string{proto.TypeDone} + } + if mode == "native-error" || mode == "process-error" || mode == "duplicate" || mode == "changed-result" { + expected = []string{proto.TypeUsage, proto.TypeError, proto.TypeDone} + } + if mode == "malformed" || mode == "wrong-session" { + expected = []string{proto.TypeError, proto.TypeDone} + } + if !reflect.DeepEqual(kinds, expected) || !reflect.DeepEqual(observed, done.Usage) { + t.Fatalf("events=%v; usage=%+v done=%+v", kinds, observed, done) + } + if len(kinds) > 1 && kinds[0] == proto.TypeUsage { + original := usageFixture + if mode == "native-error" { + original = strings.ReplaceAll(strings.ReplaceAll(original, `"subtype":"success"`, `"subtype":"error_during_execution"`), `"is_error":false`, `"is_error":true`) + } + var want map[string]any + _ = json.Unmarshal([]byte(original), &want) + if !reflect.DeepEqual(observed.Raw["claude_sdk_result"], want) || observed.Model != "" || observed.Tokens != nil || observed.CostUSD != 0 || observed.InputTokens != 0 || observed.OutputTokens != 0 { + t.Fatalf("usage normalized or lost: %+v", observed) + } + } + }) + } +} + +func runUsageHelper(request startRequest, mode string, encode func(bridgeEvent)) { + mode = strings.TrimPrefix(mode, "usage-") + value := json.RawMessage(usageFixture) + if mode == "native-error" { + value = []byte(strings.ReplaceAll(strings.ReplaceAll(string(value), `"subtype":"success"`, `"subtype":"error_during_execution"`), `"is_error":false`, `"is_error":true`)) + } + id := request.Resume + if mode == "wrong-session" { + id = "other" + } + if mode == "malformed" { + value = json.RawMessage(`[]`) + } + if mode != "missing" { + encode(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: value}) + } + if mode == "duplicate" { + encode(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: value}) + return + } + if mode == "native-error" { + encode(bridgeEvent{Type: "error", Code: "execution_failed"}) + return + } + if mode == "process-error" { + os.Exit(7) + } + if mode == "changed-result" { + id = "changed" + } + encode(bridgeEvent{Type: "result", SessionID: id, Text: "final"}) +} + +func verifyLiveUsageEvents(t *testing.T, events []proto.Envelope) { + t.Helper() + var observed proto.Usage + count := 0 + for _, event := range events { + switch event.Type { + case proto.TypeUsage: + var payload proto.UsagePayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + observed = payload.Usage + count++ + case proto.TypeDone: + var payload proto.DonePayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + if count < 1 || !reflect.DeepEqual(payload.Usage, observed) { + t.Fatal("missing, repeated or changed live usage") + } + } + } + if count < 1 || observed.Tokens != nil || observed.Model != "" || observed.CostUSD != 0 { + t.Fatal("live native evidence became unsupported public accounting") + } + if count > 1 { + snapshots, ok := observed.Raw["claude_sdk_results"].([]any) + if !ok || len(snapshots) != count || !reflect.DeepEqual(snapshots[count-1], observed.Raw["claude_sdk_result"]) { + t.Fatal("lost native-turn snapshots") + } + } + snapshot, ok := observed.Raw["claude_sdk_result"].(map[string]any) + if !ok || snapshot["subtype"] != "success" || snapshot["is_error"] != false { + t.Fatal("missing native result provenance") + } + main, ok := snapshot["usage"].(map[string]any) + if !ok { + t.Fatal("missing main-loop usage") + } + output, ok := main["output_tokens"].(float64) + if !ok || output <= 0 { + t.Fatal("missing real output count") + } + models, ok := snapshot["modelUsage"].(map[string]any) + if !ok || len(models) == 0 { + t.Fatal("missing per-model native usage") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go new file mode 100644 index 000000000..3be299d92 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -0,0 +1,208 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +// WorkspaceConfig binds one trusted private placement. It does not create an +// isolation boundary or authorize a public Environment. The operator must place +// the entire factory inside the qualified outer mount/process boundary first. +// State directories must already exist, be canonical and be mutually disjoint. +// PublicDirectory may name a second mount of the same workspace inode. +type WorkspaceConfig struct { + Directory string + PublicDirectory string + NetworkAccess string + HomeDir string + ScratchDir string + ProtectedDirs []string + DependencyPath string +} + +type workspaceProfile struct { + Skills []agentskill.Metadata `json:"skills,omitempty"` + ToolEnvironment bool `json:"tool_environment,omitempty"` + SystemPackages bool `json:"system_packages,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + ProtectedDirs []string `json:"protected_dirs"` + DependencyPath string `json:"dependency_path"` + EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` +} + +func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { + if req.DisableExecutionEnvironment || req.RemoteEnvironment != nil || req.MCPHTTPServers != nil { + return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") + } + if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { + return nil, nil, fmt.Errorf("claudesdk: work_dir conflicts with the trusted workspace binding") + } + if req.LocalEnvironment != nil && (config.Workspace.NetworkAccess == "" || req.LocalEnvironment.NetworkAccess != config.Workspace.NetworkAccess) { + return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") + } + profile, env, err := workspaceEnvironment(config) + if err != nil { + return nil, nil, err + } + if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + return nil, nil, err + } + profile.ToolEnvironment = true + profile.SystemPackages = req.LocalEnvironment.SystemPackages + } + if req.LocalEnvironment != nil { + if err := localworkspace.VerifySkills(req.LocalEnvironment.Skills); err != nil { + return nil, nil, err + } + profile.Skills = req.LocalEnvironment.Skills + } + return profile, env, nil +} + +func workspaceCwd(w *WorkspaceConfig) string { + if w.PublicDirectory != "" { + return w.PublicDirectory + } + return w.Directory +} + +// Workspace Env is a replacement, unlike the existing none profile's overlay. +// Only explicitly selected provider settings reach either readiness or execution. +func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { + fail := func() (*workspaceProfile, []string, error) { + return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") + } + w := config.Workspace + if w == nil || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { + return fail() + } + if w.NetworkAccess != "" && w.NetworkAccess != "disabled" && w.NetworkAccess != "enabled" { + return fail() + } + if w.PublicDirectory != "" { + actual, err := os.Stat(w.Directory) + alias, aliasErr := os.Stat(w.PublicDirectory) + if !canonicalWorkspaceDir(w.Directory) || !canonicalWorkspaceDir(w.PublicDirectory) || err != nil || aliasErr != nil || !os.SameFile(actual, alias) { + return fail() + } + } + runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) + if config.Entrypoint != filepath.Join(runtimeDir, "dist", "main.js") || !canonicalWorkspaceDir(runtimeDir) { + return fail() + } + // Keep the exact paths used by execution and readiness outside mutable roots. + // A symlinked entrypoint must not select an unchecked sibling companion. + codePaths := []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} + for _, path := range codePaths { + resolved, err := filepath.EvalSymlinks(path) + info, statErr := os.Stat(path) + if err != nil || resolved != path || statErr != nil || !info.Mode().IsRegular() { + return fail() + } + } + roots := append([]string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir}, w.ProtectedDirs...) + for i, dir := range roots { + if !canonicalWorkspaceDir(dir) || pathContains(runtimeDir, dir) || pathContains(dir, runtimeDir) { + return fail() + } + for _, previous := range roots[:i] { + if pathContains(previous, dir) || pathContains(dir, previous) { + return fail() + } + } + for _, executable := range codePaths { + if pathContains(dir, executable) { + return fail() + } + } + } + managed, err := paths.Root() + if err != nil { + return fail() + } + managed, err = filepath.EvalSymlinks(managed) + if err != nil || managed == config.StateDir || !pathContains(managed, config.StateDir) { + return fail() + } + if w.DependencyPath == "" { + return fail() + } + var dependencies []string + for _, dir := range filepath.SplitList(w.DependencyPath) { + info, err := os.Stat(dir) + if !workspacePathSyntax(dir) || err != nil || !info.IsDir() { + return fail() + } + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return fail() + } + for _, root := range roots { + if pathContains(root, dir) || pathContains(dir, root) || pathContains(root, resolved) || pathContains(resolved, root) { + return fail() + } + } + dependencies = append(dependencies, resolved) + } + dependencyPath := strings.Join(dependencies, string(os.PathListSeparator)) + profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, + ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess} + env := []string{"PATH=" + dependencyPath, "HOME=" + w.HomeDir, "TMPDIR=" + w.ScratchDir, + "CLAUDE_CONFIG_DIR=" + config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", + "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} + seen := map[string]bool{} + for _, entry := range config.Env { + name, _, ok := strings.Cut(entry, "=") + if !ok || seen[name] || strings.ContainsRune(entry, '\x00') || !workspaceEnvName(name) { + return fail() + } + seen[name] = true + profile.EnvNames = append(profile.EnvNames, name) + env = append(env, entry) + } + return profile, env, nil +} + +func workspaceEnvName(name string) bool { + switch name { + case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + return true + default: + return false + } +} + +func canonicalWorkspaceDir(dir string) bool { + if !workspacePathSyntax(dir) { + return false + } + resolved, err := filepath.EvalSymlinks(dir) + if err != nil || resolved != dir { + return false + } + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} + +func workspacePathSyntax(dir string) bool { + return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && dir != string(filepath.Separator) && + !strings.ContainsAny(dir, "*?[]{}():\\") && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 +} + +func pathContains(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go new file mode 100644 index 000000000..fad7426ab --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go @@ -0,0 +1,52 @@ +//go:build linux + +package claudesdk + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func liveWorkspaceCommands(t *testing.T, runID string, events []proto.Envelope, commands []string) []proto.ToolCallPayload { + t.Helper() + started := map[string]string{} + finished := map[string]bool{} + var complete []proto.ToolCallPayload + terminal := false + for _, event := range events { + if event.Type == proto.TypeDone { + terminal = true + } + if event.Type != proto.TypeToolCall { + continue + } + var call proto.ToolCallPayload + if terminal || event.ID != runID || event.DecodePayload(&call) != nil || call.ID == "" || call.NativeItem != nil || call.Observation == nil || call.Observation.Kind != "command" { + t.Fatal("invalid command frame or execution identity") + } + o := call.Observation + if o.ExitCode != nil || o.Cwd != nil || o.DurationMS != nil { + t.Fatal("command observation invented unavailable native metadata") + } + switch call.Stage { + case "before": + if len(started) >= len(commands) || started[call.ID] != "" || o.Command != commands[len(started)] || o.Status != "in_progress" || len(o.Output) != 0 { + t.Fatal("unexpected, duplicate or fabricated command start") + } + started[call.ID] = o.Command + case "after": + if started[call.ID] != o.Command || finished[call.ID] || o.Status == "in_progress" { + t.Fatal("command completion lacks a unique matching start") + } + finished[call.ID] = true + complete = append(complete, call) + default: + t.Fatal("invalid command stage") + } + } + if len(complete) != len(commands) || len(started) != len(commands) { + t.Fatal("command observations missing or replayed from an earlier query") + } + return complete +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go new file mode 100644 index 000000000..14507ab80 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go @@ -0,0 +1,249 @@ +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/google/uuid" +) + +const workspaceDirectoryMaxEntries = 1000 +const workspaceDirectoryTimeout = 12 * time.Second + +type workspaceDirectoryState struct { + mu sync.Mutex + supported bool + closed bool + uncertain bool + pending *workspaceDirectory +} +type workspaceDirectory struct { + id string + maxEntries int + done chan struct{} + result agent.WorkspaceDirectoryResult + err error +} +type workspaceDirectoryEvent struct { + Type string `json:"type"` + ID string `json:"id"` + Entries *[]workspaceDirectoryEntry `json:"entries"` + Truncated *bool `json:"truncated"` + Error string `json:"error"` +} + +var _ agent.WorkspaceDirectoryLister = (*prepared)(nil) +var _ agent.WorkspaceDirectoryLister = (*session)(nil) + +func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + p.mu.Lock() + if p.closed || p.binding != nil { + p.mu.Unlock() + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable + } + read, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) + p.mu.Unlock() + if err != nil { + return agent.WorkspaceDirectoryResult{}, err + } + return p.session.awaitWorkspaceDirectory(ctx, read) +} + +func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + read, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) + if err != nil { + return agent.WorkspaceDirectoryResult{}, err + } + return s.awaitWorkspaceDirectory(ctx, read) +} + +func (s *session) admitWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (*workspaceDirectory, error) { + w := &s.directories + w.mu.Lock() + defer w.mu.Unlock() + if !w.supported { + return nil, agent.ErrWorkspaceReadUnsupported + } + if w.uncertain { + return nil, agent.ErrWorkspaceReadUncertain + } + if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { + return nil, agent.ErrWorkspaceReadUnavailable + } + if maxEntries < 1 || maxEntries > workspaceDirectoryMaxEntries || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { + return nil, agent.ErrWorkspaceReadInvalid + } + for _, part := range strings.Split(path, "/") { + if path == "" { + break + } + if part == "" || part == "." || part == ".." { + return nil, agent.ErrWorkspaceReadInvalid + } + } + if w.pending != nil { + return nil, agent.ErrWorkspaceReadBusy + } + read := &workspaceDirectory{id: uuid.NewString(), maxEntries: maxEntries, done: make(chan struct{})} + frame, err := json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Path string `json:"directory"` + MaxEntries int `json:"max_entries"` + }{"workspace_directory", read.id, path, maxEntries}) + if err != nil || len(frame)+1 > 8192 { + return nil, agent.ErrWorkspaceReadInvalid + } + w.pending = read + deadline := time.Now().Add(workspaceDirectoryTimeout) + if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { + deadline = requested + } + go func() { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case <-read.done: + return + case <-timer.C: + } + s.failWorkspaceDirectory(read) + }() + go func() { + s.writeMu.Lock() + _, err := s.process.Stdin.Write(append(frame, '\n')) + s.writeMu.Unlock() + if err != nil { + s.failWorkspaceDirectory(read) + } + }() + return read, nil +} + +func (s *session) failWorkspaceDirectory(read *workspaceDirectory) { + s.directories.mu.Lock() + pending := s.directories.pending == read + if pending { + s.directories.uncertain = true + s.directories.pending = nil + read.err = agent.ErrWorkspaceReadUncertain + s.process.Cancel() + close(read.done) + } + s.directories.mu.Unlock() +} + +func (s *session) awaitWorkspaceDirectory(_ context.Context, read *workspaceDirectory) (agent.WorkspaceDirectoryResult, error) { + // Caller cancellation cannot discard an already admitted native wait. + <-read.done + return read.result, read.err +} + +func (s *session) receiveWorkspaceDirectory(raw []byte) bool { + var event workspaceDirectoryEvent + if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_directory" { + return false + } + w := &s.directories + w.mu.Lock() + defer w.mu.Unlock() + read := w.pending + if read == nil || event.ID != read.id { + w.uncertain = true + s.process.Cancel() + return true + } + read.err = agent.ErrWorkspaceReadUncertain + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF + if !valid { + w.uncertain = true + w.pending = nil + close(read.done) + s.process.Cancel() + return true + } + if event.Error != "" && event.Entries == nil && event.Truncated == nil { + switch event.Error { + case "not_found": + read.err = fs.ErrNotExist + case "permission": + read.err = fs.ErrPermission + case "invalid": + read.err = agent.ErrWorkspaceReadInvalid + case "busy": + read.err = agent.ErrWorkspaceReadBusy + case "unavailable": + read.err = agent.ErrWorkspaceReadUnavailable + } + } else if event.Error == "" && event.Entries != nil && event.Truncated != nil { + entries, valid := validWorkspaceDirectoryEntries(*event.Entries, read.maxEntries) + if valid && (!*event.Truncated || len(entries) == read.maxEntries) { + read.result = agent.WorkspaceDirectoryResult{Entries: entries, Truncated: *event.Truncated} + read.err = nil + } + } + if read.err == agent.ErrWorkspaceReadUncertain { + w.uncertain = true + s.process.Cancel() + } + w.pending = nil + close(read.done) + return true +} + +func (s *session) stopWorkspaceDirectories() { + w := &s.directories + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + if w.pending != nil { + read := w.pending + w.pending = nil + w.uncertain = true + read.err = agent.ErrWorkspaceReadUncertain + close(read.done) + } +} + +type workspaceDirectoryEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + SizeBytes *int64 `json:"size_bytes,omitempty"` +} + +func validWorkspaceDirectoryEntries(raw []workspaceDirectoryEntry, maxEntries int) ([]agent.WorkspaceDirectoryEntry, bool) { + if raw == nil || len(raw) > maxEntries { + return nil, false + } + entries := make([]agent.WorkspaceDirectoryEntry, 0, len(raw)) + names := make(map[string]bool, len(raw)) + for _, entry := range raw { + if entry.Name == "" || entry.Name == "." || entry.Name == ".." || strings.ContainsAny(entry.Name, "/\x00") || names[entry.Name] { + return nil, false + } + names[entry.Name] = true + switch entry.Kind { + case "file": + if entry.SizeBytes == nil || *entry.SizeBytes < 0 { + return nil, false + } + case "directory", "symlink", "other": + if entry.SizeBytes != nil { + return nil, false + } + default: + return nil, false + } + entries = append(entries, agent.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) + } + return entries, true +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go new file mode 100644 index 000000000..b681ad45f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go @@ -0,0 +1,103 @@ +//go:build linux + +package claudesdk + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +func liveWorkspaceDirectoryFixtures(t *testing.T, root string) { + t.Helper() + for _, name := range []string{"directory-empty", "directory-denied"} { + if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(filepath.Join(root, "directory-denied"), 0); err != nil { + t.Fatal(err) + } + for name, target := range map[string]string{"directory-inside-link": "directory-empty", "directory-outside-link": filepath.Dir(root)} { + if err := os.Symlink(target, filepath.Join(root, name)); err != nil { + t.Fatal(err) + } + } +} + +func liveWorkspaceDirectories(t *testing.T, ctx context.Context, reader agent.WorkspaceReader, root, stage string) { + t.Helper() + lister, ok := reader.(agent.WorkspaceDirectoryLister) + if !ok { + t.Fatal("workspace directory owner missing") + } + result, err := lister.ListWorkspaceDirectory(ctx, "", 1000) + if err != nil || result.Truncated { + t.Fatal("workspace directory failed", stage, err) + } + expected, err := os.ReadDir(root) + if err != nil || len(expected) != len(result.Entries) { + t.Fatal("directory length differs", stage, err) + } + found := make(map[string]agent.WorkspaceDirectoryEntry) + for _, entry := range result.Entries { + found[entry.Name] = entry + } + for _, entry := range expected { + actual, ok := found[entry.Name()] + if !ok { + t.Fatal("missing directory entry", stage, entry.Name()) + } + stat, err := os.Lstat(filepath.Join(root, entry.Name())) + if err != nil { + t.Fatal(err) + } + switch { + case stat.Mode().IsRegular(): + // The command heartbeat is intentionally changing during live execution. + if actual.Kind != "file" || actual.SizeBytes == nil || (entry.Name() != "heartbeat.txt" && *actual.SizeBytes != stat.Size()) { + t.Fatal("file metadata differs", stage, actual) + } + case stat.IsDir(): + if actual.Kind != "directory" || actual.SizeBytes != nil { + t.Fatal(actual) + } + case stat.Mode()&os.ModeSymlink != 0: + if actual.Kind != "symlink" || actual.SizeBytes != nil { + t.Fatal(actual) + } + } + } + empty, err := lister.ListWorkspaceDirectory(ctx, "directory-empty", 2) + if err != nil || empty.Truncated || len(empty.Entries) != 0 { + t.Fatal(empty, err) + } + bounded, err := lister.ListWorkspaceDirectory(ctx, "", 1) + if err != nil || !bounded.Truncated || len(bounded.Entries) != 1 { + t.Fatal(bounded, err) + } + for _, path := range []string{"directory-inside-link", "directory-outside-link"} { + if _, err := lister.ListWorkspaceDirectory(ctx, path, 2); !errors.Is(err, fs.ErrPermission) && !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatal("directory denial missing", path, err) + } + } + if _, err := lister.ListWorkspaceDirectory(ctx, "directory-denied", 2); !errors.Is(err, fs.ErrPermission) { + t.Fatal("permission denial missing", err) + } + if _, err := lister.ListWorkspaceDirectory(ctx, "directory-missing", 2); !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } + raw, err := json.MarshalIndent(result, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(root), "directory-"+stage+".json"), raw, 0600); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go new file mode 100644 index 000000000..ecd35fcf2 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -0,0 +1,209 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { + for scanner.Scan() { + var req struct { + Type, ID string + Path string `json:"directory"` + Max int `json:"max_entries"` + } + _ = json.Unmarshal(scanner.Bytes(), &req) + if req.Type == "start" { + _ = os.WriteFile(filepath.Join(state, "directory-started"), []byte("{}"), 0600) + continue + } + _ = os.WriteFile(filepath.Join(state, "directory-admitted"), []byte("{}"), 0600) + if mode == "directory-held" { + for { + if _, err := os.Stat(filepath.Join(state, "directory-release")); err == nil { + break + } + time.Sleep(time.Millisecond) + } + } + if mode == "directory-exit" { + return + } + entries := []map[string]any{{"name": "file", "kind": "file", "size_bytes": 3}} + if req.Path == "empty" { + entries = []map[string]any{} + } + response := map[string]any{"type": "workspace_directory", "id": req.ID, "entries": entries, "truncated": false} + switch req.Path { + case "uncertain", "invalid", "not_found", "permission": + response = map[string]any{"type": "workspace_directory", "id": req.ID, "error": req.Path} + case "bad-kind": + entries[0]["kind"] = "unknown" + case "wrong-id": + response["id"] = "other" + case "extra": + response["extra"] = true + case "bad-size": + entries[0]["size_bytes"] = -1 + case "missing-size": + delete(entries[0], "size_bytes") + case "duplicate": + response["entries"] = append(entries, entries[0]) + case "escape-name": + entries[0]["name"] = "../secret" + case "null": + response["entries"] = nil + } + + _ = json.NewEncoder(os.Stdout).Encode(response) + } +} + +func directoryPreparation(t *testing.T, mode string) (*prepared, Config) { + t.Helper() + config := preparationFixture(t, mode) + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + t.Cleanup(func() { _ = p.Cancel(context.Background()) }) + return p, config +} + +func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { + p, _ := directoryPreparation(t, "directory-normal") + for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { + if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatalf("accepted %q: %v", path, err) + } + } + for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { + if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { + t.Fatal(limit, err) + } + } + result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) + if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { + t.Fatal(result, err) + } + empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) + if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { + t.Fatal(empty, err) + } + for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { + if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { + t.Fatal(path, err) + } + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { + p, config := directoryPreparation(t, "directory-held") + ctx, detach := context.WithCancel(t.Context()) + defer detach() + result := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + detach() + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + t.Fatal(err) + } + out := make(chan proto.Envelope, 16) + running, err := p.Start(t.Context(), "run", "hello", out) + if err != nil { + t.Fatal(err) + } + if p.Close() != nil { + t.Fatal("transferred Close failed") + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + select { + case err := <-result: + t.Fatal("caller detach discarded native wait", err) + default: + } + if err := os.WriteFile(filepath.Join(config.StateDir, "directory-release"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := <-result; err != nil { + t.Fatal(err) + } + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-started")) + if _, err := running.(agent.WorkspaceDirectoryLister).ListWorkspaceDirectory(t.Context(), "file", 4); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { + for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { + t.Run(path, func(t *testing.T) { + p, _ := directoryPreparation(t, "directory-normal") + result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) + if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { + t.Fatal(result, err) + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + }) + } + for _, mode := range []string{"directory-held", "directory-exit"} { + t.Run(mode, func(t *testing.T) { + p, config := directoryPreparation(t, mode) + done := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + if mode == "directory-held" { + if err := p.Close(); err != nil { + t.Fatal(err) + } + } + select { + case err := <-done: + if err != agent.ErrWorkspaceReadUncertain { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("native waiter lost on release") + } + }) + } +} + +func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { + p, config := directoryPreparation(t, "directory-held") + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + if err := <-done; err != agent.ErrWorkspaceReadUncertain { + t.Fatal(err) + } + if p.session.process.Context().Err() == nil { + t.Fatal("uncertain deadline returned before owner cancellation") + } + if _, err := p.Start(t.Context(), "late", "hello", make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Start") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go new file mode 100644 index 000000000..2d1f59283 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -0,0 +1,178 @@ +//go:build unix + +package claudesdk + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceLaunchAndReadinessExcludeParentEnvironment(t *testing.T) { + config := workspaceFixture(t) + t.Setenv("PARSAR_PARENT_SECRET", "must-not-inherit") + t.Setenv("ANTHROPIC_API_KEY", "unselected") + // An owned process fixture verifies both real subprocess launch paths; it is + // not a native sandbox or provider acceptance test. + script := `#!/bin/sh +test -z "${PARSAR_PARENT_SECRET+x}" || exit 21 +test -z "${ANTHROPIC_API_KEY+x}" || exit 22 +test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 +test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 +case "$1" in + */runtime_check.js) + printf '%s\n' '{"type":"runtime_ready","protocol":1,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; + *) + IFS= read -r request + printf '%s\n' '{"type":"prepared"}' + IFS= read -r request + printf '%s\n' '{"type":"result","session_id":"native","text":"completed"}' ;; +esac +` + if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + info, err := CheckRuntime(t.Context(), config) + if err != nil || !info.supportsWorkspace() { + t.Fatal("readiness did not receive replacement environment", err) + } + out := make(chan proto.Envelope, 8) + s, err := NewFactory(config)(t.Context(), workspaceRequest(), out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(t.Context()) + done := 0 + for event := range out { + if event.Type == proto.TypeError { + t.Fatal("execution fixture rejected replacement environment") + } + if event.Type == proto.TypeDone { + done++ + } + } + if done != 1 { + t.Fatal("expected one settled completion") + } + // Feature checking must reject an older bridge without starting execution. + script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare"]`, `"features":[]`) + script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(config.StateDir, "unexpected-start")+"'") + if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + if _, err := NewFactory(config)(t.Context(), workspaceRequest(), out); err == nil { + t.Fatal("old packaged bridge accepted workspace execution") + } + if _, err := os.Stat(filepath.Join(config.StateDir, "unexpected-start")); !os.IsNotExist(err) { + t.Fatal("old packaged bridge started execution") + } +} + +func TestWorkspaceRejectsMutableRuntimeAliasesBeforeReadiness(t *testing.T) { + for _, name := range []string{"node", "entrypoint", "scratch-entrypoint", "companion", "dependencies"} { + t.Run(name, func(t *testing.T) { + config := workspaceFixture(t) + marker := filepath.Join(config.Workspace.Directory, "unexpected-launch") + if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + link, target := "", "" + switch name { + case "node": + link, target = filepath.Join(config.Workspace.Directory, "node"), config.Node + config.Node = link + case "entrypoint", "scratch-entrypoint": + dir := config.Workspace.Directory + if name == "scratch-entrypoint" { + dir = config.Workspace.ScratchDir + } + link, target = filepath.Join(dir, "main.js"), config.Entrypoint + config.Entrypoint = link + if err := os.WriteFile(filepath.Join(dir, "runtime_check.js"), []byte("untrusted companion"), 0o700); err != nil { + t.Fatal(err) + } + case "companion": + link, target = filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), filepath.Join(config.Workspace.Directory, "companion.js") + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("untrusted companion"), 0o700); err != nil { + t.Fatal(err) + } + case "dependencies": + link, target = filepath.Join(config.Workspace.Directory, "deps"), config.Workspace.DependencyPath + config.Workspace.DependencyPath = link + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := CheckRuntime(t.Context(), config); err == nil { + t.Fatal("mutable runtime alias passed readiness") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("invalid binding launched a credential-bearing child") + } + }) + } +} + +func TestWorkspaceUsesCanonicalDependencyPaths(t *testing.T) { + config := workspaceFixture(t) + canonical := config.Workspace.DependencyPath + alias := filepath.Join(filepath.Dir(config.StateDir), "dependency-alias") + if err := os.Symlink(canonical, alias); err != nil { + t.Fatal(err) + } + config.Workspace.DependencyPath = alias + start, env, err := prepare(config, workspaceRequest()) + if err != nil { + t.Fatal(err) + } + if start.Workspace.DependencyPath != canonical || env[0] != "PATH="+canonical { + t.Fatal("trusted launch retained a mutable dependency alias") + } +} + +func TestWorkspaceRejectsPackagedRuntimeOverlapBeforeReadiness(t *testing.T) { + for _, name := range []string{"workspace", "state", "home", "scratch", "protected", "runtime-root", "runtime-parent", "unexpected-layout"} { + t.Run(name, func(t *testing.T) { + config := workspaceFixture(t) + marker := filepath.Join(filepath.Dir(config.StateDir), "unexpected-launch") + if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) + dependencies := filepath.Join(runtimeDir, "node_modules") + switch name { + case "workspace": + config.Workspace.Directory = dependencies + case "state": + config.StateDir = dependencies + case "home": + config.Workspace.HomeDir = dependencies + case "scratch": + config.Workspace.ScratchDir = dependencies + case "protected": + config.Workspace.ProtectedDirs = []string{dependencies} + case "runtime-root": + config.Workspace.Directory = runtimeDir + case "runtime-parent": + config.Workspace.Directory = filepath.Dir(runtimeDir) + case "unexpected-layout": + config.Entrypoint = filepath.Join(filepath.Dir(config.Entrypoint), "other.js") + if err := os.WriteFile(config.Entrypoint, nil, 0o700); err != nil { + t.Fatal(err) + } + } + if _, err := CheckRuntime(t.Context(), config); err == nil { + t.Fatal("invalid runtime binding passed readiness") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("invalid runtime binding launched a credential-bearing child") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go new file mode 100644 index 000000000..5939b4e08 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -0,0 +1,295 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Run only inside a separately qualified outer placement, with its pinned native +// dependencies. This fixture does not create isolation or public admission. +func TestLiveClaudeWorkspaceFactory(t *testing.T) { + testLiveClaudeWorkspace(t, false) +} + +func TestLiveClaudePreparedWorkspace(t *testing.T) { + testLiveClaudeWorkspace(t, true) +} + +func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { + configFile := os.Getenv("PARSAR_CLAUDE_WORKSPACE_LIVE_CONFIG") + if configFile == "" { + t.Skip("requires explicit qualified placement and real provider configuration") + } + var placement struct { + Node, Entrypoint, Proof, Scratch, KeyFile, DependencyPath, Proxy string + } + raw, err := os.ReadFile(configFile) + if err != nil || json.Unmarshal(raw, &placement) != nil { + t.Fatal("invalid private live configuration") + } + root, err := os.MkdirTemp(placement.Proof, "factory-") + if err != nil { + t.Fatal(err) + } + t.Logf("workspace factory proof: %s", root) + t.Setenv("PARSAR_HOME", root) + t.Setenv("PARSAR_PARENT_SECRET", "parent-must-not-enter-workspace") + key, err := os.ReadFile(placement.KeyFile) + if err != nil || len(bytes.TrimSpace(key)) == 0 { + t.Fatal("private real-provider key unavailable") + } + scratch, err := os.MkdirTemp(placement.Scratch, "f-") + if err != nil { + t.Fatal(err) + } + config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), + Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), + ScratchDir: scratch, ProtectedDirs: []string{filepath.Dir(placement.KeyFile)}, DependencyPath: placement.DependencyPath}, + Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} + if placement.Proxy != "" { + config.Env = append(config.Env, "HTTP_PROXY="+placement.Proxy, "HTTPS_PROXY="+placement.Proxy, "NO_PROXY=127.0.0.1,localhost") + } + for _, dir := range []string{config.StateDir, config.Workspace.Directory, config.Workspace.HomeDir} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + } + liveWorkspaceReadFixtures(t, config.Workspace.Directory) + heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") + artifact := filepath.Join(config.Workspace.Directory, "value.txt") + type evidence struct { + Reads []liveWorkspaceRead `json:"reads,omitempty"` + RunID string `json:"run_id"` + Events []proto.Envelope `json:"events"` + Done proto.DonePayload `json:"done"` + Failure string `json:"failure,omitempty"` + Cancelled bool `json:"cancelled"` + CancelMS int64 `json:"cancel_ms,omitempty"` + BridgePID int `json:"bridge_pid"` + Terminals int `json:"terminals"` + Heartbeats []string `json:"heartbeats,omitempty"` + PreparedPID int `json:"prepared_pid,omitempty"` + NativeBefore string `json:"native_before,omitempty"` + NativeAfter string `json:"native_after,omitempty"` + StartContextCancelled bool `json:"start_context_cancelled,omitempty"` + } + writeEvidence := func(name string, proof evidence) { + t.Helper() + encoded, err := json.MarshalIndent(proof, "", " ") + if err != nil || bytes.Contains(encoded, bytes.TrimSpace(key)) { + t.Fatal("cannot safely encode factory observations") + } + if err := os.WriteFile(filepath.Join(root, name+".json"), encoded, 0o600); err != nil { + t.Fatal(err) + } + } + run := func(name, prompt, resume string, cancelOnEffect bool) evidence { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second) + defer cancel() + out := make(chan proto.Envelope, 64) + req := workspaceRequest() + req.RunID, req.Prompt, req.AgentSessionID = uuid.NewString(), prompt, resume + req.StrictResume, req.ReleaseOnCompletion, req.ObserveMessages, req.ObserveToolObservations = true, true, true, true + req.AgentOptions = map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work."} + proof := evidence{RunID: req.RunID} + var running agent.Session + var owner agent.PreparedCancellation + if explicitPreparation { + preparation := req + preparation.RunID, preparation.Prompt = "", "" + var resource agent.Prepared + resource, err = NewPreparationFactory(config)(ctx, preparation) + if err == nil { + defer resource.Close() + owner = resource.(agent.PreparedCancellation) + proof.PreparedPID = resource.(*prepared).session.process.Cmd.Process.Pid + proof.NativeBefore = liveWorkspaceNativeIdentity(t, proof.PreparedPID) + before, _ := os.ReadFile(artifact) + time.Sleep(500 * time.Millisecond) + after, _ := os.ReadFile(artifact) + if !bytes.Equal(before, after) || liveWorkspaceNativeIdentity(t, proof.PreparedPID) != proof.NativeBefore || len(out) != 0 { + t.Fatal("prepared resource changed before initial input") + } + proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, resource.(agent.WorkspaceReader), config.Workspace.Directory, "prepared", "read-binary.bin", "read-empty.bin", "read-large.bin")...) + operation, stopOperation := context.WithCancel(ctx) + running, err = resource.Start(operation, req.RunID, req.Prompt, out) + stopOperation() + proof.StartContextCancelled = true + if err == nil { + proof.NativeAfter = liveWorkspaceNativeIdentity(t, proof.PreparedPID) + if proof.NativeBefore != proof.NativeAfter || resource.Close() != nil { + t.Fatal("Start replaced the native process or Close affected its transfer") + } + } + } + } else { + running, err = NewFactory(config)(ctx, req, out) + } + if err != nil { + if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { + proof.Failure = err.Error() + writeEvidence(name, proof) + return proof + } + t.Fatal(err) + } + s := running.(*session) + defer s.Cancel(context.Background()) + proof.BridgePID = s.process.Cmd.Process.Pid + proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "active", "read-binary.bin", "read-empty.bin", "read-large.bin")...) + if explicitPreparation && proof.BridgePID != proof.PreparedPID { + t.Fatal("Start replaced the prepared bridge") + } + cancelOwned, outcome := s.Cancel, s.CancellationOutcome + if owner != nil { + cancelOwned, outcome = owner.Cancel, owner.CancellationOutcome + } + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + for out != nil { + select { + case <-ctx.Done(): + t.Fatal("real factory deadline expired") + case <-ticker.C: + value, _ := os.ReadFile(heartbeat) + if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { + proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "effect", "value.txt")...) + started := time.Now() + if err := cancelOwned(ctx); err != nil { + t.Fatal("factory cancellation failed", err) + } + proof.CancelMS = time.Since(started).Milliseconds() + proof.Cancelled = true + } + case event, ok := <-out: + if !ok { + out = nil + continue + } + proof.Events = append(proof.Events, event) + switch event.Type { + case proto.TypeError: + var failure proto.ErrorPayload + _ = event.DecodePayload(&failure) + proof.Failure = failure.Error + case proto.TypeDone: + proof.Terminals++ + _ = event.DecodePayload(&proof.Done) + } + } + } + if proof.Cancelled { + a, _ := os.ReadFile(heartbeat) + time.Sleep(1500 * time.Millisecond) + b, _ := os.ReadFile(heartbeat) + proof.Heartbeats = []string{string(a), string(b)} + if len(a) == 0 || !bytes.Equal(a, b) { + t.Fatal("native command effects continued after Cancel") + } + settled, _ := json.Marshal(outcome()) + done, _ := json.Marshal(proof.Done) + if !bytes.Equal(settled, done) { + t.Fatal("cancellation outcome differs from terminal Done") + } + } + writeEvidence(name, proof) + if proof.Terminals != 1 || (cancelOnEffect && !proof.Cancelled) { + t.Fatal("missing actual cancellation or unique completion") + } + return proof + } + commands := []string{ + `python3 -c "import sys; print('OBS_SUCCESS_STDOUT'); print('OBS_SUCCESS_STDERR', file=sys.stderr)"`, + `python3 -c "import sys; print('OBS_FAILURE_STDOUT'); print('OBS_FAILURE_STDERR', file=sys.stderr); sys.exit(7)"`, + } + observed := run("commands", fmt.Sprintf("Execute exactly these two foreground Bash calls, sequentially, with timeout 10000. Preserve each command exactly. The second intentionally fails; do not retry or repair it. Use no other tools.\n1. %s\n2. %s", commands[0], commands[1]), "", false) + observedID, _ := observed.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + if observedID == "" || observed.Failure != "" { + t.Fatal("real command observation query failed") + } + completed := liveWorkspaceCommands(t, observed.RunID, observed.Events, commands) + for i, expected := range []struct{ status, output string }{ + {"completed", "OBS_SUCCESS_STDERR\nOBS_SUCCESS_STDOUT"}, + {"failed", "Exit code 7\nOBS_FAILURE_STDERR\nOBS_FAILURE_STDOUT"}, + } { + var output string + if completed[i].Observation.Status != expected.status || json.Unmarshal(completed[i].Observation.Output, &output) != nil || output != expected.output { + t.Fatal("native command result text/status was not preserved") + } + } + nonce := "conversation-" + uuid.NewString() + command := "python3 -u - <<'VERIFY_PY'\nimport secrets,time\nfrom pathlib import Path\nPath('value.txt').write_text(secrets.token_hex(16)+'\\n')\nfor n in range(180):\n Path('heartbeat.txt').write_text(str(n))\n time.sleep(1)\nVERIFY_PY" + first := run("first", fmt.Sprintf("Remember the conversation-only value %s; do not write it into any file. Execute exactly one foreground Bash call with timeout 120000 and this exact command. Wait for it; use no other tools.\n%s", nonce, command), observedID, true) + id, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + if id == "" || id != observedID { + t.Fatal("cancelled native Session identity unavailable") + } + interrupted := liveWorkspaceCommands(t, first.RunID, first.Events, []string{command}) + if interrupted[0].ID == completed[0].ID || interrupted[0].ID == completed[1].ID || + (interrupted[0].Observation.Status != "incomplete" && interrupted[0].Observation.Status != "failed") || + (interrupted[0].Observation.Status == "failed" && len(interrupted[0].Observation.Output) == 0) { + t.Fatal("cancelled command lost its native failure or incomplete observation") + } + original, err := os.ReadFile(artifact) + if err != nil || len(bytes.TrimSpace(original)) != 32 { + t.Fatal("actual workspace artifact missing") + } + second := run("resumed", "Use native Read to read value.txt. Then use native Edit to append the literal suffix -resumed to its value, retaining a trailing newline. Do not use Bash. Reply with the original file value and the conversation-only value remembered earlier.", id, false) + liveWorkspaceCommands(t, second.RunID, second.Events, nil) + final, err := os.ReadFile(artifact) + if err != nil || string(final) != strings.TrimSpace(string(original))+"-resumed\n" || second.Failure != "" || + second.Done.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Done.Content, nonce) || + !strings.Contains(second.Done.Content, strings.TrimSpace(string(original))) || first.BridgePID == second.BridgePID { + t.Fatal("fresh-process native workspace/history continuation failed") + } + retained := config.StateDir + "-retained" + if err := os.Rename(config.StateDir, retained); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Remove(config.StateDir) + _ = os.Rename(retained, config.StateDir) + }() + if err := os.Mkdir(config.StateDir, 0o700); err != nil { + t.Fatal(err) + } + missing := run("missing-history", "Continue the existing Session only; do not start another Session.", id, false) + entries, err := os.ReadDir(config.StateDir) + after, _ := os.ReadFile(artifact) + if missing.Failure != "claudesdk: history_unavailable" || missing.Terminals != 0 || err != nil || len(entries) != 0 || !bytes.Equal(final, after) || missing.Done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("missing native history did not fail before new execution") + } + if err := os.RemoveAll(scratch); err != nil { + t.Fatal(err) + } +} + +func liveWorkspaceNativeIdentity(t *testing.T, bridgePID int) string { + t.Helper() + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", bridgePID, bridgePID)) + children := strings.Fields(string(raw)) + if err != nil || len(children) != 1 { + t.Fatal("expected exactly one retained native child", err) + } + status, err := os.ReadFile("/proc/" + children[0] + "/stat") + fields := strings.Fields(string(status)[strings.LastIndex(string(status), ")")+1:]) + if err != nil || len(fields) < 20 { + t.Fatal("native process identity unavailable", err) + } + return children[0] + ":" + fields[19] +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go new file mode 100644 index 000000000..9ed4b64c7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go @@ -0,0 +1,208 @@ +package claudesdk + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/google/uuid" +) + +const workspaceReadMaxBytes = 1 << 20 +const workspaceReadTimeout = 12 * time.Second + +type workspaceReadState struct { + mu sync.Mutex + supported bool + closed bool + uncertain bool + pending *workspaceRead +} +type workspaceRead struct { + id string + maxBytes int + done chan struct{} + result agent.WorkspaceReadResult + err error +} +type workspaceReadEvent struct { + Type string `json:"type"` + ID string `json:"id"` + Data *string `json:"data_base64"` + Truncated *bool `json:"truncated"` + Error string `json:"error"` +} + +var _ agent.WorkspaceReader = (*prepared)(nil) +var _ agent.WorkspaceReader = (*session)(nil) + +func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + p.mu.Lock() + if p.closed || p.binding != nil { + p.mu.Unlock() + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable + } + read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) + p.mu.Unlock() + if err != nil { + return agent.WorkspaceReadResult{}, err + } + return p.session.awaitWorkspaceRead(ctx, read) +} + +func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + read, err := s.admitWorkspaceRead(ctx, path, maxBytes) + if err != nil { + return agent.WorkspaceReadResult{}, err + } + return s.awaitWorkspaceRead(ctx, read) +} + +func (s *session) admitWorkspaceRead(ctx context.Context, path string, maxBytes int) (*workspaceRead, error) { + w := &s.reads + w.mu.Lock() + defer w.mu.Unlock() + if !w.supported { + return nil, agent.ErrWorkspaceReadUnsupported + } + if w.uncertain { + return nil, agent.ErrWorkspaceReadUncertain + } + if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { + return nil, agent.ErrWorkspaceReadUnavailable + } + if maxBytes < 1 || maxBytes > workspaceReadMaxBytes || path == "" || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { + return nil, agent.ErrWorkspaceReadInvalid + } + for _, part := range strings.Split(path, "/") { + if part == "" || part == "." || part == ".." { + return nil, agent.ErrWorkspaceReadInvalid + } + } + if w.pending != nil { + return nil, agent.ErrWorkspaceReadBusy + } + read := &workspaceRead{id: uuid.NewString(), maxBytes: maxBytes, done: make(chan struct{})} + frame, err := json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Path string `json:"path"` + MaxBytes int `json:"max_bytes"` + }{"workspace_read", read.id, path, maxBytes}) + if err != nil || len(frame)+1 > 8192 { + return nil, agent.ErrWorkspaceReadInvalid + } + w.pending = read + deadline := time.Now().Add(workspaceReadTimeout) + if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { + deadline = requested + } + go func() { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case <-read.done: + return + case <-timer.C: + } + s.failWorkspaceRead(read) + }() + go func() { + s.writeMu.Lock() + _, err := s.process.Stdin.Write(append(frame, '\n')) + s.writeMu.Unlock() + if err != nil { + s.failWorkspaceRead(read) + } + }() + return read, nil +} + +func (s *session) failWorkspaceRead(read *workspaceRead) { + s.reads.mu.Lock() + pending := s.reads.pending == read + if pending { + s.reads.uncertain = true + s.reads.pending = nil + read.err = agent.ErrWorkspaceReadUncertain + s.process.Cancel() + close(read.done) + } + s.reads.mu.Unlock() +} + +func (s *session) awaitWorkspaceRead(_ context.Context, read *workspaceRead) (agent.WorkspaceReadResult, error) { + // Caller cancellation cannot discard an already admitted native wait. + <-read.done + return read.result, read.err +} + +func (s *session) receiveWorkspaceRead(raw []byte) bool { + var event workspaceReadEvent + if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_read" { + return false + } + w := &s.reads + w.mu.Lock() + defer w.mu.Unlock() + read := w.pending + if read == nil || event.ID != read.id { + w.uncertain = true + s.process.Cancel() + return true + } + read.err = agent.ErrWorkspaceReadUncertain + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF + if !valid { + w.uncertain = true + w.pending = nil + close(read.done) + s.process.Cancel() + return true + } + if event.Error != "" && event.Data == nil && event.Truncated == nil { + switch event.Error { + case "invalid": + read.err = agent.ErrWorkspaceReadInvalid + case "busy": + read.err = agent.ErrWorkspaceReadBusy + case "unavailable": + read.err = agent.ErrWorkspaceReadUnavailable + } + } else if event.Error == "" && event.Data != nil && event.Truncated != nil { + data, err := base64.StdEncoding.Strict().DecodeString(*event.Data) + if err == nil && base64.StdEncoding.EncodeToString(data) == *event.Data && len(data) <= read.maxBytes && (!*event.Truncated || len(data) == read.maxBytes) { + read.result = agent.WorkspaceReadResult{Data: data, Truncated: *event.Truncated} + read.err = nil + } + } + if read.err == agent.ErrWorkspaceReadUncertain { + w.uncertain = true + s.process.Cancel() + } + w.pending = nil + close(read.done) + return true +} + +func (s *session) stopWorkspaceReads() { + w := &s.reads + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + if w.pending != nil { + read := w.pending + w.pending = nil + w.uncertain = true + read.err = agent.ErrWorkspaceReadUncertain + close(read.done) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go new file mode 100644 index 000000000..8f8a02b1f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go @@ -0,0 +1,58 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +type liveWorkspaceRead struct { + Stage string `json:"stage"` + Path string `json:"path"` + Bytes int `json:"bytes"` + SHA256 string `json:"sha256"` + Truncated bool `json:"truncated"` +} + +func liveWorkspaceReadFixtures(t *testing.T, root string) { + t.Helper() + liveWorkspaceDirectoryFixtures(t, root) + for path, data := range map[string][]byte{"read-binary.bin": bytes.Repeat([]byte{0, 255, 128, 1}, 64), "read-empty.bin": {}, "read-large.bin": bytes.Repeat([]byte{0, 255, 128, 1}, (workspaceReadMaxBytes+40)/4)} { + if err := os.WriteFile(filepath.Join(root, path), data, 0600); err != nil { + t.Fatal(err) + } + } +} + +func liveWorkspaceReads(t *testing.T, ctx context.Context, reader agent.WorkspaceReader, root, stage string, paths ...string) []liveWorkspaceRead { + t.Helper() + liveWorkspaceDirectories(t, ctx, reader, root, stage) + var proof []liveWorkspaceRead + for _, path := range paths { + expected, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + result, err := reader.ReadWorkspaceFile(ctx, path, workspaceReadMaxBytes) + if err != nil { + t.Fatal("actual native workspace read failed", stage, path, err) + } + truncated := len(expected) > workspaceReadMaxBytes + if truncated { + expected = expected[:workspaceReadMaxBytes] + } + if !bytes.Equal(result.Data, expected) || result.Truncated != truncated { + t.Fatal("native bytes differ", stage, path, len(result.Data), result.Truncated) + } + proof = append(proof, liveWorkspaceRead{Stage: stage, Path: path, Bytes: len(result.Data), SHA256: fmt.Sprintf("%x", sha256.Sum256(result.Data)), Truncated: result.Truncated}) + } + return proof +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go new file mode 100644 index 000000000..919497dec --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go @@ -0,0 +1,203 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { + for scanner.Scan() { + var req struct { + Type, ID, Path string + Max int `json:"max_bytes"` + } + _ = json.Unmarshal(scanner.Bytes(), &req) + if req.Type == "start" { + _ = os.WriteFile(filepath.Join(state, "read-started"), []byte("{}"), 0600) + continue + } + _ = os.WriteFile(filepath.Join(state, "read-admitted"), []byte("{}"), 0600) + if mode == "read-held" { + for { + if _, err := os.Stat(filepath.Join(state, "read-release")); err == nil { + break + } + time.Sleep(time.Millisecond) + } + } + if mode == "read-exit" { + return + } + data := bytes.Repeat([]byte{0, 255, 1, 128}, req.Max/4) + if req.Path == "empty" { + data = nil + } + encoded := base64.StdEncoding.EncodeToString(data) + truncated := req.Path == "prefix" + response := map[string]any{"type": "workspace_read", "id": req.ID, "data_base64": encoded, "truncated": truncated} + switch req.Path { + case "uncertain": + response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "uncertain"} + case "bad-base64": + response["data_base64"] = "!" + case "wrong-id": + response["id"] = "other" + case "extra": + response["extra"] = true + case "oversize": + response["data_base64"] = base64.StdEncoding.EncodeToString(make([]byte, req.Max+1)) + case "invalid": + response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "invalid"} + } + _ = json.NewEncoder(os.Stdout).Encode(response) + } +} + +func readPreparation(t *testing.T, mode string) (*prepared, Config) { + t.Helper() + config := preparationFixture(t, mode) + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + t.Cleanup(func() { _ = p.Cancel(context.Background()) }) + return p, config +} + +func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { + p, _ := readPreparation(t, "read-normal") + for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { + if _, err := p.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatalf("accepted %q: %v", path, err) + } + } + for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { + if _, err := p.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { + t.Fatal(limit, err) + } + } + for _, path := range []string{"file", "prefix", "empty"} { + result, err := p.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) + expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) + if path == "empty" { + expected = nil + } + if err != nil || !bytes.Equal(result.Data, expected) || result.Truncated != (path == "prefix") { + t.Fatal(path, err, len(result.Data), result.Truncated) + } + } + if _, err := p.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { + t.Fatal(err) + } + if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceReadDetachAndTransfer(t *testing.T) { + p, config := readPreparation(t, "read-held") + ctx, detach := context.WithCancel(t.Context()) + defer detach() + result := make(chan error, 1) + go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); result <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) + detach() + if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + t.Fatal(err) + } + out := make(chan proto.Envelope, 16) + running, err := p.Start(t.Context(), "run", "hello", out) + if err != nil { + t.Fatal(err) + } + if p.Close() != nil { + t.Fatal("transferred Close failed") + } + if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + select { + case err := <-result: + t.Fatal("caller detach discarded native wait", err) + default: + } + if err := os.WriteFile(filepath.Join(config.StateDir, "read-release"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := <-result; err != nil { + t.Fatal(err) + } + waitPreparationFile(t, filepath.Join(config.StateDir, "read-started")) + if _, err := running.(agent.WorkspaceReader).ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceReadUnknownAndRelease(t *testing.T) { + for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { + t.Run(path, func(t *testing.T) { + p, _ := readPreparation(t, "read-normal") + result, err := p.ReadWorkspaceFile(t.Context(), path, 4) + if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { + t.Fatal(result, err) + } + if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + }) + } + for _, mode := range []string{"read-held", "read-exit"} { + t.Run(mode, func(t *testing.T) { + p, config := readPreparation(t, mode) + done := make(chan error, 1) + go func() { _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) + if mode == "read-held" { + if err := p.Close(); err != nil { + t.Fatal(err) + } + } + select { + case err := <-done: + if err != agent.ErrWorkspaceReadUncertain { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("native waiter lost on release") + } + }) + } +} + +func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { + p, config := readPreparation(t, "read-held") + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) + if err := <-done; err != agent.ErrWorkspaceReadUncertain { + t.Fatal(err) + } + if p.session.process.Context().Err() == nil { + t.Fatal("uncertain deadline returned before owner cancellation") + } + if _, err := p.Start(t.Context(), "late", "hello", make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Start") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go new file mode 100644 index 000000000..5f6e9338c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -0,0 +1,149 @@ +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func workspaceFixture(t *testing.T) Config { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("PARSAR_HOME", root) + for _, name := range []string{"workspace", "home", "state", "scratch", "secrets", "bin", "runtime/dist", "runtime/node_modules"} { + if err := os.MkdirAll(filepath.Join(root, name), 0o700); err != nil { + t.Fatal(err) + } + } + config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), + Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, + Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), + ScratchDir: filepath.Join(root, "scratch"), ProtectedDirs: []string{filepath.Join(root, "secrets")}, DependencyPath: filepath.Join(root, "bin")}} + for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { + if err := os.WriteFile(name, nil, 0o700); err != nil { + t.Fatal(err) + } + } + return config +} + +func workspaceRequest() proto.PromptRequestPayload { + return proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableSubagents: true, + AgentOptions: map[string]any{"model": "fixture"}} +} + +func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { + config := workspaceFixture(t) + t.Setenv("PARSAR_PARENT_SECRET", "parent-only") + t.Setenv("ANTHROPIC_API_KEY", "unselected-provider") + start, env, err := prepare(config, workspaceRequest()) + if err != nil { + t.Fatal(err) + } + if start.Cwd != config.Workspace.Directory || start.Workspace == nil || start.MCPHTTPServers != nil { + t.Fatal("trusted binding was not retained") + } + raw, _ := json.Marshal(start) + if strings.Contains(string(raw), "selected-provider-fixture") || strings.Contains(string(raw), "parent-only") { + t.Fatal("secret value entered the private request") + } + values := map[string]string{} + for _, item := range env { + name, value, _ := strings.Cut(item, "=") + values[name] = value + } + if _, ok := values["PARSAR_PARENT_SECRET"]; ok { + t.Fatal("inherited parent credential") + } + if _, ok := values["ANTHROPIC_API_KEY"]; ok { + t.Fatal("inherited unselected provider credential") + } + if values["HOME"] != config.Workspace.HomeDir || values["CLAUDE_CONFIG_DIR"] != config.StateDir || + values["TMPDIR"] != config.Workspace.ScratchDir || values["ANTHROPIC_AUTH_TOKEN"] != "selected-provider-fixture" { + t.Fatal("explicit runtime environment was not preserved") + } + entries, err := os.ReadDir(config.StateDir) + if err != nil || len(entries) != 0 { + t.Fatal("preparation created history or nested scratch") + } +} + +func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { + for _, name := range []string{"none", "remote", "work-dir", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { + t.Run(name, func(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + switch name { + case "none": + req.DisableExecutionEnvironment = true + case "remote": + req.RemoteEnvironment = &proto.RemoteEnvironment{} + case "work-dir": + req.WorkDir = config.Workspace.ScratchDir + case "mcp": + req.MCPHTTPServers = &[]proto.MCPHTTPServer{} + case "caller-policy": + req.AgentOptions["workspace"] = "override" + case "relative": + config.Workspace.Directory = "relative" + case "missing": + config.Workspace.Directory = filepath.Join(config.Workspace.Directory, "missing") + case "overlap": + config.Workspace.ScratchDir = config.StateDir + case "symlink": + alias := filepath.Join(filepath.Dir(config.StateDir), "alias") + if err := os.Symlink(config.Workspace.Directory, alias); err != nil { + t.Fatal(err) + } + config.Workspace.Directory = alias + case "rule-pattern": + config.Workspace.Directory += "*" + if err := os.Mkdir(config.Workspace.Directory, 0o700); err != nil { + t.Fatal(err) + } + case "ambient-setting": + config.Env = append(config.Env, "NODE_OPTIONS=--require=untrusted") + case "duplicate-env": + config.Env = append(config.Env, "ANTHROPIC_AUTH_TOKEN=second") + case "bad-env": + config.Env = append(config.Env, "ANTHROPIC_API_KEY=bad\x00value") + case "path-empty-component": + config.Workspace.DependencyPath += ":" + case "path-workspace": + config.Workspace.DependencyPath = config.Workspace.Directory + case "code-in-workspace": + config.Node = filepath.Join(config.Workspace.Directory, "node") + if err := os.WriteFile(config.Node, nil, 0o700); err != nil { + t.Fatal(err) + } + } + if _, _, err := prepare(config, req); err == nil { + t.Fatal("invalid binding or request accepted") + } + entries, err := os.ReadDir(config.StateDir) + if err != nil || len(entries) != 0 { + t.Fatal("rejection created execution state") + } + }) + } +} + +func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + start, _, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if start.Workspace == nil || len(start.Functions) != 1 || start.Functions[0].Name != "lookup" || start.MCPHTTPServers != nil { + t.Fatal("workspace function declaration was not retained independently of external MCP") + } +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process.go b/apps/parsar-daemon/internal/agent/clirunner/process.go new file mode 100644 index 000000000..384c2ae20 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process.go @@ -0,0 +1,161 @@ +package clirunner + +import ( + "context" + "fmt" + "io" + "os/exec" + "sync" + "syscall" + "time" +) + +type StartOptions struct { + Parent context.Context + Binary string + Args []string + Dir string + Env []string + NeedStdin bool + KillTimeout time.Duration + // OwnProcessGroup bounds the lifetime of subprocess descendants on Unix. + OwnProcessGroup bool +} + +type Process struct { + Cmd *exec.Cmd + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + killAfter time.Duration + + cancelOnce sync.Once + waitOnce sync.Once + cancelProcess func() error + waitProcess func() error +} + +func Start(opts StartOptions) (*Process, error) { + if opts.Parent == nil { + opts.Parent = context.Background() + } + if opts.Binary == "" { + return nil, fmt.Errorf("clirunner: binary required") + } + if opts.KillTimeout <= 0 { + opts.KillTimeout = 3 * time.Second + } + + if opts.OwnProcessGroup { + return startProcessGroup(opts) + } + + ctx, cancel := context.WithCancel(opts.Parent) + cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append([]string{}, opts.Env...) + } + + var stdin io.WriteCloser + var err error + if opts.NeedStdin { + stdin, err = cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("clirunner: stdin pipe: %w", err) + } + } + stdout, err := cmd.StdoutPipe() + if err != nil { + closePipe(stdin) + cancel() + return nil, fmt.Errorf("clirunner: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + closePipe(stdin) + cancel() + return nil, fmt.Errorf("clirunner: stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + closePipe(stdin) + cancel() + return nil, fmt.Errorf("clirunner: start %q: %w", opts.Binary, err) + } + + return &Process{ + Cmd: cmd, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + killAfter: opts.KillTimeout, + }, nil +} + +func (p *Process) Context() context.Context { + if p == nil || p.ctx == nil { + return context.Background() + } + return p.ctx +} + +func (p *Process) Done() <-chan struct{} { + if p == nil { + ch := make(chan struct{}) + close(ch) + return ch + } + return p.done +} + +func (p *Process) Cancel() { + if p == nil { + return + } + p.cancelOnce.Do(func() { + if p.cancelProcess != nil { + _ = p.cancelProcess() + p.cancel() + return + } + if p.Cmd != nil && p.Cmd.Process != nil { + _ = p.Cmd.Process.Signal(syscall.SIGTERM) + go func() { + select { + case <-p.done: + case <-time.After(p.killAfter): + _ = p.Cmd.Process.Signal(syscall.SIGKILL) + } + }() + } + if p.cancel != nil { + p.cancel() + } + }) +} + +func (p *Process) Wait() error { + if p == nil || p.Cmd == nil { + return nil + } + if p.waitProcess != nil { + return p.waitProcess() + } + err := p.Cmd.Wait() + p.waitOnce.Do(func() { close(p.done) }) + return err +} + +func closePipe(p io.Closer) { + if p != nil { + _ = p.Close() + } +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go new file mode 100644 index 000000000..d1e246aba --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go @@ -0,0 +1,244 @@ +//go:build linux + +package clirunner + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestOwnedGroupCancellation(t *testing.T) { + for _, mode := range []string{"graceful", "ignore-term", "leader-exits", "child-cleanup"} { + for _, parentCancel := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/parent=%t", mode, parentCancel), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dir := t.TempDir() + p := startOwnedHelper(t, ctx, mode, dir) + stdout, stderr := drainOwned(t, p) + child := waitChild(t, dir) + group, err := syscall.Getpgid(child) + if err != nil || group != p.Cmd.Process.Pid { + t.Fatalf("child group = %d, err = %v", group, err) + } + started := time.Now() + if parentCancel { + cancel() + } else { + p.Cancel() + p.Cancel() + } + <-p.Context().Done() + awaitOutput(t, stdout) + awaitOutput(t, stderr) + if mode == "ignore-term" && time.Since(started) < 300*time.Millisecond { + t.Fatal("TERM-ignoring processes exited before escalation") + } + if time.Since(started) > 3*time.Second { + t.Fatal("cancellation exceeded bound") + } + first := p.Wait() + if fmt.Sprint(p.Wait()) != fmt.Sprint(first) { + t.Fatal("Wait result changed") + } + p.Cancel() + waitExited(t, child) + if mode == "child-cleanup" { + if _, err := os.Stat(filepath.Join(dir, "child-stopped")); err != nil { + t.Fatalf("child lost its TERM cleanup grace: %v", err) + } + } + if mode == "graceful" { + for _, name := range []string{"child-stopped", "leader-stopped"} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("graceful shutdown missing %s: %v", name, err) + } + } + } + }) + } + } +} + +func TestOwnedGroupLeaderExitReleasesInheritedOutput(t *testing.T) { + dir := t.TempDir() + p := startOwnedHelper(t, context.Background(), "natural-exit", dir) + stdout, stderr := drainOwned(t, p) + child := waitChild(t, dir) + // Read to EOF before Wait, as the existing adapters do. + awaitOutput(t, stdout) + awaitOutput(t, stderr) + if err := p.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + waitExited(t, child) +} + +func TestOwnedGroupPreservesOutputAfterLeaderExit(t *testing.T) { + p := startOwnedHelper(t, context.Background(), "output", t.TempDir()) + stdout, stderr := drainOwned(t, p) + for _, stream := range []<-chan []byte{stdout, stderr} { + actual := awaitOutput(t, stream) + if !bytes.Equal(actual, bytes.Repeat([]byte("x"), 512*1024)) { + t.Fatalf("truncated output: %d bytes", len(actual)) + } + } + if err := p.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + select { + case <-p.Done(): + default: + t.Fatal("Done is not closed") + } +} + +func startOwnedHelper(t *testing.T, ctx context.Context, mode, dir string) *Process { + t.Helper() + p, err := Start(StartOptions{ + Parent: ctx, Binary: os.Args[0], + Args: []string{"-test.run=^TestOwnedGroupHelper$", "--", "leader", mode, dir}, + Env: append(os.Environ(), "GO_WANT_OWNED_GROUP=1", "GORACE=atexit_sleep_ms=0"), + OwnProcessGroup: true, KillTimeout: 300 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { p.Cancel(); _ = p.Wait() }) + return p +} + +func drainOwned(t *testing.T, p *Process) (<-chan []byte, <-chan []byte) { + t.Helper() + read := func(r io.Reader) <-chan []byte { + ch := make(chan []byte, 1) + go func() { + value, err := io.ReadAll(r) + if err != nil { + t.Errorf("read output: %v", err) + } + ch <- value + }() + return ch + } + return read(p.Stdout), read(p.Stderr) +} + +func awaitOutput(t *testing.T, ch <-chan []byte) []byte { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(5 * time.Second): + t.Fatal("output remained open") + return nil + } +} + +func waitChild(t *testing.T, dir string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + value, err := os.ReadFile(filepath.Join(dir, "child-ready")) + if err == nil { + pid, err := strconv.Atoi(string(value)) + if err == nil && pid > 0 { + return pid + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("child did not start") + return 0 +} + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if os.IsNotExist(err) { + return + } + if err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) > 0 && fields[0] == "Z" { + return + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} + +func TestOwnedGroupHelper(t *testing.T) { + if os.Getenv("GO_WANT_OWNED_GROUP") != "1" { + return + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) != 4 { + os.Exit(2) + } + role, mode, dir := args[1], args[2], args[3] + if mode == "output" { + data := bytes.Repeat([]byte("x"), 512*1024) + _, _ = os.Stdout.Write(data) + _, _ = os.Stderr.Write(data) + os.Exit(0) + } + terminated := make(chan os.Signal, 1) + if mode == "ignore-term" || role == "child" && mode != "graceful" && mode != "child-cleanup" { + signal.Ignore(syscall.SIGTERM) + } else { + signal.Notify(terminated, syscall.SIGTERM) + } + if role == "child" { + _ = os.WriteFile(filepath.Join(dir, "child-ready"), []byte(strconv.Itoa(os.Getpid())), 0o600) + if mode != "graceful" && mode != "child-cleanup" { + time.Sleep(time.Hour) + } + <-terminated + if mode == "child-cleanup" { + time.Sleep(100 * time.Millisecond) + } + _ = os.WriteFile(filepath.Join(dir, "child-stopped"), nil, 0o600) + os.Exit(0) + } + child := exec.Command(os.Args[0], "-test.run=^TestOwnedGroupHelper$", "--", "child", mode, dir) + child.Env, child.Stdout, child.Stderr = os.Environ(), os.Stdout, os.Stderr + if err := child.Start(); err != nil { + os.Exit(3) + } + if mode == "natural-exit" { + for { + if value, err := os.ReadFile(filepath.Join(dir, "child-ready")); err == nil && len(value) > 0 { + os.Exit(0) + } + time.Sleep(time.Millisecond) + } + } + if mode == "ignore-term" { + time.Sleep(time.Hour) + } + <-terminated + if mode == "leader-exits" || mode == "child-cleanup" { + os.Exit(0) + } + _ = child.Wait() + _ = os.WriteFile(filepath.Join(dir, "leader-stopped"), nil, 0o600) + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go new file mode 100644 index 000000000..8160c61a4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package clirunner + +import "errors" + +func startProcessGroup(StartOptions) (*Process, error) { + return nil, errors.New("clirunner: process-group ownership requires Unix") +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go new file mode 100644 index 000000000..c00986d46 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go @@ -0,0 +1,126 @@ +//go:build unix + +package clirunner + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +func startProcessGroup(opts StartOptions) (*Process, error) { + ctx, cancel := context.WithCancel(opts.Parent) + cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append([]string{}, opts.Env...) + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + group := &ownedGroup{cmd: cmd, killAfter: opts.KillTimeout} + cmd.Cancel = group.cancel + p := &Process{Cmd: cmd, ctx: ctx, cancel: cancel, done: make(chan struct{}), cancelProcess: group.cancel} + + // Cmd.Wait must reap the leader without closing output that consumers still need to drain. + stdout, stdoutWriter, err := os.Pipe() + if err != nil { + cancel() + return nil, fmt.Errorf("clirunner: stdout pipe: %w", err) + } + stderr, stderrWriter, err := os.Pipe() + if err != nil { + cancel() + closePipe(stdout) + closePipe(stdoutWriter) + return nil, fmt.Errorf("clirunner: stderr pipe: %w", err) + } + p.Stdout, p.Stderr = stdout, stderr + cmd.Stdout, cmd.Stderr = stdoutWriter, stderrWriter + if opts.NeedStdin { + p.Stdin, err = cmd.StdinPipe() + } + if err == nil { + err = cmd.Start() + } + closePipe(stdoutWriter) + closePipe(stderrWriter) + if err != nil { + cancel() + closePipe(p.Stdin) + closePipe(stdout) + closePipe(stderr) + return nil, fmt.Errorf("clirunner: start %q: %w", opts.Binary, err) + } + + var waitErr error + p.waitProcess = func() error { + <-p.done + closePipe(stdout) + closePipe(stderr) + return waitErr + } + go func() { + waitErr = cmd.Wait() + group.finish() + close(p.done) + }() + return p, nil +} + +type ownedGroup struct { + cmd *exec.Cmd + killAfter time.Duration + mu sync.Mutex + timer *time.Timer + graceDone chan struct{} + cancelled bool + finished bool +} + +func (g *ownedGroup) cancel() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.finished { + return os.ErrProcessDone + } + if g.cancelled { + return nil + } + g.cancelled = true + err := syscall.Kill(-g.cmd.Process.Pid, syscall.SIGTERM) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + g.graceDone = make(chan struct{}) + g.timer = time.AfterFunc(g.killAfter, func() { + g.mu.Lock() + defer g.mu.Unlock() + if !g.finished { + _ = syscall.Kill(-g.cmd.Process.Pid, syscall.SIGKILL) + } + close(g.graceDone) + }) + return err +} + +func (g *ownedGroup) finish() { + g.mu.Lock() + if g.graceDone != nil && !errors.Is(syscall.Kill(-g.cmd.Process.Pid, 0), syscall.ESRCH) { + // Descendants retain their TERM grace even when the leader has already exited. + graceDone := g.graceDone + g.mu.Unlock() + <-graceDone + g.mu.Lock() + } + defer g.mu.Unlock() + g.finished = true + if g.timer != nil { + g.timer.Stop() + } + // A leader can exit while a descendant still holds the output pipes open. + _ = syscall.Kill(-g.cmd.Process.Pid, syscall.SIGKILL) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_test.go new file mode 100644 index 000000000..774f6be7c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_test.go @@ -0,0 +1,78 @@ +package clirunner + +import ( + "context" + "os" + "testing" + "time" +) + +func TestStartWaitClosesDone(t *testing.T) { + proc, err := Start(StartOptions{ + Parent: context.Background(), + Binary: helperBinary(), + Args: []string{"-test.run=TestHelperProcess", "--", "exit"}, + Env: helperEnv(), + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + if err := proc.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + select { + case <-proc.Done(): + case <-time.After(time.Second): + t.Fatal("Done did not close") + } +} + +func TestCancelCancelsContext(t *testing.T) { + proc, err := Start(StartOptions{ + Parent: context.Background(), + Binary: helperBinary(), + Args: []string{"-test.run=TestHelperProcess", "--", "sleep"}, + Env: helperEnv(), + KillTimeout: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + proc.Cancel() + select { + case <-proc.Context().Done(): + case <-time.After(time.Second): + t.Fatal("context was not cancelled") + } + _ = proc.Wait() +} + +func helperBinary() string { + return os.Args[0] +} + +func helperEnv() []string { + return append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") +} + +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) < 2 { + os.Exit(2) + } + switch args[1] { + case "exit": + os.Exit(0) + case "sleep": + time.Sleep(10 * time.Second) + os.Exit(0) + default: + os.Exit(2) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/approval_policy.go b/apps/parsar-daemon/internal/agent/codex/approval_policy.go new file mode 100644 index 000000000..d11766cfa --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/approval_policy.go @@ -0,0 +1,85 @@ +package codex + +import ( + "encoding/json" + "fmt" +) + +// SilentGranularPolicy disables every granular Codex approval gate. +func SilentGranularPolicy() AskForApproval { + g := GranularAskForApproval{} // zero value = every gate false + return AskForApproval{Granular: &g} +} + +// HumanApprovalPolicy lets Codex request sandbox escalation from Parsar. +func HumanApprovalPolicy() AskForApproval { + return AskForApproval{String: "on-request"} +} + +// IsSilent reports whether p suppresses every approval surface. The +// daemon logs this distinction when it starts a thread. Server-request +// handlers are always registered so explicit policy overrides cannot leave +// Codex waiting on an unhandled request. +// +// A nil pointer is treated as silent — the safe default when callers +// forget to wire a policy. +func IsSilent(p *AskForApproval) bool { + if p == nil { + return true + } + if p.Granular != nil { + g := p.Granular + return !g.SandboxApproval && !g.Rules && !g.SkillApproval && + !g.RequestPermissions && !g.MCPElicitations + } + // Bare string variants ("never" is silent; everything else surfaces). + return p.String == "never" +} + +// MarshalJSON emits codex-rs's discriminated union — either a bare +// string or the granular object. Producing both would deserialise to +// the granular branch on the codex side (it wins precedence in +// codex-rs/protocol.rs AskForApproval), but the wire would be wrong; we +// validate exclusivity here. +func (a AskForApproval) MarshalJSON() ([]byte, error) { + hasString := a.String != "" + hasGranular := a.Granular != nil + if hasString && hasGranular { + return nil, fmt.Errorf("codex: AskForApproval must set exactly one of String or Granular, got both") + } + if hasString { + return json.Marshal(a.String) + } + if hasGranular { + return json.Marshal(struct { + Granular *GranularAskForApproval `json:"granular"` + }{Granular: a.Granular}) + } + // Neither set — preserve the historical zero-value encoding. Production + // plans always set a policy explicitly; an empty marshal would + // produce `null`, which codex-rs rejects. + return json.Marshal(struct { + Granular GranularAskForApproval `json:"granular"` + }{}) +} + +// UnmarshalJSON is the inverse: codex's ThreadStartResult.approvalPolicy +// echoes back as either a string or {"granular":...}. Provided so tests +// can round-trip without surprises; production code only marshals. +func (a *AskForApproval) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + a.String = s + a.Granular = nil + return nil + } + var obj struct { + Granular *GranularAskForApproval `json:"granular"` + } + if err := json.Unmarshal(data, &obj); err != nil { + return fmt.Errorf("codex: AskForApproval: %w", err) + } + a.Granular = obj.Granular + a.String = "" + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/approval_policy_test.go b/apps/parsar-daemon/internal/agent/codex/approval_policy_test.go new file mode 100644 index 000000000..7a98f06cd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/approval_policy_test.go @@ -0,0 +1,142 @@ +package codex + +import ( + "encoding/json" + "testing" +) + +func TestSilentGranularPolicy_AllFalse(t *testing.T) { + p := SilentGranularPolicy() + if p.Granular == nil { + t.Fatal("SilentGranularPolicy must populate Granular") + } + g := *p.Granular + if g.SandboxApproval || g.Rules || g.SkillApproval || g.RequestPermissions || g.MCPElicitations { + t.Fatalf("silent policy must have every gate false, got %+v", g) + } + if !IsSilent(&p) { + t.Fatal("IsSilent must return true for the silent default") + } +} + +func TestHumanApprovalPolicy_UsesOnRequest(t *testing.T) { + p := HumanApprovalPolicy() + if p.String != "on-request" || p.Granular != nil { + t.Fatalf("HumanApprovalPolicy = %+v, want on-request string policy", p) + } + if IsSilent(&p) { + t.Fatal("HumanApprovalPolicy must surface app-server requests") + } +} + +func TestIsSilent_NilIsSilent(t *testing.T) { + if !IsSilent(nil) { + t.Fatal("nil policy must be treated as silent (safe default)") + } +} + +func TestIsSilent_StringPolicies(t *testing.T) { + cases := []struct { + name string + policy AskForApproval + want bool + }{ + {"never silences", AskForApproval{String: "never"}, true}, + {"on-request loud", AskForApproval{String: "on-request"}, false}, + {"untrusted loud", AskForApproval{String: "untrusted"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsSilent(&tc.policy); got != tc.want { + t.Fatalf("IsSilent(%+v) = %v, want %v", tc.policy, got, tc.want) + } + }) + } +} + +func TestIsSilent_PartialGranularIsLoud(t *testing.T) { + // Flipping any single gate true must make IsSilent report false so + // the daemon registers the loud server-request handler. + for _, flip := range []func(*GranularAskForApproval){ + func(g *GranularAskForApproval) { g.SandboxApproval = true }, + func(g *GranularAskForApproval) { g.Rules = true }, + func(g *GranularAskForApproval) { g.SkillApproval = true }, + func(g *GranularAskForApproval) { g.RequestPermissions = true }, + func(g *GranularAskForApproval) { g.MCPElicitations = true }, + } { + g := GranularAskForApproval{} + flip(&g) + p := AskForApproval{Granular: &g} + if IsSilent(&p) { + t.Fatalf("IsSilent must be false when any gate is true: %+v", g) + } + } +} + +func TestAskForApproval_MarshalString(t *testing.T) { + p := AskForApproval{String: "never"} + raw, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(raw) != `"never"` { + t.Fatalf("marshal string variant = %s", raw) + } +} + +func TestAskForApproval_MarshalGranular(t *testing.T) { + g := GranularAskForApproval{RequestPermissions: true} + p := AskForApproval{Granular: &g} + raw, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"granular":{"sandbox_approval":false,"rules":false,"skill_approval":false,"request_permissions":true,"mcp_elicitations":false}}` + if string(raw) != want { + t.Fatalf("marshal granular = %s\nwant = %s", raw, want) + } +} + +func TestAskForApproval_MarshalEmpty_DefaultsSilent(t *testing.T) { + // Neither field set — must not produce `null` (codex rejects it). + var p AskForApproval + raw, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(raw) == "null" { + t.Fatalf("empty AskForApproval must not marshal to null: %s", raw) + } + if string(raw) != `{"granular":{"sandbox_approval":false,"rules":false,"skill_approval":false,"request_permissions":false,"mcp_elicitations":false}}` { + t.Fatalf("empty AskForApproval marshal = %s", raw) + } +} + +func TestAskForApproval_MarshalBothErrors(t *testing.T) { + g := GranularAskForApproval{} + p := AskForApproval{String: "never", Granular: &g} + if _, err := json.Marshal(p); err == nil { + t.Fatal("setting both String and Granular must error on marshal") + } +} + +func TestAskForApproval_RoundTripString(t *testing.T) { + var got AskForApproval + if err := json.Unmarshal([]byte(`"on-request"`), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.String != "on-request" || got.Granular != nil { + t.Fatalf("round-trip = %+v", got) + } +} + +func TestAskForApproval_RoundTripGranular(t *testing.T) { + src := `{"granular":{"sandbox_approval":true,"rules":false,"skill_approval":false,"request_permissions":false,"mcp_elicitations":false}}` + var got AskForApproval + if err := json.Unmarshal([]byte(src), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Granular == nil || !got.Granular.SandboxApproval || got.String != "" { + t.Fatalf("round-trip = %+v", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go b/apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go new file mode 100644 index 000000000..021de0ea5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go @@ -0,0 +1,51 @@ +package codex + +import ( + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type cancellationOutcomeState struct { + notificationMu sync.Mutex + mu sync.Mutex + terminal *proto.DonePayload +} + +func (s *Session) rememberOutcome(outcome proto.DonePayload) proto.DonePayload { + s.usageMu.Lock() + if outcome.Usage.Provider == "" && s.latestUsage != nil { + outcome.Usage = s.usagePayload(*s.latestUsage) + } + s.usageMu.Unlock() + s.outcome.mu.Lock() + s.outcome.terminal = &outcome + s.outcome.mu.Unlock() + return outcome +} + +// CancellationOutcome remains readable after Cancel stops the native process. +func (s *Session) CancellationOutcome() proto.DonePayload { + s.outcome.notificationMu.Lock() + defer s.outcome.notificationMu.Unlock() + s.outcome.mu.Lock() + terminal := s.outcome.terminal + s.outcome.mu.Unlock() + if terminal != nil { + return *terminal + } + outcome := proto.DonePayload{Metadata: map[string]any{}} + if id := s.currentThreadID(); id != "" { + outcome.Metadata[proto.DoneMetaAgentSessionID] = id + outcome.Metadata[proto.DoneMetaAgentSessionType] = "codex_thread" + } + s.finalTextMu.Lock() + outcome.Content = s.finalText + s.finalTextMu.Unlock() + s.usageMu.Lock() + if usage := s.latestUsage; usage != nil { + outcome.Usage = s.usagePayload(*usage) + } + s.usageMu.Unlock() + return outcome +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go new file mode 100644 index 000000000..a459db36b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment.go @@ -0,0 +1,35 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" +) + +// Check the native provider instead of assuming an older binary honors the flag. +func verifyNoExecutionEnvironment(ctx context.Context, rpc *JSONRPCClient) error { + for _, id := range []string{"local", "remote"} { + status, err := nativeEnvironmentStatus(ctx, rpc, id) + if err != nil { + return fmt.Errorf("codex: cannot confirm disabled execution environment: %w", err) + } + if status != "unknown" { + return fmt.Errorf("codex: execution environment %s was not disabled", id) + } + } + return nil +} + +func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) (string, error) { + raw, err := rpc.Request(ctx, "environment/status", map[string]string{"environmentId": id}) + if err != nil { + return "", err + } + var result struct { + Status string `json:"status"` + } + if json.Unmarshal(raw, &result) != nil || result.Status == "" { + return "", fmt.Errorf("codex: invalid native environment status") + } + return result.Status, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go new file mode 100644 index 000000000..6ca9c1ce1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -0,0 +1,27 @@ +package codex + +import ( + "os" + "runtime" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" +) + +// SupportsLocalEnvironment checks deployment prerequisites, not public admission. +func SupportsLocalEnvironment(version string) bool { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsRemoteEnvironment(version) || os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("PARSAR_CODEX_HARNESS_BIN") != "" { + return false + } + binding, err := localworkspace.Load() + return err == nil && binding != nil +} + +// SupportsLocalNetworkPolicy describes the qualified adapter and bound policy; +// actual native preparation still validates the managed requirements. +func SupportsLocalNetworkPolicy(version string) bool { + if !SupportsLocalEnvironment(version) || os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE") != "managed-workspace" { + return false + } + binding, err := localworkspace.Load() + return err == nil && binding != nil && binding.NetworkAccess() != "" +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote.go b/apps/parsar-daemon/internal/agent/codex/environment_remote.go new file mode 100644 index 000000000..aef9b1264 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_remote.go @@ -0,0 +1,71 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// SupportsRemoteEnvironment admits the native protocol verified by the placement +// and daemon probes. Each binding still needs a native connection/readiness check. +func SupportsRemoteEnvironment(version string) bool { + return strings.TrimSpace(version) == "codex-cli 0.153.4" +} + +// EnvironmentSelection mirrors the native app-server selection. Resume does not +// retain this selection; send it with every turn/start, including cold resumes. +type EnvironmentSelection struct { + EnvironmentID string `json:"environmentId"` + Cwd string `json:"cwd"` + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` +} + +func configureRemoteEnvironment(plan *SessionPlan, environment proto.RemoteEnvironment) { + plan.Env = append(plan.Env, + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL="+strings.TrimRight(environment.ConnectionURL, "/"), + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID="+environment.ID, + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN="+environment.ConnectionToken) + plan.Environments = []EnvironmentSelection{{ + EnvironmentID: "remote", Cwd: environment.WorkspaceDirectory, + RuntimeWorkspaceRoots: []string{environment.WorkspaceDirectory}, + }} + configureRestrictedShellEnvironment(plan) +} + +func configureRestrictedShellEnvironment(plan *SessionPlan) { + plan.ExtraConfig = append(plan.ExtraConfig, + [2]string{"shell_environment_policy.inherit", `"core"`}, + [2]string{"shell_environment_policy.ignore_default_excludes", "false"}) +} + +func verifyRemoteEnvironment(parent context.Context, rpc *JSONRPCClient) error { + ctx, cancel := context.WithTimeout(parent, 30*time.Second) + defer cancel() + status, err := nativeEnvironmentStatus(ctx, rpc, "local") + if err != nil || status != "unknown" { + return errors.New("codex: cannot confirm absence of local execution fallback") + } + // environment/info establishes the native encrypted connection. Status alone + // observes a lazy pending environment without connecting or recovering it. + raw, err := rpc.Request(ctx, "environment/info", map[string]string{"environmentId": "remote"}) + if err != nil { + return errors.New("codex: remote environment connection failed") + } + var info struct { + Shell struct { + Path string `json:"path"` + } `json:"shell"` + } + if json.Unmarshal(raw, &info) != nil || info.Shell.Path == "" { + return errors.New("codex: invalid remote environment information") + } + status, err = nativeEnvironmentStatus(ctx, rpc, "remote") + if err != nil || status != "ready" { + return errors.New("codex: remote environment is not ready") + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go new file mode 100644 index 000000000..c8dd64910 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go @@ -0,0 +1,130 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestRemoteEnvironmentRequiresNativeReadiness(t *testing.T) { + for _, mode := range []string{"ready", "local fallback", "connection rejected", "invalid info", "pending", "disconnected"} { + t.Run(mode, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- verifyRemoteEnvironment(ctx, client.JSONRPCClient) }() + for index, method := range []string{"environment/status", "environment/info", "environment/status"} { + var request struct { + ID, Method string + Params map[string]string + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + id := "remote" + if index == 0 { + id = "local" + } + if request.Method != method || request.Params["environmentId"] != id { + t.Fatal(request) + } + var result any = map[string]string{"status": "unknown"} + stop := false + if index == 0 && mode == "local fallback" { + result = map[string]string{"status": "ready"} + stop = true + } + if index == 1 { + result = map[string]any{"shell": map[string]string{"path": "/bin/bash"}} + } + if index == 1 && mode == "invalid info" { + result = map[string]any{} + stop = true + } + if index == 2 { + status := "ready" + if mode == "pending" || mode == "disconnected" { + status = mode + } + result = map[string]string{"status": status} + } + reply := map[string]any{"id": request.ID, "result": result} + if index == 1 && mode == "connection rejected" { + delete(reply, "result") + reply["error"] = map[string]any{"code": -32603, "message": "connection denied"} + stop = true + } + if err := json.NewEncoder(server.ToClient).Encode(reply); err != nil { + t.Fatal(err) + } + if stop { + break + } + } + if err := <-done; (err == nil) != (mode == "ready") { + t.Fatalf("%s: %v", mode, err) + } + }) + } +} + +func TestRemoteEnvironmentSelectedForFirstAndResumedTurns(t *testing.T) { + for _, resume := range []bool{false, true} { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), out: make(chan proto.Envelope, 8), bufs: NewItemBuffers(), waitDone: make(chan struct{}), cleanup: func() {}, interactions: newPendingCodexInteractions()} + plan := SessionPlan{Cwd: "/local-harness", Environments: []EnvironmentSelection{{EnvironmentID: "remote", Cwd: "/executor-only", RuntimeWorkspaceRoots: []string{"/executor-only"}}}} + req := proto.PromptRequestPayload{Prompt: "remote work", StrictResume: true} + method := "thread/start" + if resume { + req.AgentSessionID = "native-thread" + method = "thread/resume" + } + go s.run(plan, req) + for index, expected := range []string{method, "turn/start"} { + var request struct { + ID, Method string + Params struct { + Environments []EnvironmentSelection `json:"environments"` + Cwd string `json:"cwd"` + ThreadID string `json:"threadId"` + } + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != expected { + t.Fatal(request.Method) + } + if index == 1 || !resume { + if len(request.Params.Environments) != 1 || request.Params.Environments[0].EnvironmentID != "remote" || request.Params.Environments[0].Cwd != "/executor-only" { + t.Fatal("native request omitted remote selection") + } + } else if len(request.Params.Environments) != 0 { + t.Fatal("resume invented unsupported environments field") + } + if index == 0 && !resume && request.Params.Cwd != "/local-harness" { + t.Fatal("thread/start changed local cwd") + } + if index == 1 && request.Params.ThreadID != "native-thread" { + t.Fatal("turn lost native identity") + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]string{"id": "native-thread"}}}); err != nil { + t.Fatal(err) + } + } + cancel() + select { + case <-s.waitDone: + case <-time.After(time.Second): + t.Fatal("native run did not stop") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go new file mode 100644 index 000000000..836922d52 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go @@ -0,0 +1,94 @@ +package codex + +import ( + "errors" + "net" + "net/url" + "os" + "path" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func validateRemoteEnvironmentRequest(req proto.PromptRequestPayload) error { + environment := req.RemoteEnvironment + if environment == nil { + return nil + } + if req.DisableExecutionEnvironment { + return errors.New("codex: remote environment conflicts with environment none") + } + if !req.ReleaseOnCompletion || !req.StrictResume || strings.TrimSpace(req.AgentStateKey) == "" { + return errors.New("codex: remote environment requires completion release, strict resume and a stable state key") + } + if req.WorkspaceAuthoring || len(req.Attachments) != 0 { + return errors.New("codex: remote authoring and attachments are not supported") + } + for _, key := range []string{"skills", "mcp_servers", "plugin_dirs"} { + if hasLocalEnvironmentOption(req.AgentOptions[key]) { + return errors.New("codex: remote environment does not support local managed skills, MCP or plugins") + } + } + if environment.ID == "" || environment.ID == "." || environment.ID == ".." || url.PathEscape(environment.ID) != environment.ID { + return errors.New("codex: remote environment requires a valid identity") + } + if !path.IsAbs(environment.WorkspaceDirectory) || strings.ContainsAny(environment.WorkspaceDirectory, "\x00\r\n\\") { + return errors.New("codex: remote workspace must be an absolute POSIX path") + } + if strings.TrimSpace(environment.ConnectionToken) == "" || strings.ContainsAny(environment.ConnectionToken, "\x00\r\n") { + return errors.New("codex: remote environment requires a valid connection credential") + } + u, err := url.Parse(environment.ConnectionURL) + if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return errors.New("codex: remote connection URL must be an absolute origin without credentials") + } + switch u.Scheme { + case "https": + case "http": + ip := net.ParseIP(u.Hostname()) + if !strings.EqualFold(u.Hostname(), "localhost") && (ip == nil || !ip.IsLoopback()) { + return errors.New("codex: remote HTTP connections require loopback") + } + default: + return errors.New("codex: remote connection requires HTTPS or loopback HTTP") + } + for _, entry := range os.Environ() { + key, value, _ := strings.Cut(entry, "=") + if reservedRemoteEnvironmentVariable(key) && value != "" { + return errors.New("codex: remote environment conflicts with native transport process configuration") + } + } + env, err := buildSessionEnv(req.AgentOptions) + if err != nil { + return err + } + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + if reservedRemoteEnvironmentVariable(key) { + return errors.New("codex: remote environment conflicts with native transport agent options") + } + } + return nil +} + +func reservedRemoteEnvironmentVariable(key string) bool { + return strings.HasPrefix(strings.ToUpper(key), "CODEX_EXEC_SERVER_") +} + +func hasLocalEnvironmentOption(value any) bool { + switch v := value.(type) { + case nil: + return false + case []any: + return len(v) != 0 + case []string: + return len(v) != 0 + case map[string]any: + return len(v) != 0 + case map[string]string: + return len(v) != 0 + default: + return true + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go new file mode 100644 index 000000000..ecd52345e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go @@ -0,0 +1,132 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func remoteEnvironmentRequest() proto.PromptRequestPayload { + return proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "remote-test", ReleaseOnCompletion: true, StrictResume: true, + RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment-test", WorkspaceDirectory: "/executor-only", + ConnectionURL: "https://registry.example", ConnectionToken: "synthetic-harness-token"}} +} + +func TestRemoteEnvironmentValidatesBeforeLocalSetup(t *testing.T) { + cases := map[string]func(*proto.PromptRequestPayload){ + "none conflict": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, + "retained harness": func(r *proto.PromptRequestPayload) { r.ReleaseOnCompletion = false }, + "silent new thread": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, + "missing state": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "" }, + "authoring": func(r *proto.PromptRequestPayload) { r.WorkspaceAuthoring = true }, + "skills": func(r *proto.PromptRequestPayload) { r.AgentOptions = map[string]any{"skills": []any{"local"}} }, + "MCP": func(r *proto.PromptRequestPayload) { + r.AgentOptions = map[string]any{"mcp_servers": map[string]any{"local": map[string]any{}}} + }, + "plugins": func(r *proto.PromptRequestPayload) { + r.AgentOptions = map[string]any{"plugin_dirs": []string{"/local"}} + }, + "identity": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ID = "../another" }, + "relative workspace": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.WorkspaceDirectory = "relative" }, + "home workspace": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.WorkspaceDirectory = "~/remote" }, + "URL credentials": func(r *proto.PromptRequestPayload) { + r.RemoteEnvironment.ConnectionURL = "https://private:secret@registry.example" + }, + "URL query": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionURL += "?token=secret" }, + "plaintext remote": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionURL = "http://registry.example" }, + "missing token": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionToken = "" }, + "invalid token": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionToken += "\n" }, + "transport options": func(r *proto.PromptRequestPayload) { + r.AgentOptions = map[string]any{"env": map[string]any{"CODEX_EXEC_SERVER_URL": "none"}} + }, + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + r := remoteEnvironmentRequest() + r.WorkDir = filepath.Join(t.TempDir(), "must-not-be-created") + change(&r) + _, err := newSession(context.Background(), r, make(chan proto.Envelope, 8), defaultSessionConfig()) + if err == nil || strings.Contains(err.Error(), "synthetic-harness-token") || strings.Contains(err.Error(), "private:secret") { + t.Fatalf("invalid or unsafe rejection: %v", err) + } + if _, err := os.Stat(r.WorkDir); !os.IsNotExist(err) { + t.Fatal("invalid binding reached local setup") + } + }) + } +} + +func TestRemoteEnvironmentRejectsAmbientTransport(t *testing.T) { + t.Setenv("CODEX_EXEC_SERVER_URL", "none") + if err := validateRemoteEnvironmentRequest(remoteEnvironmentRequest()); err == nil { + t.Fatal("ambient native transport accepted") + } +} + +func TestRemoteEnvironmentKeepsPathsAndCredentialsSeparate(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + r := remoteEnvironmentRequest() + r.WorkDir = filepath.Join(home, "harness") + r.RemoteEnvironment.ConnectionURL = "http://127.0.0.1:34567/" + r.RemoteEnvironment.WorkspaceDirectory = "/remote-environment-" + filepath.Base(home) + r.AgentOptions = map[string]any{"skills": []any{}, "mcp_servers": map[string]any{}} + if err := validateRemoteEnvironmentRequest(r); err != nil { + t.Fatal(err) + } + plan, skillRoot, err := prepareSessionPlan(t.Context(), r, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if plan.Cwd != r.WorkDir || skillRoot != "" || len(plan.Environments) != 1 || plan.Environments[0].Cwd != r.RemoteEnvironment.WorkspaceDirectory || plan.Environments[0].EnvironmentID != "remote" { + t.Fatal("remote execution changed harness cwd or installed local skills") + } + if _, err := os.Stat(r.RemoteEnvironment.WorkspaceDirectory); !os.IsNotExist(err) { + t.Fatal("remote workspace exists on the harness host") + } + env := map[string]string{} + for _, entry := range plan.Env { + key, value, _ := strings.Cut(entry, "=") + env[key] = value + } + if env["CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"] != r.RemoteEnvironment.ConnectionToken || env["CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"] != r.RemoteEnvironment.ID || env["CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"] != "http://127.0.0.1:34567" { + t.Fatal("native launch did not consume the transient connection") + } + config := map[string]string{} + for _, pair := range plan.ExtraConfig { + config[pair[0]] = pair[1] + } + if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { + t.Fatal("credential inheritance policy is not explicit") + } + if err := filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + data, err := os.ReadFile(path) + if strings.Contains(string(data), r.RemoteEnvironment.ConnectionToken) { + t.Errorf("connection credential persisted in %s", path) + } + return err + }); err != nil { + t.Fatal(err) + } + wire, err := json.Marshal(plan.Environments) + if err != nil || strings.Contains(string(wire), r.RemoteEnvironment.ConnectionToken) { + t.Fatal("secret in native selection") + } +} + +func TestRemoteEnvironmentCapabilityRequiresVerifiedVersion(t *testing.T) { + for _, version := range []string{"", "codex-cli 0.141.0", "codex-cli 0.153.4", "codex-cli 0.154.0", "some binary"} { + if SupportsRemoteEnvironment(version) != (version == "codex-cli 0.153.4") { + t.Fatalf("unexpected capability for %q", version) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_test.go b/apps/parsar-daemon/internal/agent/codex/environment_test.go new file mode 100644 index 000000000..53b88379d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_test.go @@ -0,0 +1,43 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +func TestNoEnvironmentRequiresNativeConfirmation(t *testing.T) { + for _, status := range []string{"unknown", "ready", "pending", "disconnected", ""} { + t.Run(status, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- verifyNoExecutionEnvironment(ctx, client.JSONRPCClient) }() + for _, id := range []string{"local", "remote"} { + var req struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]string `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "environment/status" || req.Params["environmentId"] != id { + t.Fatal(req) + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": req.ID, "result": map[string]string{"status": status}}); err != nil { + t.Fatal(err) + } + if status != "unknown" { + break + } + } + if err := <-done; (err == nil) != (status == "unknown") { + t.Fatalf("status %q: %v", status, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/execution_controls.go b/apps/parsar-daemon/internal/agent/codex/execution_controls.go new file mode 100644 index 000000000..bd7fb8c0e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/execution_controls.go @@ -0,0 +1,26 @@ +package codex + +import ( + "maps" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executionOptions(req proto.PromptRequestPayload) map[string]any { + if req.ExecutionControls == nil && req.MCPHTTPServers == nil { + return req.AgentOptions + } + options := maps.Clone(req.AgentOptions) + if options == nil { + options = make(map[string]any) + } + if req.ExecutionControls != nil { + // Reuse native validation/catalog handling, overriding lower-priority operator options. + options["web_search"] = req.ExecutionControls.WebSearch + options["model_verbosity"] = req.ExecutionControls.TextVerbosity + } + if req.MCPHTTPServers != nil { + delete(options, "mcp_servers") + } + return options +} diff --git a/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go b/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go new file mode 100644 index 000000000..8e7a55adb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go @@ -0,0 +1,57 @@ +package codex + +import ( + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutionControlsOverrideWithoutMutatingNativeOptions(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + original := map[string]any{"model": "test-model", "web_search": "live", "model_verbosity": "high"} + request := proto.PromptRequestPayload{AgentOptions: original} + if got := executionOptions(request); !reflect.DeepEqual(got, original) { + t.Fatal("ordinary options changed") + } + for _, search := range []string{"disabled", "cached", "live"} { + for _, verbosity := range []string{"low", "medium", "high"} { + request.ExecutionControls = &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity} + options := executionOptions(request) + if options["model"] != "test-model" || original["web_search"] != "live" || original["model_verbosity"] != "high" { + t.Fatal("operator options mutated") + } + plan, err := BuildSessionPlan("run", "state", "", options) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"web_search": `"` + search + `"`, "model_verbosity": `"` + verbosity + `"`} + for _, kv := range plan.ExtraConfig { + if v, ok := want[kv[0]]; ok { + if kv[1] != v { + t.Fatal("native control differs", kv) + } + delete(want, kv[0]) + } + } + plan.Cleanup() + if len(want) != 0 { + t.Fatal("native settings omitted", want) + } + } + } +} + +func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + for _, controls := range []proto.ExecutionControls{ + {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, + {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, + } { + options := executionOptions(proto.PromptRequestPayload{ExecutionControls: &controls}) + if plan, err := BuildSessionPlan("run", "state", "", options); err == nil { + plan.Cleanup() + t.Fatal("invalid controls accepted", controls) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/export_test.go b/apps/parsar-daemon/internal/agent/codex/export_test.go new file mode 100644 index 000000000..0a8fa46b4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/export_test.go @@ -0,0 +1,124 @@ +package codex + +import ( + "context" + "encoding/json" + "io" +) + +// TestClient is a JSONRPCClient stand-in driven by in-memory pipes. +// Lets unit tests exercise the wire dispatch (response / notification / +// server-request branches) without spawning a real codex binary. +// +// Returned from NewTestClient as a triple: +// +// (client, ServerSide, cleanup) +// +// ServerSide is the codex-side view: Read() returns what the daemon +// wrote (outgoing frames), Write() injects what codex would have sent. +// cleanup tears down both halves so the test can defer it. +type TestClient struct { + *JSONRPCClient + serverIn *io.PipeReader + serverOut *io.PipeWriter + clientIn *io.PipeReader + clientOut *io.PipeWriter +} + +// ServerSide is the half of the pipe pair the test owns. +type ServerSide struct { + // FromClient reads frames the JSONRPCClient sent (what would have + // gone to codex's stdin). + FromClient *io.PipeReader + // ToClient writes frames the test wants the JSONRPCClient to receive + // (what would have come from codex's stdout). + ToClient *io.PipeWriter +} + +// NewTestClient builds a JSONRPCClient wired to a pair of in-memory pipes. +// The returned cleanup func closes everything; defer it. +func NewTestClient() (*TestClient, ServerSide, func()) { + daemonStdinR, daemonStdinW := io.Pipe() + daemonStdoutR, daemonStdoutW := io.Pipe() + + cfg := JSONRPCConfig{LogTag: "codex-rpc-test"} + c := NewJSONRPCClient(cfg) + c.stdin = daemonStdinW + c.stdout = daemonStdoutR + c.stderr = io.NopCloser(emptyReader{}) + c.mu.Lock() + c.alive = true + c.mu.Unlock() + go c.readStdoutLoop() + + tc := &TestClient{ + JSONRPCClient: c, + serverIn: daemonStdinR, + serverOut: daemonStdoutW, + clientIn: daemonStdoutR, + clientOut: daemonStdinW, + } + cleanup := func() { + _ = daemonStdinW.Close() + _ = daemonStdinR.Close() + _ = daemonStdoutW.Close() + _ = daemonStdoutR.Close() + c.mu.Lock() + c.alive = false + c.mu.Unlock() + } + return tc, ServerSide{FromClient: daemonStdinR, ToClient: daemonStdoutW}, cleanup +} + +type emptyReader struct{} + +func (emptyReader) Read(_ []byte) (int, error) { return 0, io.EOF } + +// SendCannedResponse reads one outgoing frame from the client, then +// writes a {"id":,"result":} reply. Returns the method the +// client sent (for assertions). ctx reserved for future cancellation. +func SendCannedResponse(_ context.Context, srv ServerSide, result any) (method string, err error) { + decoder := json.NewDecoder(srv.FromClient) + var req struct { + ID string `json:"id"` + Method string `json:"method"` + } + if err := decoder.Decode(&req); err != nil { + return "", err + } + resp := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": result, + } + body, _ := json.Marshal(resp) + body = append(body, '\n') + _, err = srv.ToClient.Write(body) + return req.Method, err +} + +// SendNotification pushes a notification (no id) to the client. +func SendNotification(srv ServerSide, method string, params any) error { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "method": method, + "params": params, + }) + body = append(body, '\n') + _, err := srv.ToClient.Write(body) + return err +} + +// SendServerRequest pushes a server-initiated request and returns when +// the frame is on the wire. +func SendServerRequest(srv ServerSide, id string, method string, params any) error { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + body = append(body, '\n') + _, err := srv.ToClient.Write(body) + return err +} diff --git a/apps/parsar-daemon/internal/agent/codex/functions.go b/apps/parsar-daemon/internal/agent/codex/functions.go new file mode 100644 index 000000000..0de25946b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/functions.go @@ -0,0 +1,147 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type dynamicFunctionTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +type functionCalls struct { + mu sync.Mutex + definitions []dynamicFunctionTool + names map[string]bool + pending map[string]any + closed bool +} + +func prepareFunctionTools(tools []proto.FunctionTool) (*functionCalls, error) { + state := &functionCalls{names: map[string]bool{}, pending: map[string]any{}} + if len(tools) > 64 { + return nil, errors.New("at most 64 function tools are supported") + } + for _, tool := range tools { + var schema map[string]any + if strings.TrimSpace(tool.Name) == "" || state.names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, errors.New("function tools require unique names and object schemas") + } + state.names[tool.Name] = true + state.definitions = append(state.definitions, dynamicFunctionTool{Type: "function", Name: tool.Name, Description: tool.Description, InputSchema: append(json.RawMessage(nil), tool.Parameters...)}) + } + return state, nil +} + +func (s *Session) handleFunctionCall(raw json.RawMessage, rpcID any) (any, error) { + var call struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + CallID string `json:"callId"` + Namespace *string `json:"namespace"` + Tool string `json:"tool"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(raw, &call); err != nil { + return nil, err + } + if s.functions == nil || !s.functions.names[call.Tool] || call.Namespace != nil || call.CallID == "" || call.ThreadID != s.currentThreadID() || call.TurnID == "" || !json.Valid(call.Arguments) { + return nil, errors.New("unexpected function call") + } + s.functions.mu.Lock() + if s.functions.closed || s.cancelled.Load() || s.terminal.Load() || s.functions.pending[call.CallID] != nil || len(s.functions.pending) >= 64 { + s.functions.mu.Unlock() + return nil, errors.New("function call cannot be admitted") + } + s.functions.pending[call.CallID] = rpcID + s.functions.mu.Unlock() + env, err := proto.NewEnvelope(proto.TypeFunctionCall, s.runID, proto.FunctionCallPayload{CallID: call.CallID, Name: call.Tool, Arguments: call.Arguments}) + if err == nil { + err = s.sendFunctionCall(env) + } + if err != nil { + s.functions.mu.Lock() + delete(s.functions.pending, call.CallID) + s.functions.mu.Unlock() + return nil, err + } + return DeferReply, nil +} + +func (s *Session) sendFunctionCall(env proto.Envelope) error { + s.outMu.RLock() + defer s.outMu.RUnlock() + if s.outClosed { + return agent.ErrUnknownFunctionCall + } + timer := time.NewTimer(terminalSendTimeout) + defer timer.Stop() + select { + case s.out <- env: + return nil + case <-s.cancelCtx.Done(): + return s.cancelCtx.Err() + case <-timer.C: + return errors.New("function call delivery timed out") + } +} + +func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if s.functions == nil { + return agent.ErrUnknownFunctionCall + } + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + id, exists := s.functions.pending[result.CallID] + if !exists || s.functions.closed || s.cancelled.Load() || s.terminal.Load() { + return agent.ErrUnknownFunctionCall + } + if err := result.ValidateContent(); err != nil { + return err + } + content := make([]functionContent, 0, len(result.Content)) + for _, part := range result.Content { + kind := "inputText" + if part.Type == "input_image" { + kind = "inputImage" + } + content = append(content, functionContent{Type: kind, Text: part.Text, ImageURL: part.ImageURL}) + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + reply := struct { + Success bool `json:"success"` + ContentItems []functionContent `json:"contentItems"` + }{Success: result.Success, ContentItems: content} + if err := s.rpc.writeFrameContext(ctx, JsonRpcResponse{JsonRpc: JsonRpcVersion, ID: id, Result: reply}); err != nil { + return fmt.Errorf("write function result: %w", err) + } + delete(s.functions.pending, result.CallID) + return nil +} + +type functionContent struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` +} + +func (s *Session) stopFunctionCalls() { + if s.functions != nil { + s.functions.mu.Lock() + s.functions.closed = true + clear(s.functions.pending) + s.functions.mu.Unlock() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/functions_test.go b/apps/parsar-daemon/internal/agent/codex/functions_test.go new file mode 100644 index 000000000..4611c10ec --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/functions_test.go @@ -0,0 +1,111 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestFunctionCallWaitsAndRepliesOnce(t *testing.T) { + for _, success := range []bool{true, false} { + t.Run(map[bool]string{true: "success", false: "failure"}[success], func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + var err error + s.functions, err = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) + if err != nil { + t.Fatal(err) + } + s.setThreadID("thread") + params := map[string]any{"threadId": "thread", "turnId": "turn", "callId": "call", "tool": "lookup", "arguments": map[string]string{"ticket": "42"}} + if err := SendServerRequest(srv, "rpc-call", "item/tool/call", params); err != nil { + t.Fatal(err) + } + select { + case env := <-out: + var call proto.FunctionCallPayload + if err := env.DecodePayload(&call); err != nil || env.Type != proto.TypeFunctionCall || env.ID != "run-test" || call.CallID != "call" || call.Name != "lookup" { + t.Fatal(env, err) + } + case <-time.After(time.Second): + t.Fatal("missing function call") + } + text, image, empty := "answer", "https://example.com/result.png", "" + content := []proto.FunctionResultContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &image}, {Type: "input_text", Text: &empty}} + if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Content: []proto.FunctionResultContent{{Type: "input_audio"}}}); err == nil { + t.Fatal("invalid result consumed the pending call") + } + finished := make(chan error, 1) + go func() { + finished <- s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Success: success, Content: content}) + }() + var reply struct { + ID string `json:"id"` + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(srv.FromClient).Decode(&reply); err != nil { + t.Fatal(err) + } + if reply.ID != "rpc-call" { + t.Fatal(reply.ID) + } + if err := <-finished; err != nil { + t.Fatal(err) + } + var result struct { + Success bool `json:"success"` + Content []functionContent `json:"contentItems"` + } + if err := json.Unmarshal(reply.Result, &result); err != nil || result.Success != success || !reflect.DeepEqual(result.Content, []functionContent{{Type: "inputText", Text: &text}, {Type: "inputImage", ImageURL: &image}, {Type: "inputText", Text: &empty}}) { + t.Fatal(string(reply.Result), err) + } + if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call"}); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatal(err) + } + }) + } +} + +func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { + tc, _, cleanup := NewTestClient() + defer cleanup() + s, _ := newInteractionTestSession(tc.JSONRPCClient) + s.setThreadID("thread") + s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) + for _, params := range []string{ + `{"threadId":"other","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`, + `{"threadId":"thread","turnId":"turn","callId":"a","tool":"unknown","arguments":{}}`, + `{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","namespace":"foreign","arguments":{}}`, + } { + if _, err := s.handleFunctionCall(json.RawMessage(params), "rpc"); err == nil { + t.Fatal("unexpected call accepted", params) + } + } + s.stopFunctionCalls() + if _, err := s.handleFunctionCall(json.RawMessage(`{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`), "rpc"); err == nil { + t.Fatal("closed run accepted call") + } + if err := s.SubmitFunctionResult(context.Background(), proto.FunctionResultPayload{CallID: "a"}); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatal(err) + } +} + +func TestFunctionToolDefinitionsRejectAmbiguousInput(t *testing.T) { + for _, tools := range [][]proto.FunctionTool{ + {{Name: "", Parameters: json.RawMessage(`{}`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`[]`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`null`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`{}`)}, {Name: "lookup", Parameters: json.RawMessage(`{}`)}}, + } { + if _, err := prepareFunctionTools(tools); err == nil { + t.Fatal("invalid function accepted", tools) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/generation_config.go b/apps/parsar-daemon/internal/agent/codex/generation_config.go new file mode 100644 index 000000000..8cce97fbf --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/generation_config.go @@ -0,0 +1,17 @@ +package codex + +func extraConfigFromOpts(opts map[string]any) [][2]string { + var out [][2]string + if rs := stringOpt(opts, "reasoning_summary"); rs != "" { + // codex app-server has no per-call flag; route via -c override. + // TOML literal — quoted string keeps shell-safe special chars. + out = append(out, [2]string{"model_reasoning_summary", strconv(rs)}) + } + if mode := stringOpt(opts, "web_search"); mode != "" { + out = append(out, [2]string{"web_search", strconv(mode)}) + } + if verbosity := stringOpt(opts, "model_verbosity"); verbosity != "" { + out = append(out, [2]string{"model_verbosity", strconv(verbosity)}) + } + return out +} diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go new file mode 100644 index 000000000..969b17a69 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go @@ -0,0 +1,44 @@ +package codex + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func verifyHostedSkills(skills []agentskill.Metadata) error { + if err := localworkspace.VerifySkills(skills); err != nil { + return err + } + for _, skill := range skills { + if err := verifyHostedSkillLayout(filepath.Join(localworkspace.SkillDirectory, skill.Name)); err != nil { + return err + } + } + return nil +} + +func verifyHostedSkillLayout(root string) error { + // Native dependency declarations may start MCP installation outside the + // workspace tool sandbox. They are not qualified by an inert Skill upload. + if _, err := os.Lstat(filepath.Join(root, "agents", "openai.yaml")); err == nil { + return fmt.Errorf("codex: native Skill configuration is unsupported") + } else if !os.IsNotExist(err) { + return err + } + // Extra roots are scanned recursively. One public Skill must not silently + // introduce additional native Skills with their own activation metadata. + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Name() == "SKILL.md" && path != filepath.Join(root, "SKILL.md") { + return fmt.Errorf("codex: nested native Skills are unsupported") + } + return nil + }) +} diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go new file mode 100644 index 000000000..0c7455687 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go @@ -0,0 +1,36 @@ +package codex + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHostedSkillDoesNotActivateAdditionalNativeResources(t *testing.T) { + for _, tc := range []struct { + name string + files []string + rejected bool + }{ + {"ordinary supporting files", []string{"SKILL.md", "scripts/check.py", "references/guide.md"}, false}, + {"native dependency configuration", []string{"SKILL.md", "agents/openai.yaml"}, true}, + {"nested discovery", []string{"SKILL.md", "references/other/SKILL.md"}, true}, + {"nested dependencies", []string{"SKILL.md", "references/other/SKILL.md", "references/other/agents/openai.yaml"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, name := range tc.files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0400); err != nil { + t.Fatal(err) + } + } + if err := verifyHostedSkillLayout(root); (err != nil) != tc.rejected { + t.Fatalf("rejected=%v err=%v", tc.rejected, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go new file mode 100644 index 000000000..5217da02d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -0,0 +1,158 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// mcpServerConfig is the daemon-internal MCP server config flattened +// from agent_options["mcp_servers"] (rendered by render.TargetCodex / +// claudecode's mcpServers JSON shape). Written into /config.toml +// before spawning the app-server child. +type mcpServerConfig struct { + Name string + URL string + Headers map[string]string + Command string + Args []string + Env map[string]string + EnabledTools *[]string + Required bool + BearerTokenEnvVar string +} + +// writeCodexMCPConfig writes a `[mcp_servers.]` TOML table per +// server into /config.toml. Servers are sorted by name so +// the file is deterministic and diffable. +// +// Appends to the file rather than truncating because +// writeCodexProviderConfig writes to the same path. Both writers run +// once per prompt after resetGeneratedConfig; native history stays in CODEX_HOME. +// The transport and enabled_tools fields mirror native McpServerConfig. +func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) error { + if err := os.MkdirAll(codexHome, 0o700); err != nil { + return fmt.Errorf("codex: mkdir CODEX_HOME %s: %w", codexHome, err) + } + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + var b strings.Builder + for _, name := range names { + srv := servers[name] + // TOML table name. Use a quoted key form for safety against + // names that contain dots / dashes / unicode. + b.WriteString("[mcp_servers.") + b.WriteString(tomlQuoteString(name)) + b.WriteString("]\n") + if srv.URL != "" { + if srv.Required { + b.WriteString("required = true\n") + } + b.WriteString(`url = `) + b.WriteString(tomlQuoteString(srv.URL)) + b.WriteByte('\n') + if srv.BearerTokenEnvVar != "" { + b.WriteString("bearer_token_env_var = ") + b.WriteString(tomlQuoteString(srv.BearerTokenEnvVar)) + b.WriteByte('\n') + } + if srv.EnabledTools != nil { + b.WriteString("enabled_tools = [") + for i, name := range *srv.EnabledTools { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(name)) + } + b.WriteString("]\n") + } + if len(srv.Headers) > 0 { + headerKeys := make([]string, 0, len(srv.Headers)) + for key := range srv.Headers { + headerKeys = append(headerKeys, key) + } + sort.Strings(headerKeys) + b.WriteString("http_headers = {") + for index, key := range headerKeys { + if index > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(key)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(srv.Headers[key])) + } + b.WriteString("}\n") + } + b.WriteByte('\n') + continue + } + b.WriteString(`command = `) + b.WriteString(tomlQuoteString(srv.Command)) + b.WriteByte('\n') + if len(srv.Args) > 0 { + b.WriteString("args = [") + for i, a := range srv.Args { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(a)) + } + b.WriteString("]\n") + } + if len(srv.Env) > 0 { + envKeys := make([]string, 0, len(srv.Env)) + for k := range srv.Env { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + b.WriteString("\n[mcp_servers.") + b.WriteString(tomlQuoteString(name)) + b.WriteString(".env]\n") + for _, k := range envKeys { + b.WriteString(tomlQuoteString(k)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(srv.Env[k])) + b.WriteByte('\n') + } + } + b.WriteByte('\n') + } + + path := filepath.Join(codexHome, "config.toml") + return appendConfigTOML(path, b.String()) +} + +// tomlQuoteString returns a TOML basic-string literal (double-quoted) +// with the documented escape set applied — \" \\ \n \r \t plus +// \uXXXX for control chars. Matches the TOML 1.0 spec for basic strings. +func tomlQuoteString(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + fmt.Fprintf(&b, `\u%04X`, r) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go new file mode 100644 index 000000000..499733029 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go @@ -0,0 +1,134 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteCodexMCPConfig_DeterministicOrdering(t *testing.T) { + dir := t.TempDir() + servers := map[string]mcpServerConfig{ + "zulu": {Name: "zulu", Command: "z"}, + "alpha": {Name: "alpha", Command: "a"}, + "mike": {Name: "mike", Command: "m"}, + } + if err := writeCodexMCPConfig(dir, servers); err != nil { + t.Fatalf("write: %v", err) + } + out, err := os.ReadFile(filepath.Join(dir, "config.toml")) + if err != nil { + t.Fatalf("read: %v", err) + } + body := string(out) + idxA := strings.Index(body, `[mcp_servers."alpha"]`) + idxM := strings.Index(body, `[mcp_servers."mike"]`) + idxZ := strings.Index(body, `[mcp_servers."zulu"]`) + if idxA < 0 || idxM < 0 || idxZ < 0 { + t.Fatalf("missing tables: %s", body) + } + if !(idxA < idxM && idxM < idxZ) { + t.Fatalf("servers not sorted: alpha=%d mike=%d zulu=%d", idxA, idxM, idxZ) + } +} + +func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { + dir := t.TempDir() + servers := map[string]mcpServerConfig{ + "docs": { + Name: "docs", + Command: "docs-server", + Args: []string{"--port", "8080"}, + Env: map[string]string{"DOCS_TOKEN": "secret"}, + }, + } + if err := writeCodexMCPConfig(dir, servers); err != nil { + t.Fatalf("write: %v", err) + } + body, _ := os.ReadFile(filepath.Join(dir, "config.toml")) + if !strings.Contains(string(body), `command = "docs-server"`) { + t.Fatalf("missing command: %s", body) + } + if !strings.Contains(string(body), `args = ["--port", "8080"]`) { + t.Fatalf("missing args: %s", body) + } + if !strings.Contains(string(body), `[mcp_servers."docs".env]`) { + t.Fatalf("missing env table: %s", body) + } + if !strings.Contains(string(body), `"DOCS_TOKEN" = "secret"`) { + t.Fatalf("missing env entry: %s", body) + } +} + +func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { + dir := t.TempDir() + servers := map[string]mcpServerConfig{ + "docs": { + Name: "docs", + URL: "https://docs.example.com/mcp", + Headers: map[string]string{"Authorization": "Bearer token"}, + }, + } + if err := writeCodexMCPConfig(dir, servers); err != nil { + t.Fatalf("write: %v", err) + } + body, _ := os.ReadFile(filepath.Join(dir, "config.toml")) + if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { + t.Fatalf("remote config: %s", body) + } + if !strings.Contains(string(body), `http_headers = {"Authorization" = "Bearer token"}`) { + t.Fatalf("remote headers: %s", body) + } +} + +// TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the +// "fresh entries only" guarantee: callers allocate a brand-new +// CODEX_HOME per prompt (BuildSessionPlan does this via allocCodexHome +// + plan.Cleanup), so the previous run's mcp_servers can't leak. +// +// writeCodexMCPConfig itself is APPEND semantics now — the truncation +// guarantee lives in allocCodexHome's RemoveAll, not in the writer. +// Test that workflow explicitly so a future refactor that breaks the +// fresh-home contract fails here. +func TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries(t *testing.T) { + dir1 := t.TempDir() + first := map[string]mcpServerConfig{ + "alpha": {Name: "alpha", Command: "a"}, + "bravo": {Name: "bravo", Command: "b"}, + } + if err := writeCodexMCPConfig(dir1, first); err != nil { + t.Fatalf("first write: %v", err) + } + + // Simulate the per-run CODEX_HOME pattern: new dir, only "alpha". + dir2 := t.TempDir() + second := map[string]mcpServerConfig{ + "alpha": {Name: "alpha", Command: "a"}, + } + if err := writeCodexMCPConfig(dir2, second); err != nil { + t.Fatalf("second write: %v", err) + } + body, _ := os.ReadFile(filepath.Join(dir2, "config.toml")) + if strings.Contains(string(body), "bravo") { + t.Fatalf("fresh CODEX_HOME contains stale entry: %s", body) + } +} + +func TestTOMLQuoteString_EscapesSpecials(t *testing.T) { + cases := []struct { + in, want string + }{ + {`abc`, `"abc"`}, + {`a"b`, `"a\"b"`}, + {"a\nb", `"a\nb"`}, + {`a\b`, `"a\\b"`}, + {"a\tb", `"a\tb"`}, + } + for _, tc := range cases { + got := tomlQuoteString(tc.in) + if got != tc.want { + t.Fatalf("quote %q = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http.go b/apps/parsar-daemon/internal/agent/codex/mcp_http.go new file mode 100644 index 000000000..8e6931c5a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http.go @@ -0,0 +1,85 @@ +package codex + +import ( + "errors" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// A non-nil public declaration owns the complete MCP profile, including an empty +// declaration. Product requests without this field retain their existing options. +func publicMCPHTTPServers(req proto.PromptRequestPayload) (map[string]mcpServerConfig, error) { + if req.MCPHTTPServers == nil { + return nil, nil + } + if req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { + return nil, errors.New("codex: public HTTP MCP requires environment:none or a remote environment") + } + servers := make(map[string]mcpServerConfig, len(*req.MCPHTTPServers)) + for _, declaration := range *req.MCPHTTPServers { + name := declaration.ServerLabel + if name == "" || strings.TrimSpace(name) != name || name == "codex_apps" { + return nil, errors.New("codex: unsupported public MCP server label") + } + if _, exists := servers[name]; exists { + return nil, errors.New("codex: duplicate public MCP server label") + } + endpoint, err := url.Parse(declaration.ServerURL) + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || endpoint.Opaque != "" { + return nil, errors.New("codex: unsupported public MCP server URL") + } + if declaration.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*declaration.BearerToken)) { + return nil, errors.New("codex: unsupported HTTPS MCP bearer credential") + } + server := mcpServerConfig{Name: name, URL: declaration.ServerURL, Required: declaration.Required} + if declaration.AllowedTools != nil { + tools := slices.Clone(*declaration.AllowedTools) + for _, tool := range tools { + if tool == "" || strings.TrimSpace(tool) != tool { + return nil, errors.New("codex: invalid public MCP tool allowlist") + } + } + server.EnabledTools = &tools + } + servers[name] = server + } + return servers, nil +} + +func configureMCPHTTP(plan *SessionPlan, servers map[string]mcpServerConfig) error { + var codexHome string + for _, entry := range plan.Env { + if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { + codexHome = value + } + } + if codexHome == "" || !filepath.IsAbs(codexHome) { + return errors.New("codex: public MCP requires a private native home") + } + // Native OAuth defaults to the global keyring. File mode confines lookup to + // this owned history directory; never delete existing credentials to admit it. + if _, err := os.Lstat(filepath.Join(codexHome, ".credentials.json")); !errors.Is(err, os.ErrNotExist) { + return errors.New("codex: public MCP requires a native home without stored MCP credentials") + } + if err := writeCodexMCPConfig(codexHome, servers); err != nil { + return errors.New("codex: cannot write public MCP configuration") + } + if plan.Cwd == "" { + plan.Cwd = codexHome + } + for _, feature := range []string{"plugins", "apps"} { + plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) + if !slices.Contains(plan.DisableFeatures, feature) { + plan.DisableFeatures = append(plan.DisableFeatures, feature) + } + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) + plan.mcpHTTPServers = servers + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer.go new file mode 100644 index 000000000..d4b27793b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer.go @@ -0,0 +1,26 @@ +package codex + +import ( + "crypto/rand" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Only the daemon-generated reference enters native configuration. The returned +// secrets are added to the app-server environment after other launch probes. +func prepareMCPHTTPBearer(servers map[string]mcpServerConfig, declarations *[]proto.MCPHTTPServer) []string { + if declarations == nil { + return nil + } + var env []string + for _, declaration := range *declarations { + if declaration.BearerToken == nil { + continue + } + server := servers[declaration.ServerLabel] + server.BearerTokenEnvVar = "PARSAR_MCP_BEARER_" + rand.Text() + servers[declaration.ServerLabel] = server + env = append(env, server.BearerTokenEnvVar+"="+*declaration.BearerToken) + } + return env +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go new file mode 100644 index 000000000..6e655eff4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -0,0 +1,136 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { + for _, remote := range []bool{false, true} { + t.Run(map[bool]string{false: "none", true: "remote"}[remote], func(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + tokens := []string{"first-synthetic.token+/==", "second-synthetic_token~"} + servers := []proto.MCPHTTPServer{ + {ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, + {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, + {ServerLabel: "public", ServerURL: "http://public.example/mcp"}, + } + req := proto.PromptRequestPayload{AgentStateKey: "retained-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} + if remote { + req = remoteEnvironmentRequest() + req.MCPHTTPServers = &servers + } + seen := map[string]bool{} + for range 2 { + plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + config, err := os.ReadFile(filepath.Join(plan.Cwd, "config.toml")) + if err != nil { + t.Fatal(err) + } + args, _ := json.Marshal(plan.ExtraConfig) + for i, server := range servers[:2] { + ref := plan.mcpHTTPServers[server.ServerLabel].BearerTokenEnvVar + if !strings.HasPrefix(ref, "PARSAR_MCP_BEARER_") || seen[ref] || !slices.Contains(plan.Env, ref+"="+tokens[i]) { + t.Fatal("missing exact per-server secret or reused native reference") + } + seen[ref] = true + if _, present := os.LookupEnv(ref); present { + t.Fatal("secret entered parent environment") + } + if !strings.Contains(string(config), `bearer_token_env_var = "`+ref+`"`) || strings.Contains(string(config), tokens[i]) || strings.Contains(string(args), tokens[i]) { + t.Fatal("secret reached configuration/arguments or reference was omitted") + } + } + if plan.mcpHTTPServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { + t.Fatal("credential-free server received authentication") + } + plan.Cleanup() + } + }) + } +} + +func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + for _, token := range []string{"", "=", " has-space", "has-space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + req := proto.PromptRequestPayload{AgentStateKey: "invalid-bearer", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} + if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil || err.Error() != "codex: unsupported HTTPS MCP bearer credential" { + t.Fatal("invalid bearer value accepted or unsafe error returned") + } + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatal("invalid credential wrote native state", err) + } +} + +func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { + if !SupportsTextVerbosity { + t.Skip("catalog probe requires Unix") + } + t.Setenv("PARSAR_HOME", t.TempDir()) + binary := filepath.Join(t.TempDir(), "catalog-probe") + script := "#!/bin/sh\nif env | grep -q '^PARSAR_MCP_BEARER_'; then exit 9; fi\nprintf '%s' '{\"models\":[{\"slug\":\"fixture-model\",\"support_verbosity\":true}]}'\n" + if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + token := "synthetic-catalog-secret" + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + req := proto.PromptRequestPayload{AgentStateKey: "catalog", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, + AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}} + cfg := defaultSessionConfig() + cfg.codexBinary = binary + plan, _, err := prepareSessionPlan(t.Context(), req, cfg) + if err != nil { + t.Fatal("catalog probe inherited bearer or failed", err) + } + defer plan.Cleanup() + if !slices.Contains(plan.Env, plan.mcpHTTPServers["tools"].BearerTokenEnvVar+"="+token) { + t.Fatal("app-server did not receive bearer after catalog probe") + } +} + +func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { + servers := map[string]mcpServerConfig{ + "first": {URL: "https://first.example/mcp", BearerTokenEnvVar: "PARSAR_MCP_BEARER_FIRST"}, + "second": {URL: "https://second.example/mcp", BearerTokenEnvVar: "PARSAR_MCP_BEARER_SECOND"}, + "public": {URL: "http://public.example/mcp"}, + } + for _, mutation := range []string{"none", "missing", "ambient", "swapped", "extra", "header", "helper"} { + t.Run(mutation, func(t *testing.T) { + response := mcpHTTPConfigResponse(servers) + entries := response["config"].(map[string]any)["mcp_servers"].(map[string]any) + first := entries["first"].(map[string]any) + switch mutation { + case "missing": + delete(first, "bearer_token_env_var") + case "ambient": + first["bearer_token_env_var"] = "OPERATOR_SECRET" + case "swapped": + first["bearer_token_env_var"] = servers["second"].BearerTokenEnvVar + case "extra": + entries["public"].(map[string]any)["bearer_token_env_var"] = servers["first"].BearerTokenEnvVar + case "header": + first["http_headers"] = map[string]string{"Authorization": "Bearer synthetic-private"} + case "helper": + first["http_headers_helper"] = "operator-helper" + } + raw, err := json.Marshal(response) + if err != nil || matchesMCPHTTPConfig(raw, servers) != (mutation == "none") { + t.Fatal("incorrect authenticated configuration decision", err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go new file mode 100644 index 000000000..8fb917e53 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go @@ -0,0 +1,104 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "time" +) + +// config/read loads the same cwd and CLI layers without MCP discovery. Native +// mcpServerStatus/list instead opens eager discovery connections; do not use it +// to decide whether undeclared servers are safe to contact. +func verifyMCPHTTPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPlan) error { + check, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + raw, err := rpc.Request(check, "config/read", map[string]any{"cwd": plan.Cwd, "includeLayers": false}) + if err != nil { + // Native configuration errors and responses can contain operator secrets. + return errors.New("codex: cannot verify public MCP configuration") + } + if !matchesMCPHTTPConfig(raw, plan.mcpHTTPServers) { + return errors.New("codex: effective native MCP configuration differs from the public declaration") + } + return nil +} + +func matchesMCPHTTPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) bool { + var response struct { + Config struct { + Servers map[string]map[string]any `json:"mcp_servers"` + Features map[string]any `json:"features"` + CredentialStore string `json:"mcp_oauth_credentials_store"` + } `json:"config"` + } + if json.Unmarshal(raw, &response) != nil { + return false + } + config := response.Config + if config.CredentialStore != "file" || config.Features["plugins"] != false || config.Features["apps"] != false || config.Servers == nil || len(config.Servers) != len(declared) { + return false + } + for name, expected := range declared { + server, exists := config.Servers[name] + if !exists || server["url"] != expected.URL || server["environment_id"] != "local" || server["enabled"] != true { + return false + } + delete(server, "url") + delete(server, "environment_id") + delete(server, "enabled") + if expected.Required { + if server["required"] != true { + return false + } + delete(server, "required") + } + if expected.BearerTokenEnvVar != "" { + if server["bearer_token_env_var"] != expected.BearerTokenEnvVar { + return false + } + delete(server, "bearer_token_env_var") + } + if expected.EnabledTools != nil { + // Compare sets: native enabled_tools is an allowlist, not an ordered program. + actual, ok := server["enabled_tools"].([]any) + if !ok { + return false + } + want := make(map[string]bool, len(*expected.EnabledTools)) + for _, tool := range *expected.EnabledTools { + want[tool] = true + } + got := make(map[string]bool, len(actual)) + for _, tool := range actual { + name, ok := tool.(string) + if !ok { + return false + } + got[name] = true + } + if !reflect.DeepEqual(want, got) { + return false + } + delete(server, "enabled_tools") + } + // These are the native serializer's inert defaults. Reject all additional + // settings, including headers, credential helpers, tool policies and filters. + for key, value := range server { + switch key { + case "tool_timeout_sec": + if value != nil { + return false + } + case "required", "supports_parallel_tool_calls": + if value != false { + return false + } + default: + return false + } + } + } + return true +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go new file mode 100644 index 000000000..bcc43e300 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -0,0 +1,218 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { + for _, allowlist := range []*[]string{nil, new([]string), {"lookup", "query"}} { + servers := map[string]mcpServerConfig{"docs": {URL: "https://docs.example/mcp", EnabledTools: allowlist}} + for _, mutation := range []string{"none", "ambient", "url", "header", "header helper", "env header", "auth", "required", "tools", "disabled", "remote", "plugins", "apps", "keyring", "policy"} { + t.Run(mutation+"/"+allowlistName(allowlist), func(t *testing.T) { + response := mcpHTTPConfigResponse(servers) + config := response["config"].(map[string]any) + entries := config["mcp_servers"].(map[string]any) + server := entries["docs"].(map[string]any) + switch mutation { + case "ambient": + entries["operator"] = map[string]any{"command": "operator-mcp", "enabled": true} + case "url": + server["url"] = "https://other.example/mcp" + case "header": + server["http_headers"] = map[string]any{"Authorization": "synthetic-private"} + case "header helper": + server["http_headers_helper"] = "operator-credentials" + case "env header": + server["env_http_headers"] = map[string]any{"Authorization": "OPERATOR_SECRET"} + case "auth": + server["auth"] = "chatgpt" + case "required": + server["required"] = true + case "tools": + server["enabled_tools"] = []string{"undeclared"} + case "disabled": + server["enabled"] = false + case "remote": + server["environment_id"] = "remote" + case "plugins", "apps": + config["features"].(map[string]any)[mutation] = true + case "keyring": + config["mcp_oauth_credentials_store"] = "auto" + case "policy": + server["tools"] = map[string]any{"lookup": map[string]any{"enabled": false}} + } + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if matchesMCPHTTPConfig(data, servers) != (mutation == "none") { + t.Fatal("effective configuration decision differed", mutation) + } + }) + } + } + for _, raw := range []string{`null`, `{}`, `{"config":{"mcp_servers":[]}}`, `not json`} { + if matchesMCPHTTPConfig(json.RawMessage(raw), map[string]mcpServerConfig{}) { + t.Fatal("missing or malformed native proof accepted") + } + } + empty := map[string]mcpServerConfig{} + data, _ := json.Marshal(mcpHTTPConfigResponse(empty)) + if !matchesMCPHTTPConfig(data, empty) { + t.Fatal("explicit empty declaration rejected") + } +} + +func allowlistName(tools *[]string) string { + if tools == nil { + return "all" + } + if len(*tools) == 0 { + return "none" + } + return "selected" +} + +func TestPublicMCPHTTPRequiredConfigurationCannotBeWeakened(t *testing.T) { + servers := map[string]mcpServerConfig{"docs": {URL: "https://docs.example/mcp", Required: true}} + for _, value := range []any{true, false, nil, "true", "omitted"} { + response := mcpHTTPConfigResponse(servers) + server := response["config"].(map[string]any)["mcp_servers"].(map[string]any)["docs"].(map[string]any) + server["required"] = value + if value == "omitted" { + delete(server, "required") + } + raw, err := json.Marshal(response) + if err != nil || matchesMCPHTTPConfig(raw, servers) != (value == true) { + t.Fatal("required initialization was weakened or rejected", value, err) + } + } +} + +func TestPublicMCPHTTPPreflightRedactsNativeErrors(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + done := make(chan error, 1) + go func() { + done <- verifyMCPHTTPConfig(t.Context(), client.JSONRPCClient, SessionPlan{Cwd: "/private/workspace"}) + }() + var req struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "config/read" || req.Params["cwd"] != "/private/workspace" || req.Params["includeLayers"] != false { + t.Fatal("preflight did not request exact cwd configuration") + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32603, "message": "synthetic-secret"}}); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatal("native error was accepted or exposed", err) + } +} + +func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { + for _, mode := range []string{"new", "resume", "reject", "reject bearer reference", "remote new", "remote resume", "remote reject"} { + t.Run(mode, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + remote := strings.HasPrefix(mode, "remote ") + mode = strings.TrimPrefix(mode, "remote ") + if !remote { + req.RemoteEnvironment = nil + req.DisableExecutionEnvironment = true + } + req.AgentOptions = map[string]any{"model": "fixture-model"} + servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} + if mode == "reject bearer reference" { + token := "synthetic-private-bearer" + servers[0].BearerToken = &token + } + req.MCPHTTPServers = &servers + if mode == "resume" { + req.AgentSessionID = "fixture-native-thread" + } + if !remote { + t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "unknown-status")) + } + if err := os.WriteFile(filepath.Join(root, "unknown-status"), []byte("unknown"), 0o600); err != nil { + t.Fatal(err) + } + declarations, err := publicMCPHTTPServers(req) + if err != nil { + t.Fatal(err) + } + response := mcpHTTPConfigResponse(declarations) + if mode == "reject" { + response["config"].(map[string]any)["mcp_servers"].(map[string]any)["operator"] = map[string]any{"url": "https://operator.example/private"} + } + if mode == "reject bearer reference" { + response["config"].(map[string]any)["mcp_servers"].(map[string]any)["docs"].(map[string]any)["bearer_token_env_var"] = "OPERATOR_SECRET" + } + path := filepath.Join(root, "native-config.json") + writeMCPHTTPConfigResponse(t, path, response) + t.Setenv("PARSAR_PREPARATION_MCP_CONFIG", path) + p, err := newPreparation(t.Context(), req, cfg) + if strings.HasPrefix(mode, "reject") { + if err == nil || p != nil { + t.Fatal("ambient MCP configuration admitted") + } + assertPreparationOnly(t, root) + waitPreparationMethod(t, root, "config/read") + return + } + if err != nil { + t.Fatal(err) + } + defer p.Close() + assertPreparationOnly(t, root) + s, err := p.start(t.Context(), "actual-run", "actual prompt", make(chan proto.Envelope, 8)) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + frames := waitPreparationMethod(t, root, "turn/start") + checked, ready := false, !remote + for _, frame := range frames { + if frame.Method == "environment/info" { + ready = true + } + if frame.Method == "config/read" { + if !ready { + t.Fatal("MCP check preceded remote readiness") + } + checked = true + } + if strings.HasPrefix(frame.Method, "thread/") { + var params map[string]any + if err := json.Unmarshal(frame.Params, ¶ms); err != nil { + t.Fatal(err) + } + if !checked || params["cwd"] != req.WorkDir || (frame.Method == "thread/resume") != (mode == "resume") { + t.Fatal("thread started before the check or with another cwd") + } + } + if frame.Method == "mcpServerStatus/list" { + t.Fatal("preflight started discovery") + } + } + _ = s.Cancel(context.Background()) + select { + case <-s.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("native fixture did not release") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go new file mode 100644 index 000000000..893956936 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go @@ -0,0 +1,162 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + tools := []string{"lookup.docs", `quote"tool`} + denyAll := []string{} + servers := []proto.MCPHTTPServer{ + {ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, + {ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, + } + original := map[string]any{ + "mcp_servers": map[string]any{"operator": map[string]any{"command": "operator-mcp"}}, + "enable_features": []any{"apps", "plugins", "unrelated"}, + } + req := proto.PromptRequestPayload{AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: original} + req.AgentOptions = executionOptions(req) + plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if original["mcp_servers"] == nil || req.AgentOptions["mcp_servers"] != nil { + t.Fatal("declaration failed to replace operator MCP without mutation") + } + if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) || !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { + t.Fatal("native profile was not pinned") + } + home, err := allocCodexHome(req.AgentStateKey) + if err != nil { + t.Fatal(err) + } + if plan.Cwd != home { + t.Fatal("empty cwd did not resolve to the private home") + } + config, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{`[mcp_servers."docs.server"]`, `enabled_tools = ["lookup.docs", "quote\"tool"]`, `enabled_tools = []`, "required = true"} { + if !strings.Contains(string(config), expected) { + t.Fatalf("missing native config %q", expected) + } + } + if strings.Contains(string(config), "operator") { + t.Fatal("operator MCP was rendered") + } + tools[0] = "mutated" + if (*plan.mcpHTTPServers["docs.server"].EnabledTools)[0] != "lookup.docs" { + t.Fatal("prepared allowlist retained caller-owned memory") + } + history := filepath.Join(home, "retained-history.jsonl") + if err := os.WriteFile(history, []byte("native-history"), 0o600); err != nil { + t.Fatal(err) + } + servers = []proto.MCPHTTPServer{{ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} + second, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer second.Cleanup() + config, err = os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil || strings.Contains(string(config), "docs.server") || !strings.Contains(string(config), "replacement") || strings.Contains(string(config), "enabled_tools") || strings.Contains(string(config), "required") { + t.Fatal("cold configuration retained old servers or changed unrestricted tools", err) + } + retained, err := os.ReadFile(history) + if err != nil || string(retained) != "native-history" { + t.Fatal("configuration reset changed native history", err) + } +} + +func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { + valid := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} + for _, req := range []proto.PromptRequestPayload{ + {MCPHTTPServers: &valid}, + {MCPHTTPServers: &valid, DisableExecutionEnvironment: true, RemoteEnvironment: &proto.RemoteEnvironment{ID: "remote"}}, + } { + if _, err := publicMCPHTTPServers(req); err == nil { + t.Fatal("non-service profile accepted") + } + } + for _, server := range []proto.MCPHTTPServer{ + {ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, + {ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, + {ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, + {ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, + } { + servers := []proto.MCPHTTPServer{server} + if _, err := publicMCPHTTPServers(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatal("unsupported configuration was accepted or exposed", err) + } + } + t.Setenv("PARSAR_HOME", t.TempDir()) + home, err := allocCodexHome("credentials") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".credentials.json") + stored := []byte(`{"synthetic":"private"}`) + if err := os.WriteFile(path, stored, 0o600); err != nil { + t.Fatal(err) + } + req := proto.PromptRequestPayload{AgentStateKey: "credentials", DisableExecutionEnvironment: true, MCPHTTPServers: &valid} + if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { + t.Fatal("existing MCP credentials accepted") + } + if after, err := os.ReadFile(path); err != nil || !reflect.DeepEqual(after, stored) { + t.Fatal("existing credentials were modified", err) + } +} + +// This is the pinned native serializer's config/read shape, not live discovery. +func mcpHTTPConfigResponse(servers map[string]mcpServerConfig) map[string]any { + entries := make(map[string]any, len(servers)) + for name, server := range servers { + entry := map[string]any{"url": server.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "required": server.Required} + if server.EnabledTools != nil { + entry["enabled_tools"] = append([]string{}, (*server.EnabledTools)...) + } + if server.BearerTokenEnvVar != "" { + entry["bearer_token_env_var"] = server.BearerTokenEnvVar + } + entries[name] = entry + } + return map[string]any{"config": map[string]any{"mcp_servers": entries, "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}} +} + +func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { + t.Helper() + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRemoteMCPBearerRequiresHTTPS(t *testing.T) { + req := remoteEnvironmentRequest() + token := "synthetic-private-token" + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} + req.MCPHTTPServers = &servers + if _, err := publicMCPHTTPServers(req); err == nil || strings.Contains(err.Error(), token) { + t.Fatal("plaintext bearer accepted or exposed") + } + servers[0].ServerURL = "https://tools.example/mcp" + if _, err := publicMCPHTTPServers(req); err != nil { + t.Fatal("remote HTTPS bearer declaration rejected", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go new file mode 100644 index 000000000..aa9bd2da6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go @@ -0,0 +1,90 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// The controlled native response checks adapter ordering. Real MCP initialization +// and model execution are verified separately against the pinned harness. +func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing.T) { + for _, mode := range []string{"new ready", "new failed", "resume ready", "resume failed"} { + t.Run(mode, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.StrictResume = true + req.AgentOptions = map[string]any{"model": "fixture-model"} + servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} + req.MCPHTTPServers = &servers + method := "thread/start" + if strings.HasPrefix(mode, "resume") { + req.AgentSessionID = "fixture-native-thread" + method = "thread/resume" + } + declarations, err := publicMCPHTTPServers(req) + if err != nil { + t.Fatal(err) + } + config := filepath.Join(root, "mcp-config.json") + writeMCPHTTPConfigResponse(t, config, mcpHTTPConfigResponse(declarations)) + t.Setenv("PARSAR_PREPARATION_MCP_CONFIG", config) + gate := filepath.Join(root, "required-initialization") + t.Setenv("PARSAR_PREPARATION_THREAD_GATE", gate) + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + out := make(chan proto.Envelope, 16) + s, err := p.start(t.Context(), "required-run", "actual prompt", out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + waitPreparationMethod(t, root, method) + time.Sleep(100 * time.Millisecond) + assertNoTurn := func() { + t.Helper() + threads := 0 + for _, frame := range preparationFrames(t, root) { + if strings.HasPrefix(frame.Method, "turn/") { + t.Fatal("Turn sent without initialized native thread", frame.Method) + } + if strings.HasPrefix(frame.Method, "thread/") { + threads++ + if frame.Method != method || threads != 1 { + t.Fatal("failed native initialization retried or replaced history") + } + } + } + } + assertNoTurn() + _, state, _ := strings.Cut(mode, " ") + if err := os.WriteFile(gate, []byte(state), 0o600); err != nil { + t.Fatal(err) + } + if state == "ready" { + waitPreparationMethod(t, root, "turn/start") + return + } + select { + case <-s.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("failed initialization did not terminate") + } + assertNoTurn() + failed := false + for len(out) > 0 { + failed = (<-out).Type == proto.TypeError || failed + } + if !failed { + t.Fatal("native initialization failure was not reported") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go new file mode 100644 index 000000000..38a3fb6ae --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go @@ -0,0 +1,16 @@ +//go:build !unix + +package codex + +import ( + "context" + "errors" + "os/exec" +) + +// SupportsTextVerbosity requires bounded cancellation of the catalog probe. +const SupportsTextVerbosity = false + +func modelCatalogCommand(context.Context, string, ...string) (*exec.Cmd, error) { + return nil, errors.New("codex: text verbosity requires Unix process-group cancellation support") +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go new file mode 100644 index 000000000..949b3b86e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go @@ -0,0 +1,21 @@ +//go:build unix + +package codex + +import ( + "context" + "os/exec" + "syscall" + "time" +) + +// SupportsTextVerbosity requires bounded cancellation of the catalog probe. +const SupportsTextVerbosity = true + +func modelCatalogCommand(ctx context.Context, binary string, args ...string) (*exec.Cmd, error) { + cmd := exec.CommandContext(ctx, binary, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = time.Second + return cmd, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go new file mode 100644 index 000000000..79b74bf3a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go @@ -0,0 +1,56 @@ +//go:build unix + +package codex + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestModelCatalogCancellationStopsDescendants(t *testing.T) { + for _, finish := range []string{"wait", "exit 0"} { + t.Run(finish, func(t *testing.T) { + checkCatalogDescendantCancellation(t, finish) + }) + } +} + +func checkCatalogDescendantCancellation(t *testing.T, finish string) { + t.Helper() + started := time.Now() + dir := t.TempDir() + binary := filepath.Join(dir, "codex") + if err := os.WriteFile(binary, []byte("#!/bin/sh\n(sleep 2; printf leaked > leaked) &\nprintf ready > ready\n"+finish+"\n"), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- prepareModelVerbosity(ctx, binary, &SessionPlan{Cwd: dir}) }() + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("catalog launcher did not start") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled catalog probe succeeded") + } + case <-time.After(1500 * time.Millisecond): + t.Fatal("catalog probe kept waiting on child output pipes") + } + time.Sleep(time.Until(started.Add(2200 * time.Millisecond))) + if _, err := os.Stat(filepath.Join(dir, "leaked")); !os.IsNotExist(err) { + t.Fatalf("catalog child survived cancellation: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_verbosity.go b/apps/parsar-daemon/internal/agent/codex/model_verbosity.go new file mode 100644 index 000000000..db17e64c5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/model_verbosity.go @@ -0,0 +1,111 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// Validate against the binary's active catalog and use that same snapshot for +// execution. A CLI override alone is silently ignored for unsupported models. +func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan) error { + args := []string{} + for _, kv := range plan.ExtraConfig { + args = append(args, "-c", kv[0]+"="+kv[1]) + } + args = append(args, "debug", "models") + ctx, cancel := context.WithTimeout(ctx, rpcDefaultRequestTimeout) + defer cancel() + cmd, err := modelCatalogCommand(ctx, binary, args...) + if err != nil { + return err + } + cmd.Dir = plan.Cwd + cmd.Env = append(os.Environ(), plan.Env...) + catalog, err := cmd.Output() + // The launcher can exit before its children, ending the context watcher. + if cmd.Process != nil { + _ = cmd.Cancel() + } + if err != nil { + return fmt.Errorf("codex: cannot verify model verbosity support: %w", err) + } + supported, err := catalogSupportsVerbosity(catalog, plan.Model) + if err != nil { + return fmt.Errorf("codex: cannot read model verbosity support: %w", err) + } + if !supported { + if !slices.Contains(plan.ExtraConfig, [2]string{"model_verbosity", `"medium"`}) { + return fmt.Errorf("codex: model %q does not declare text verbosity support", plan.Model) + } + // Protocol medium means the default text amount, which needs no native override. + plan.ExtraConfig = slices.DeleteFunc(plan.ExtraConfig, func(kv [2]string) bool { return kv[0] == "model_verbosity" }) + } + codexHome := "" + for _, entry := range plan.Env { + if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { + codexHome = value + } + } + if !filepath.IsAbs(codexHome) { + return fmt.Errorf("codex: missing managed home for model catalog") + } + file, err := os.CreateTemp(codexHome, "model-catalog-*.json") + if err != nil { + return err + } + _, writeErr := file.Write(catalog) + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + _ = os.Remove(file.Name()) + if writeErr != nil { + return writeErr + } + return closeErr + } + cleanup := plan.Cleanup + plan.Cleanup = func() { _ = os.Remove(file.Name()); cleanup() } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(file.Name())}) + return nil +} + +func catalogSupportsVerbosity(raw []byte, model string) (bool, error) { + var catalog struct { + Models []struct { + Slug string `json:"slug"` + SupportVerbosity bool `json:"support_verbosity"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &catalog); err != nil { + return false, err + } + // Match Codex models-manager's longest-prefix lookup, then its single, + // simple provider namespace fallback. Do not infer support from a model name. + match := func(name string) (bool, bool) { + longest, supported := 0, false + for _, candidate := range catalog.Models { + if len(candidate.Slug) > longest && strings.HasPrefix(name, candidate.Slug) { + longest, supported = len(candidate.Slug), candidate.SupportVerbosity + } + } + return supported, longest > 0 + } + if supported, found := match(model); found { + return supported, nil + } + namespace, suffix, found := strings.Cut(model, "/") + if !found || namespace == "" || strings.Contains(suffix, "/") { + return false, nil + } + for _, c := range namespace { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + return false, nil + } + } + supported, _ := match(suffix) + return supported, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go b/apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go new file mode 100644 index 000000000..7ff92270e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go @@ -0,0 +1,118 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCatalogVerbositySupport(t *testing.T) { + catalog := []byte(`{"models":[{"slug":"gpt-5","support_verbosity":true},{"slug":"gpt-5-special","support_verbosity":false}]}`) + for model, want := range map[string]bool{ + "gpt-5": true, "gpt-5.5": true, "provider/gpt-5.5": true, + "gpt-5-special": false, "provider/gpt-5-special": false, + "custom-provider-model": false, "": false, "a/b/gpt-5": false, "a b/gpt-5": false, + } { + got, err := catalogSupportsVerbosity(catalog, model) + if err != nil || got != want { + t.Errorf("%q: got %v, %v; want %v", model, got, err, want) + } + } + if _, err := catalogSupportsVerbosity([]byte(`{"models":false}`), "gpt-5"); err == nil { + t.Fatal("accepted malformed catalog") + } +} + +func TestPrepareModelVerbosity(t *testing.T) { + if !SupportsTextVerbosity { + t.Skip("catalog probe requires Unix") + } + t.Setenv("PARSAR_HOME", t.TempDir()) + binary := filepath.Join(t.TempDir(), "codex") + catalog := `{"models":[{"slug":"known-model","support_verbosity":true,"native_extra":{"keep":true}}]}` + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { + t.Fatal(err) + } + plan, err := BuildSessionPlan("run", "state", "", map[string]any{"model": "known-model", "model_verbosity": "high"}) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if err := prepareModelVerbosity(context.Background(), binary, &plan); err != nil { + t.Fatal(err) + } + kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] + if kv[0] != "model_catalog_json" { + t.Fatal("catalog was not pinned") + } + name := strings.Trim(kv[1], `"`) + got, err := os.ReadFile(name) + if err != nil || string(got) != catalog { + t.Fatalf("catalog changed: %s, %v", got, err) + } + plan.Cleanup() + if _, err := os.Stat(name); !os.IsNotExist(err) { + t.Fatalf("catalog was not removed: %v", err) + } + plan.Model = "custom-provider-model" + if err := prepareModelVerbosity(context.Background(), binary, &plan); err == nil { + t.Fatal("accepted model that would ignore verbosity") + } + if err := prepareModelVerbosity(context.Background(), "/missing-codex", &plan); err == nil { + t.Fatal("accepted unreadable catalog") + } +} + +func TestPrepareDefaultModelVerbosity(t *testing.T) { + if !SupportsTextVerbosity { + t.Skip("catalog probe requires Unix") + } + t.Setenv("PARSAR_HOME", t.TempDir()) + binary := filepath.Join(t.TempDir(), "codex") + catalog := `{"models":[{"slug":"supported","support_verbosity":true,"default_verbosity":"low"},{"slug":"unsupported","support_verbosity":false}]}` + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { + t.Fatal(err) + } + for _, model := range []string{"supported", "unsupported", "unknown-provider-model"} { + for _, level := range []string{"low", "medium", "high"} { + t.Run(model+"/"+level, func(t *testing.T) { + plan, err := BuildSessionPlan("run", "state", "", executionOptions(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": model}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: level}})) + if err != nil { + t.Fatal(err) + } + defer func() { plan.Cleanup() }() + err = prepareModelVerbosity(context.Background(), binary, &plan) + if model != "supported" && level != "medium" { + if err == nil { + t.Fatal("accepted unsupported non-default verbosity") + } + return + } + if err != nil { + t.Fatal(err) + } + found := false + for _, kv := range plan.ExtraConfig { + if kv[0] == "model_verbosity" { + found = true + if kv[1] != `"`+level+`"` { + t.Fatal("configured verbosity changed", kv) + } + } + } + if found != (model == "supported") || plan.Model != model { + t.Fatal("native default or model identity changed", plan.ExtraConfig, plan.Model) + } + kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] + raw, err := os.ReadFile(strings.Trim(kv[1], `"`)) + if kv[0] != "model_catalog_json" || err != nil || string(raw) != catalog { + t.Fatal("native catalog snapshot changed", err) + } + }) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go new file mode 100644 index 000000000..1f86b5788 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -0,0 +1,506 @@ +package codex + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// SessionPlan holds the resolved per-prompt launch plan derived from +// the daemon's PromptRequestPayload. +type SessionPlan struct { + // Cwd is the validated working directory passed to codex (and to + // the spawned app-server). Empty when the caller provided no work_dir. + Cwd string + + // Environments select native execution independently of the process cwd. + Environments []EnvironmentSelection + + // Env is the full environment slice (KEY=value) to layer onto + // os.Environ() before spawning. Includes CODEX_HOME, plus any + // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. + Env []string + + // ExtraConfig is a list of `-c key=value` overrides applied at the + // app-server CLI. Used to layer model_reasoning_summary etc. without + // editing config.toml. + ExtraConfig [][2]string + + // EnableFeatures / DisableFeatures forward to `--enable / --disable` + // flags. Today empty by default; reserved for future ARC opt-in. + EnableFeatures []string + DisableFeatures []string + + // Non-nil for typed service-side HTTP MCP, including private bearer references. + mcpHTTPServers map[string]mcpServerConfig + + // Model is the slug to request on thread/start. Empty inherits the + // codex.config.toml default. + Model string + + // ModelProvider is the slug pinned on thread/start so codex routes + // the prompt through the [model_providers.] entry we wrote + // into /config.toml. Empty leaves codex on its builtin + // "openai" provider (only valid when the caller really wants + // public api.openai.com + OPENAI_API_KEY env), so the normal path is + // parsarProviderSlug. + ModelProvider string + + // SystemPrompt is forwarded as developerInstructions on thread/start. + SystemPrompt string + + // CollaborationMode selects Codex's default or plan tool surface. + CollaborationMode CollaborationModeKind + + // ApprovalPolicy + Sandbox apply to both new and resumed threads. + ApprovalPolicy AskForApproval + Sandbox SandboxMode + Permissions string + + // Cleanup is the deferred housekeeping the session must run after the child exits. + Cleanup func() +} + +// BuildSessionPlan derives a SessionPlan from PromptRequestPayload's +// fields. The work_dir / opts shape mirrors how claudecode + opencode +// consume their own opts: a string-keyed map of any. +// +// The agent_options keys this function reads: +// +// model string codex model slug, e.g. "gpt-5.5" +// system_prompt string forwarded as developerInstructions +// override_system_prompt string replaces system_prompt entirely when +// non-empty (mirrors claudecode/opencode) +// env map[string]any extra env vars (KEY=string-value) +// mcp_servers map[string]any rendered MCP server config — written +// to /config.toml [mcp_servers] +// codex_provider map[string]any full provider config — written to +// /config.toml +// [model_providers.]. +// Required for any real prompt; when +// missing, codex falls back to its +// builtin "openai" provider which only +// speaks api.openai.com. +// Recognised keys: name (string), +// base_url (string, required when +// codex_provider is present), +// bearer_token (string, required), +// wire_api (string; defaults to +// "responses"), +// http_headers (map[string]string), +// query_params (map[string]string), +// request_max_retries (number), +// stream_max_retries (number). +// reasoning_summary string one of auto/concise/detailed/none — +// routed via -c model_reasoning_summary +// mode string Codex collaboration mode: default/plan +// enable_features []any string list, forwarded as --enable +// disable_features []any string list, forwarded as --disable +// +// The codex binary itself is resolved via PATH only — there's no +// per-prompt override knob. If a deployment needs a custom binary +// location, set it via the daemon's process environment (PATH / +// codexBinary in sessionConfig) rather than per-call. +// +// Daemon-managed Codex sessions bypass approvals and the engine sandbox. +func BuildSessionPlan(runID, agentStateKey, workDir string, opts map[string]any) (SessionPlan, error) { + cleanup := func() {} + plan := SessionPlan{ + CollaborationMode: CollaborationModeDefault, + ApprovalPolicy: AskForApproval{String: "never"}, + Sandbox: SandboxDangerFullAcces, + Cleanup: cleanup, + } + + if value, present := opts["web_search"]; present { + switch value { + case "disabled", "cached", "live": + default: + return plan, fmt.Errorf("codex: web_search must be disabled, cached or live") + } + } + + if value, present := opts["model_verbosity"]; present { + switch value { + case "low", "medium", "high": + default: + return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") + } + } + + resolvedCwd, err := resolveWorkDirCodex(workDir) + if err != nil { + return plan, err + } + plan.Cwd = resolvedCwd + + plan.Model = stringOpt(opts, "model") + plan.SystemPrompt = stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + plan.SystemPrompt = override + } + if mode := CollaborationModeKind(stringOpt(opts, "mode")); mode != "" { + switch mode { + case CollaborationModeDefault, CollaborationModePlan: + plan.CollaborationMode = mode + default: + return plan, fmt.Errorf("codex: unsupported collaboration mode %q", mode) + } + } + + env, err := buildSessionEnv(opts) + if err != nil { + return plan, err + } + + codexHome, err := allocCodexHome(agentStateKey) + if err != nil { + return plan, err + } + if err := resetGeneratedConfig(codexHome); err != nil { + return plan, err + } + env = append(env, "CODEX_HOME="+codexHome) + + // MCP servers come pre-rendered from server/internal/connector/agentdaemon + // (capabilityAdditions.MCPServers, rendered via render.TargetCodex) + // as a map of name → {command,args,env}. Write them into + // /config.toml so codex picks them up on startup. + mcpServers, err := normaliseMCPServers(opts["mcp_servers"]) + if err != nil { + return plan, err + } + if len(mcpServers) > 0 { + if err := writeCodexMCPConfig(codexHome, mcpServers); err != nil { + return plan, err + } + } + + // codex_provider carries the full ModelProviderInfo the server-side + // injectCodexManagedModel resolved. When set, we materialise it into + // the [model_providers.] block and pin + // thread/start.model_provider to that slug, so codex skips its + // builtin "openai" provider entirely. + provider, hasProvider, err := normaliseProviderConfig(opts["codex_provider"]) + if err != nil { + return plan, err + } + if hasProvider { + if err := writeCodexProviderConfig(codexHome, provider); err != nil { + return plan, err + } + plan.ModelProvider = parsarProviderSlug + } + + plan.Env = env + plan.Cleanup = cleanup + plan.ExtraConfig = extraConfigFromOpts(opts) + if plan.ModelProvider != "" { + // Pin model_provider at the CLI layer so codex skips its builtin + // "openai" provider — without this the [model_providers.parsar] + // block we wrote into config.toml would be loaded but never + // selected (the default model_provider is "openai"). + plan.ExtraConfig = append(plan.ExtraConfig, + [2]string{"model_provider", strconv(plan.ModelProvider)}) + } + plan.EnableFeatures = stringListOpt(opts, "enable_features") + plan.DisableFeatures = stringListOpt(opts, "disable_features") + return plan, nil +} + +// FirstUserInput translates the prompt text + attachments into the +// turn/start payload. Today only text is honoured; image / file +// attachments arrive as proto.PromptAttachment but aren't surfaced to +// the codex CLI yet — TODO once the daemon writes them to disk. +func FirstUserInput(prompt string) []UserInput { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil + } + return []UserInput{{Type: UserInputText, Text: prompt}} +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func resolveWorkDirCodex(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("codex: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("codex: work_dir must be absolute or start with ~/, got %q", trimmed) + } + // Match claudecode's resolveSessionWorkDir: mkdir -p so a user + // naming a fresh project root in the agent wizard works on first + // run instead of erroring with "does not exist". + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("codex: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func allocCodexHome(agentStateKey string) (string, error) { + if strings.TrimSpace(agentStateKey) == "" { + return "", fmt.Errorf("codex: agentStateKey required for CODEX_HOME allocation") + } + root, err := paths.Root() + if err != nil { + return "", err + } + parts := strings.Split(agentStateKey, "/") + safeParts := make([]string, 0, len(parts)) + for _, part := range parts { + if safe := safePathPartCodex(part); safe != "" { + safeParts = append(safeParts, safe) + } + } + if len(safeParts) == 0 { + return "", fmt.Errorf("codex: invalid agentStateKey %q", agentStateKey) + } + dirParts := append([]string{root, "parsar-daemon", "agent-sessions"}, safeParts...) + dir := filepath.Join(dirParts...) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("codex: create CODEX_HOME %s: %w", dir, err) + } + return dir, nil +} + +func resetGeneratedConfig(codexHome string) error { + path := filepath.Join(codexHome, "config.toml") + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("codex: remove generated config %s: %w", path, err) + } + return nil +} + +func buildSessionEnv(opts map[string]any) ([]string, error) { + env := []string{ + "DISABLE_TELEMETRY=1", + } + raw, ok := opts["env"] + if !ok || raw == nil { + return env, nil + } + envMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex.BuildSessionPlan: env must be object, got %T", raw) + } + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return nil, fmt.Errorf("codex.BuildSessionPlan: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func stringListOpt(opts map[string]any, key string) []string { + if opts == nil { + return nil + } + raw, ok := opts[key] + if !ok || raw == nil { + return nil + } + arr, ok := raw.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + +func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { + if raw == nil { + return nil, nil + } + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex: mcp_servers must be object, got %T", raw) + } + out := make(map[string]mcpServerConfig, len(m)) + for name, v := range m { + entry, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) + } + srv := mcpServerConfig{Name: name} + if url, ok := entry["url"].(string); ok { + srv.URL = strings.TrimSpace(url) + } + if cmd, ok := entry["command"].(string); ok { + srv.Command = cmd + } + if args, ok := entry["args"].([]any); ok { + for _, a := range args { + if s, ok := a.(string); ok { + srv.Args = append(srv.Args, s) + } + } + } + if env, ok := entry["env"].(map[string]any); ok { + srv.Env = make(map[string]string, len(env)) + for k, val := range env { + if s, ok := val.(string); ok { + srv.Env[k] = s + } + } + } + if headers, ok := entry["headers"].(map[string]any); ok { + srv.Headers = make(map[string]string, len(headers)) + for key, value := range headers { + if text, ok := value.(string); ok { + srv.Headers[key] = text + } + } + } else if headers, ok := entry["headers"].(map[string]string); ok { + srv.Headers = headers + } + if srv.Command == "" && srv.URL == "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) + } + if srv.Command != "" && srv.URL != "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) + } + out[name] = srv + } + return out, nil +} + +// normaliseProviderConfig flattens agent_options["codex_provider"] (a +// string-keyed map produced by injectCodexManagedModel) into a typed +// providerConfig. Returns hasProvider=false when the key is absent, so +// BuildSessionPlan can skip the TOML write entirely; that path is only +// exercised by tests that don't care about model auth. +// +// base_url + bearer_token are validated by writeCodexProviderConfig +// itself (single source of truth) so this function only normalises +// shapes. +func normaliseProviderConfig(raw any) (providerConfig, bool, error) { + if raw == nil { + return providerConfig{}, false, nil + } + m, ok := raw.(map[string]any) + if !ok { + return providerConfig{}, false, fmt.Errorf("codex: codex_provider must be object, got %T", raw) + } + cfg := providerConfig{} + if v, ok := m["name"].(string); ok { + cfg.Name = v + } + if v, ok := m["base_url"].(string); ok { + cfg.BaseURL = v + } + if v, ok := m["bearer_token"].(string); ok { + cfg.BearerToken = v + } + if v, ok := m["wire_api"].(string); ok { + cfg.WireAPI = v + } + if hdrs, ok := m["http_headers"].(map[string]any); ok { + cfg.HTTPHeaders = make(map[string]string, len(hdrs)) + for k, v := range hdrs { + if s, ok := v.(string); ok { + cfg.HTTPHeaders[k] = s + } + } + } + if params, ok := m["query_params"].(map[string]any); ok { + cfg.QueryParams = make(map[string]string, len(params)) + for k, v := range params { + if s, ok := v.(string); ok { + cfg.QueryParams[k] = s + } + } + } + cfg.RequestMaxRetries = intOpt(m, "request_max_retries") + cfg.StreamMaxRetries = intOpt(m, "stream_max_retries") + return cfg, true, nil +} + +// intOpt extracts an integer-shaped value from a map. JSON-decoded +// numbers arrive as float64; tests sometimes pass int directly. Both +// are accepted; non-numeric / missing yields 0. +func intOpt(m map[string]any, key string) int { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + } + return 0 +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +func safePathPartCodex(runID string) string { + var b strings.Builder + for _, r := range runID { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "" { + return "run" + } + return out +} + +// strconv quotes a value as a TOML string. Done by reusing the JSON +// encoder for escape rules — TOML strings accept the same standard +// escape set so this is wire-safe. +func strconv(s string) string { + q, _ := json.Marshal(s) + return string(q) +} diff --git a/apps/parsar-daemon/internal/agent/codex/options_test.go b/apps/parsar-daemon/internal/agent/codex/options_test.go new file mode 100644 index 000000000..19bb72737 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/options_test.go @@ -0,0 +1,280 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + if plan.ApprovalPolicy.String != "never" { + t.Fatalf("default policy must bypass approvals, got %+v", plan.ApprovalPolicy) + } + if plan.Sandbox != SandboxDangerFullAcces { + t.Fatalf("default sandbox = %s, want danger-full-access", plan.Sandbox) + } + if plan.Cleanup == nil { + t.Fatal("Cleanup must be non-nil") + } + plan.Cleanup() +} + +func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "env": map[string]any{ + "OPENAI_API_KEY": "sk-test", + }, + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + hasCodexHome := false + hasOpenAI := false + hasTelemetry := false + for _, kv := range plan.Env { + switch { + case strings.HasPrefix(kv, "CODEX_HOME="): + hasCodexHome = true + case kv == "OPENAI_API_KEY=sk-test": + hasOpenAI = true + case kv == "DISABLE_TELEMETRY=1": + hasTelemetry = true + } + } + if !hasCodexHome { + t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) + } + if !hasOpenAI { + t.Fatalf("env missing OPENAI_API_KEY: %+v", plan.Env) + } + if !hasTelemetry { + t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) + } +} + +func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { + stateKey := "conv-stable/agent-stable/codex" + planA, err := BuildSessionPlan("run-a", stateKey, "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan A: %v", err) + } + planB, err := BuildSessionPlan("run-b", stateKey, "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan B: %v", err) + } + if codexHomeFromEnv(planA.Env) == "" || codexHomeFromEnv(planA.Env) != codexHomeFromEnv(planB.Env) { + t.Fatalf("CODEX_HOME must be stable by state key: A=%q B=%q", codexHomeFromEnv(planA.Env), codexHomeFromEnv(planB.Env)) + } +} + +func TestBuildSessionPlan_RoutesReasoningSummary(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "reasoning_summary": "detailed", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if len(plan.ExtraConfig) != 1 { + t.Fatalf("ExtraConfig = %+v", plan.ExtraConfig) + } + kv := plan.ExtraConfig[0] + if kv[0] != "model_reasoning_summary" { + t.Fatalf("override key = %q", kv[0]) + } + if kv[1] != `"detailed"` { + t.Fatalf("override value = %q (must be TOML-quoted)", kv[1]) + } +} + +func codexHomeFromEnv(env []string) string { + for _, kv := range env { + if strings.HasPrefix(kv, "CODEX_HOME=") { + return strings.TrimPrefix(kv, "CODEX_HOME=") + } + } + return "" +} + +func TestBuildSessionPlan_OverrideSystemPromptReplacesAppend(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "system_prompt": "user base", + "override_system_prompt": "you are pirate", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.SystemPrompt != "you are pirate" { + t.Fatalf("SystemPrompt = %q, want %q", plan.SystemPrompt, "you are pirate") + } +} + +func TestBuildSessionPlan_EmptyOverrideKeepsSystemPrompt(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "system_prompt": "user base", + "override_system_prompt": "", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.SystemPrompt != "user base" { + t.Fatalf("SystemPrompt = %q, want %q (empty override should not clobber)", plan.SystemPrompt, "user base") + } +} + +func TestBuildSessionPlan_ParsesCollaborationMode(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mode": "plan", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.CollaborationMode != CollaborationModePlan { + t.Fatalf("CollaborationMode = %q, want plan", plan.CollaborationMode) + } +} + +func TestBuildSessionPlan_OmittedModeRetainsCurrentInstructions(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + for _, mode := range []string{"", "default"} { + opts := map[string]any{"system_prompt": "current reference", "model": "MiniMax-M3"} + if mode != "" { + opts["mode"] = mode + } + plan, err := BuildSessionPlan("run", "conv/agent/codex", "", opts) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { + t.Fatalf("mode %q did not retain the default turn instructions: %+v", mode, plan) + } + } +} + +func TestBuildSessionPlan_RejectsUnknownCollaborationMode(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mode": "autopilot", + }) + if err == nil || !strings.Contains(err.Error(), "unsupported collaboration mode") { + t.Fatalf("expected unsupported collaboration mode error, got %v", err) + } +} + +func TestBuildSessionPlan_RejectsRelativeWorkDir(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "relative/dir", nil) + if err == nil { + t.Fatal("relative work_dir must error") + } +} + +// TestBuildSessionPlan_CreatesMissingWorkDir: align with claudecode — +// a non-existent absolute path is mkdir -p'd so a user can pin a fresh +// project root in the agent wizard. Without this, codex agents would +// hard-fail the first turn instead of running. +func TestBuildSessionPlan_CreatesMissingWorkDir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", target, nil) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.Cwd != target { + t.Fatalf("plan.Cwd = %q, want %q", plan.Cwd, target) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildSessionPlan_WritesMCPConfig(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mcp_servers": map[string]any{ + "docs": map[string]any{ + "command": "docs-server", + "args": []any{"--port", "8080"}, + "env": map[string]any{"TOKEN": "abc"}, + }, + }, + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + // Find CODEX_HOME so we can verify the config.toml was written there. + codexHome := "" + for _, kv := range plan.Env { + if strings.HasPrefix(kv, "CODEX_HOME=") { + codexHome = strings.TrimPrefix(kv, "CODEX_HOME=") + break + } + } + if codexHome == "" { + t.Fatal("CODEX_HOME not in plan.Env") + } + // mcp_config.go writes /config.toml — verify it exists + // and contains the rendered server. + bodyBytes, err := os.ReadFile(codexHome + "/config.toml") + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + body := string(bodyBytes) + if !strings.Contains(body, `[mcp_servers."docs"]`) { + t.Fatalf("config.toml missing docs server: %s", body) + } + if !strings.Contains(body, `command = "docs-server"`) { + t.Fatalf("config.toml missing command: %s", body) + } +} + +func TestBuildSessionPlan_MissingMCPCommandErrors(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mcp_servers": map[string]any{ + "broken": map[string]any{ + "args": []any{"--x"}, + // no command + }, + }, + }) + if err == nil { + t.Fatal("missing mcp command must surface as error") + } +} + +func TestFirstUserInput_TrimsAndWrapsAsText(t *testing.T) { + inputs := FirstUserInput(" hello world ") + if len(inputs) != 1 { + t.Fatalf("len = %d", len(inputs)) + } + if inputs[0].Type != UserInputText || inputs[0].Text != "hello world" { + t.Fatalf("input = %+v", inputs[0]) + } +} + +func TestFirstUserInput_EmptyReturnsNil(t *testing.T) { + if got := FirstUserInput(" "); got != nil { + t.Fatalf("empty prompt must return nil, got %+v", got) + } +} + +func TestStringListOpt_FiltersEmpties(t *testing.T) { + got := stringListOpt(map[string]any{ + "enable_features": []any{"a", "", " ", "b"}, + }, "enable_features") + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("filter = %+v", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go new file mode 100644 index 000000000..4e0fc9fdf --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -0,0 +1,48 @@ +package codex + +import ( + "errors" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// The deployment selects a native named profile; request options cannot select it. +// Native managed requirements must constrain its definition and allowed profiles. +// This selection does not establish workspace authority or public admission. +func validatePermissionProfile(req proto.PromptRequestPayload, profile string) error { + if req.LocalEnvironment != nil && profile == "" { + return errors.New("codex: local Environment requires deployment-managed permissions") + } + if profile == "" { + return nil + } + if strings.TrimSpace(profile) != profile || strings.HasPrefix(profile, ":") { + return errors.New("codex: deployment permissions require a named native profile") + } + if req.RemoteEnvironment != nil || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { + return errors.New("codex: deployment permission profile requires local execution") + } + return nil +} + +// managedPermissionProfile maps a validated deployment binding to native policy. +// Public or prompt options cannot choose a native profile or widen that binding. +func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) (string, error) { + profile := cfg.permissionProfile + if cfg.runtimeNetworkAccess != "" { + if req.LocalEnvironment == nil || req.LocalEnvironment.NetworkAccess != cfg.runtimeNetworkAccess || profile != "managed-workspace" { + return "", errors.New("codex: Runtime network policy mismatch") + } + switch cfg.runtimeNetworkAccess { + case "disabled": + case "enabled": + profile = "managed-workspace-enabled" + default: + return "", errors.New("codex: unsupported Runtime network policy") + } + } else if req.LocalEnvironment != nil && req.LocalEnvironment.NetworkAccess != "" { + return "", errors.New("codex: Runtime has no bound network policy") + } + return profile, validatePermissionProfile(req, profile) +} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go new file mode 100644 index 000000000..50c1cb23c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -0,0 +1,92 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPermissionProfileRejectsIncompatiblePreparationBeforeState(t *testing.T) { + for _, tc := range []struct { + name string + profile string + req proto.PromptRequestPayload + }{ + {"builtin", ":danger-full-access", proto.PromptRequestPayload{}}, + {"whitespace", " ", proto.PromptRequestPayload{}}, + {"remote", "managed-workspace", proto.PromptRequestPayload{RemoteEnvironment: &proto.RemoteEnvironment{}}}, + {"none", "managed-workspace", proto.PromptRequestPayload{DisableExecutionEnvironment: true}}, + {"read-owner", "managed-workspace", proto.PromptRequestPayload{WorkspaceReadOnly: true}}, + } { + t.Run(tc.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "uncreated") + t.Setenv("PARSAR_HOME", root) + if _, _, err := prepareSessionPlan(context.Background(), tc.req, sessionConfig{permissionProfile: tc.profile}); err == nil { + t.Fatal("incompatible profile accepted") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("rejection created state", err) + } + }) + } +} + +func TestPermissionProfileSelectsNativeStartupConfig(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + t.Setenv("PARSAR_CODEX_PERMISSION_PROFILE", "managed-workspace") + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true}, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + config := map[string]string{} + for _, kv := range plan.ExtraConfig { + config[kv[0]] = kv[1] + } + if config["default_permissions"] != `"managed-workspace"` || plan.Sandbox != "" || plan.Permissions != "managed-workspace" { + t.Fatal("native managed selection missing") + } + if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { + t.Fatal("shell can inherit model credentials") + } + if config["features.shell_snapshot"] != "false" { + t.Fatal("managed shell depends on inaccessible private snapshots") + } +} + +func TestManagedNetworkPolicySelectsNativeProfileAndRejectsMismatchBeforeState(t *testing.T) { + for _, mode := range []string{"enabled", "disabled"} { + t.Run(mode, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "uncreated") + t.Setenv("PARSAR_HOME", root) + req := proto.PromptRequestPayload{AgentStateKey: "session", LocalEnvironment: &proto.LocalEnvironment{ID: "environment", NetworkAccess: mode}} + cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetworkAccess: mode} + wrong := req + wrong.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted"} + if _, _, err := prepareSessionPlan(t.Context(), wrong, cfg); err == nil { + t.Fatal("policy mismatch accepted") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("policy rejection created native state") + } + plan, _, err := prepareSessionPlan(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + expected := "managed-workspace" + if mode == "enabled" { + expected += "-enabled" + } + if plan.Permissions != expected || plan.Sandbox != "" { + t.Fatal("wrong native profile", plan.Permissions) + } + if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{permissionProfile: "managed-workspace"}); err == nil { + t.Fatal("unbound Runtime accepted explicit policy") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go new file mode 100644 index 000000000..6470334bd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -0,0 +1,177 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// Prepare connects the native harness without creating a thread or starting model +// work. req supplies configuration and a stable state key, but no RunID or Prompt. +// owner owns the entire harness lifetime, including the eventual Session; it must +// not be a disposable readiness-request context. Initialization/readiness use their +// existing operation-local deadlines. Close an unused preparation explicitly. +func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { + return newPreparation(owner, req, defaultSessionConfig()) +} + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("codex: nil out channel") + } + runID, prompt := req.RunID, req.Prompt + req.AgentStateKey = effectiveAgentStateKey(req) + req.RunID, req.Prompt = "", "" + prepared, err := newPreparation(parent, req, cfg) + if err != nil { + return nil, err + } + defer prepared.Close() + return prepared.start(parent, runID, prompt, out) +} + +func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Prepared, error) { + if req.WorkspaceReadOnly && (!proto.ValidWorkspaceReadPreparation(req) || cfg.harnessBinary == "") { + return nil, errors.New("codex: read-only preparation requires a private harness and a closed read configuration") + } + if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { + return nil, errors.New("codex: native-session recovery requires strict private state") + } + if req.RunID != "" || req.Prompt != "" { + return nil, errors.New("codex: preparation does not accept a run identity or prompt") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.codexBinary == "" { + cfg.codexBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = rpcKillTimeout + } + functions, err := prepareFunctionTools(req.FunctionTools) + if err != nil { + return nil, err + } + if err := validateRemoteEnvironmentRequest(req); err != nil { + return nil, err + } + req.AgentStateKey = effectiveAgentStateKey(req) + + if !req.WorkspaceReadOnly { + req.AgentOptions = executionOptions(req) + } + plan, skillRoot, err := prepareSessionPlan(parent, req, cfg) + if err != nil { + return nil, err + } + + cancelCtx, cancelFn := context.WithCancel(parent) + + rpcCfg := JSONRPCConfig{ + Binary: cfg.codexBinary, + EnableFeatures: plan.EnableFeatures, + DisableFeatures: plan.DisableFeatures, + Cwd: plan.Cwd, + Env: append(os.Environ(), plan.Env...), + LogTag: "codex-preparation", + Logger: cfg.logger, + } + if req.WorkspaceReadOnly { + rpcCfg.Env = append(workspaceReadEnvironment(os.Environ()), plan.Env...) + rpcCfg.ExtraArgs = []string{"--workspace-read-only"} + } + for _, kv := range plan.ExtraConfig { + rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) + } + harness, err := configurePrivateHarness(&rpcCfg, cfg.harnessBinary, req.RemoteEnvironment) + if err != nil { + cancelFn() + plan.Cleanup() + return nil, err + } + + rpc := NewJSONRPCClient(rpcCfg) + defer harness.releaseWith(rpc) + + s := &Session{ + toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, + functions: functions, + observeMessages: req.ObserveMessages, + observeTools: req.ObserveTools, + observeToolObservations: req.ObserveToolObservations, + observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, + cfg: cfg, + rpc: rpc, + harness: harness, + cancelCtx: cancelCtx, + cancelFn: cancelFn, + waitDone: make(chan struct{}), + cleanup: sync.OnceFunc(plan.Cleanup), + bufs: NewItemBuffers(), + resolvedModel: plan.Model, + interactions: newPendingCodexInteractions(), + } + if req.WorkspaceReadOnly { + s.cleanup = readPreparationCleanup(rpc, s.cleanup) + } + plan.Cleanup = s.cleanup + p := &Prepared{ + session: s, plan: plan, remote: req.RemoteEnvironment != nil, workspaceReadOnly: req.WorkspaceReadOnly, + resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, + transferred: make(chan struct{}), + } + + initParams := InitializeParams{ + ClientInfo: InitializeClientInfo{Name: "parsar-daemon", Version: "0.0.0"}, + Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, + } + if _, err := rpc.Start(cancelCtx, initParams); err != nil { + return p.preparationFailed(fmt.Errorf("codex: rpc start: %w", err)) + } + if err := harness.verify(); err != nil { + return p.preparationFailed(err) + } + if req.DisableExecutionEnvironment { + if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { + cancelFn() + _ = rpc.Close() + plan.Cleanup() + return nil, err + } + } + if req.RemoteEnvironment != nil { + if err := verifyRemoteEnvironment(cancelCtx, rpc); err != nil { + return p.preparationFailed(err) + } + } + if s.toolEnvironment { + if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { + return p.preparationFailed(err) + } + } + if plan.mcpHTTPServers != nil { + if err := verifyMCPHTTPConfig(cancelCtx, rpc, plan); err != nil { + cancelFn() + _ = rpc.Close() + plan.Cleanup() + return nil, err + } + } + if skillRoot != "" { + if err := setSkillExtraRoots(cancelCtx, rpc, []string{skillRoot}); err != nil { + cancelFn() + _ = rpc.Close() + plan.Cleanup() + return nil, fmt.Errorf("codex: register skill root: %w", err) + } + } + + go p.watchOwner() + return p, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_close_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_close_test.go new file mode 100644 index 000000000..4abb0ce68 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/preparation_close_test.go @@ -0,0 +1,45 @@ +package codex + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestPreparedCloseWaitsForOwnerCleanup(t *testing.T) { + req, cfg, root := preparationFixture(t) + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + p, err := newPreparation(owner, req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + cleanup := p.plan.Cleanup + p.plan.Cleanup = sync.OnceFunc(func() { close(entered); <-release; cleanup() }) + cancel() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("owner watcher did not enter cleanup") + } + done := make(chan struct{}) + go func() { _ = p.Close(); close(done) }() + select { + case <-done: + t.Fatal("Close returned while owner cleanup was still running") + case <-time.After(50 * time.Millisecond): + } + unblock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Close did not finish after cleanup") + } + waitPreparedRelease(t, p, root) +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go new file mode 100644 index 000000000..8ca9fa19d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -0,0 +1,217 @@ +package codex + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type preparationFrame struct { + PID int `json:"pid"` + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig, string) { + t.Helper() + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + t.Setenv("PARSAR_PREPARATION_FAKE", "1") + t.Setenv("PARSAR_PREPARATION_FRAMES", filepath.Join(root, "frames.jsonl")) + t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "remote-status")) + t.Setenv("PARSAR_PREPARATION_BLOCK", "") + t.Setenv("PARSAR_PREPARATION_OBSERVE", "") + for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { + t.Setenv(key, "") + } + binary := filepath.Join(root, "fake-codex") + executable := "'" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "'" + body := "#!/bin/sh\nexec " + executable + " -test.run=^TestPreparationFakeCodexProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cfg := defaultSessionConfig() + cfg.codexBinary = binary + req := proto.PromptRequestPayload{ + AgentKind: "codex", AgentStateKey: "prepared-session", WorkDir: filepath.Join(root, "harness"), + ReleaseOnCompletion: true, StrictResume: true, + AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}, + RemoteEnvironment: &proto.RemoteEnvironment{ID: "fixture-environment", WorkspaceDirectory: "/executor-only", ConnectionURL: "http://127.0.0.1:12345", ConnectionToken: "synthetic-harness-token"}, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, + } + return req, cfg, root +} + +func preparationFrames(t *testing.T, root string) []preparationFrame { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "frames.jsonl")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + var frames []preparationFrame + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var frame preparationFrame + if err := json.Unmarshal([]byte(line), &frame); err != nil { + t.Fatal(err) + } + frames = append(frames, frame) + } + return frames +} + +func waitPreparationMethod(t *testing.T, root, method string) []preparationFrame { + t.Helper() + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + frames := preparationFrames(t, root) + for _, frame := range frames { + if frame.Method == method { + return frames + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("native request not observed", method) + return nil +} + +func assertPreparationOnly(t *testing.T, root string) { + t.Helper() + for _, frame := range preparationFrames(t, root) { + if strings.HasPrefix(frame.Method, "thread/") || strings.HasPrefix(frame.Method, "turn/") { + t.Fatal("preparation started native work", frame.Method) + } + } +} + +func preparedCatalogs(t *testing.T, root string) []string { + t.Helper() + files, err := filepath.Glob(filepath.Join(root, "parsar-daemon", "agent-sessions", "prepared-session", "model-catalog-*.json")) + if err != nil { + t.Fatal(err) + } + return files +} + +func waitPreparedRelease(t *testing.T, p *Prepared, root string) { + t.Helper() + select { + case <-p.session.rpc.Done(): + case <-time.After(4 * time.Second): + t.Fatal("prepared child was not released") + } + deadline := time.Now().Add(time.Second) + for len(preparedCatalogs(t, root)) != 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if len(preparedCatalogs(t, root)) != 0 { + t.Fatal("prepared model catalog was not cleaned") + } +} + +func TestPreparationFakeCodexProcess(t *testing.T) { + if os.Getenv("PARSAR_PREPARATION_FAKE") != "1" { + return + } + for _, arg := range os.Args { + if arg == "models" { + _, _ = os.Stdout.WriteString(`{"models":[{"slug":"fixture-model","support_verbosity":true}]}`) + os.Exit(0) + } + } + fakePrivateHarnessEndpoint() + log, err := os.OpenFile(os.Getenv("PARSAR_PREPARATION_FRAMES"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + os.Exit(2) + } + frames := json.NewEncoder(log) + output := json.NewEncoder(os.Stdout) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var frame preparationFrame + if json.Unmarshal(scanner.Bytes(), &frame) != nil { + os.Exit(3) + } + frame.PID = os.Getpid() + if frames.Encode(frame) != nil { + os.Exit(4) + } + var result any = map[string]any{} + switch frame.Method { + case "initialize": + result = map[string]string{"userAgent": "fixture-codex"} + case "environment/info": + if os.Getenv("PARSAR_PREPARATION_BLOCK") == "1" { + for { + time.Sleep(time.Second) + } + } + result = map[string]any{"shell": map[string]string{"path": "/bin/sh"}} + case "environment/status": + var params map[string]string + _ = json.Unmarshal(frame.Params, ¶ms) + status := "unknown" + if params["environmentId"] == "remote" { + status = "ready" + if data, err := os.ReadFile(os.Getenv("PARSAR_PREPARATION_STATUS")); err == nil { + status = string(data) + } + } + if status == "blocked" { + for { + time.Sleep(time.Second) + } + } + result = map[string]string{"status": status} + case "config/read": + data, err := os.ReadFile(os.Getenv("PARSAR_PREPARATION_MCP_CONFIG")) + if err != nil || json.Unmarshal(data, &result) != nil { + os.Exit(6) + } + case "thread/start", "thread/resume": + if gate := os.Getenv("PARSAR_PREPARATION_THREAD_GATE"); gate != "" { + var state []byte + for string(state) != "ready" && string(state) != "failed" { + state, _ = os.ReadFile(gate) + time.Sleep(time.Millisecond) + } + if string(state) == "failed" { + _ = output.Encode(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "error": map[string]any{"code": -32603, "message": "required MCP initialization failed"}}) + continue + } + } + result = map[string]any{"thread": map[string]string{"id": "fixture-native-thread"}, "model": "fixture-model"} + case "turn/start": + result = map[string]any{"turn": map[string]string{"id": "fixture-native-turn"}} + } + if output.Encode(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": result}) != nil { + os.Exit(5) + } + if frame.Method == "turn/start" { + _ = output.Encode(map[string]any{"jsonrpc": "2.0", "method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": "fixture-native-turn"}}}) + if os.Getenv("PARSAR_PREPARATION_OBSERVE") == "1" { + for _, raw := range []string{ + `{"method":"item/completed","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","item":{"type":"agentMessage","id":"message","text":"observed partial answer"}}}`, + `{"method":"thread/tokenUsage/updated","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","tokenUsage":{"total":{"inputTokens":30,"cachedInputTokens":4,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":40}}}}`, + } { + _ = output.Encode(json.RawMessage(raw)) + } + } + } + } + _ = log.Close() + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go new file mode 100644 index 000000000..80bd7b5d6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -0,0 +1,129 @@ +package codex + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type preparationWireSender chan proto.Envelope + +func (s preparationWireSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { + for _, start := range []bool{false, true} { + t.Run(map[bool]string{false: "disconnect-before-start", true: "transfer-and-cancel"}[start], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("ordinary Factory must not run") + }) + prepared := make(chan *Prepared, 1) + registry.RegisterPreparation("codex", false, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + p, err := newPreparation(ctx, req, cfg) + if err != nil { + return nil, err + } + prepared <- p + return p, nil + }) + sender := make(preparationWireSender, 64) + r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + send := func(kind, id string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + await := func(state string) proto.PreparationStatusPayload { + t.Helper() + timer := time.NewTimer(4 * time.Second) + defer timer.Stop() + for { + select { + case env := <-sender: + var status proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil { + if status.State == "failed" || status.State == "rejected" { + t.Fatal(status.State, status.ErrorCode) + } + if status.State == state { + return status + } + } + case <-timer.C: + t.Fatal("preparation status missing", state) + return proto.PreparationStatusPayload{} + } + } + } + send(proto.TypeExecutionPrepare, "prepare-request", proto.ExecutionPreparePayload{Configuration: req}) + ready := await("ready") + p := <-prepared + assertPreparationOnly(t, root) + pid := p.session.rpc.cmd.Process.Pid + if r.ActiveRuns() != 0 { + t.Fatal("preparation became a Run") + } + if start { + input := proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "actual-run", Prompt: "actual input"} + send(proto.TypeExecutionStart, "prepare-request", input) + await("started") + frames := waitPreparationMethod(t, root, "turn/start") + turns := 0 + for _, frame := range frames { + if frame.PID != pid { + t.Fatal("native child changed") + } + if frame.Method == "turn/start" { + turns++ + } + } + if turns != 1 { + t.Fatal("unexpected native Turn count", turns) + } + send(proto.TypeExecutionStart, "prepare-request", input) + send(proto.TypeExecutionRelease, "prepare-request", proto.ExecutionReleasePayload{Handle: ready.Handle}) + if !p.session.rpc.Alive() || r.ActiveRuns() != 1 { + t.Fatal("release cancelled transferred native session") + } + send(proto.TypePromptCancel, "actual-run", proto.PromptCancelPayload{}) + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Fatal(err) + } + waitPreparedRelease(t, p, root) + if !start { + assertPreparationOnly(t, root) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/parsar-daemon/internal/agent/codex/prepared.go new file mode 100644 index 000000000..2f0220db9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/prepared.go @@ -0,0 +1,141 @@ +package codex + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Prepared owns a connected native resource until Start transfers it to a Session. +// It observes owner cancellation and RPC exit, not continuous executor readiness. +// Remote status is rechecked at Start without reconnecting the prepared resource. +type Prepared struct { + mu sync.Mutex + session *Session + plan SessionPlan + remote bool + workspaceReadOnly bool + resumeID string + strictResume bool + requireExistingNativeSession bool + claimed bool + closed bool + started bool + transferred chan struct{} +} + +var _ agent.PreparedCancellation = (*Prepared)(nil) + +// Start consumes the preparation once. ctx bounds only this start operation; +// cancellation after return does not cancel the transferred Session. The original +// owner context remains its lifetime context. On success the Session owns out. +func (p *Prepared) Start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (agent.Session, error) { + session, err := p.start(ctx, runID, prompt, out) + if err != nil { + return nil, err + } + return session, nil +} + +func (p *Prepared) start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (*Session, error) { + if p.workspaceReadOnly { + return nil, errors.New("codex: read-only preparation cannot start execution") + } + if out == nil || strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" { + return nil, errors.New("codex: start requires a run identity, prompt and output channel") + } + p.mu.Lock() + if p.claimed || p.closed { + p.mu.Unlock() + return nil, errors.New("codex: preparation is no longer available") + } + p.claimed = true + p.mu.Unlock() + + transferred := false + defer func() { + if !transferred { + _ = p.Close() + } + }() + if p.remote { + check, cancel := context.WithTimeout(ctx, 5*time.Second) + status, err := nativeEnvironmentStatus(check, p.session.rpc, "remote") + cancel() + if err != nil || status != "ready" { + return nil, errors.New("codex: prepared remote environment is no longer ready") + } + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || ctx.Err() != nil || p.session.cancelCtx.Err() != nil || !p.session.rpc.Alive() { + return nil, errors.New("codex: prepared harness is no longer available") + } + s := p.session + s.runID, s.out = runID, out + if s.observeSubagentIdentities { + s.startSubagentObservations() + } + s.registerHandlers() + p.started = true + close(p.transferred) + transferred = true + req := proto.PromptRequestPayload{RunID: runID, Prompt: prompt, AgentSessionID: p.resumeID, StrictResume: p.strictResume, RequireExistingNativeSession: p.requireExistingNativeSession} + go s.run(p.plan, req) + return s, nil +} + +// Close waits for unused teardown and plan cleanup, including another caller's +// ongoing Close. After successful Start it is inert; use +// the returned Session's cancellation path to release the transferred resource. +func (p *Prepared) Close() error { + p.mu.Lock() + if p.started { + p.mu.Unlock() + return nil + } + p.closed = true + p.mu.Unlock() + p.session.cancelFn() + err := p.session.rpc.Close() + p.plan.Cleanup() + if err == nil && p.workspaceReadOnly { + return os.RemoveAll(p.plan.Cwd) + } + return err +} + +// Cancel fences Start and cancels the resource even after transfer. +func (p *Prepared) Cancel(ctx context.Context) error { + p.mu.Lock() + p.closed = true + started := p.started + p.mu.Unlock() + if started { + err := p.session.Cancel(ctx) + <-p.session.waitDone + return err + } + return p.Close() +} + +// CancellationOutcome returns observed state, not a guarantee of final output or quiescence. +func (p *Prepared) CancellationOutcome() proto.DonePayload { + return p.session.CancellationOutcome() +} + +func (p *Prepared) watchOwner() { + select { + case <-p.session.cancelCtx.Done(): + case <-p.session.rpc.Done(): + case <-p.transferred: + return + } + _ = p.Close() +} diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go new file mode 100644 index 000000000..68426b2e8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go @@ -0,0 +1,269 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.AgentSessionID = "requested-but-unobserved-thread" + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + allowCleanup := sync.OnceFunc(func() { close(release) }) + defer allowCleanup() + cleanup := p.plan.Cleanup + var cleanups atomic.Int32 + p.plan.Cleanup = sync.OnceFunc(func() { + cleanups.Add(1) + close(entered) + <-release + cleanup() + }) + finished := make(chan error, 4) + for range 4 { + go func() { finished <- p.Cancel(context.Background()) }() + } + select { + case <-entered: + case <-time.After(4 * time.Second): + t.Fatal("cancellation did not begin cleanup") + } + out := make(chan proto.Envelope, 8) + if s, err := p.Start(t.Context(), "late", "must not execute", out); err == nil || s != nil { + t.Fatal("cancellation did not fence Start") + } + select { + case <-finished: + t.Fatal("cancellation returned before unused cleanup") + default: + } + allowCleanup() + for range 4 { + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("repeated cancellation did not finish") + } + } + if cleanups.Load() != 1 { + t.Fatal("cleanup ran more than once") + } + waitPreparedRelease(t, p, root) + assertPreparationOnly(t, root) + assertUnstartedCancellation(t, p) +} + +func TestPreparedCancelDuringStartReadiness(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.AgentSessionID = "requested-but-unobserved-thread" + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Cancel(context.Background()) + before := len(preparationFrames(t, root)) + if err := os.WriteFile(filepath.Join(root, "remote-status"), []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + out := make(chan proto.Envelope, 8) + go func() { + session, err := p.Start(t.Context(), "run", "prompt", out) + if session != nil { + t.Error("cancelled readiness returned a Session") + } + finished <- err + }() + deadline := time.Now().Add(4 * time.Second) + for len(preparationFrames(t, root)) == before && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + frames := preparationFrames(t, root) + if len(frames) != before+1 || frames[before].Method != "environment/status" { + t.Fatal("Start did not enter its readiness recheck") + } + if err := p.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + select { + case err := <-finished: + if err == nil { + t.Fatal("cancelled Start succeeded") + } + case <-time.After(4 * time.Second): + t.Fatal("cancelled Start remained blocked") + } + waitPreparedRelease(t, p, root) + assertPreparationOnly(t, root) + assertUnstartedCancellation(t, p) + if len(out) != 0 { + t.Fatal("unused resource emitted Run output") + } +} + +func assertUnstartedCancellation(t *testing.T, p *Prepared) { + t.Helper() + got := p.CancellationOutcome() + if got.Content != "" || len(got.Metadata) != 0 || !reflect.DeepEqual(got.Usage, proto.Usage{}) { + t.Fatalf("unobserved result was invented: %+v", got) + } +} + +func TestPreparedCancelTransferredPreservesObservedOutcome(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("PARSAR_PREPARATION_OBSERVE", "1") + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Cancel(context.Background()) + started, err := p.Start(t.Context(), "run", "prompt", make(chan proto.Envelope, 16)) + if err != nil { + t.Fatal(err) + } + s := started.(*Session) + deadline := time.Now().Add(4 * time.Second) + for { + got := p.CancellationOutcome() + if got.Content == "observed partial answer" && got.Usage.Tokens != nil { + break + } + if time.Now().After(deadline) { + t.Fatal("native output and Usage were not observed") + } + time.Sleep(time.Millisecond) + } + if err := p.Close(); err != nil || !s.rpc.Alive() { + t.Fatal("Close cancelled transferred Session", err) + } + var calls sync.WaitGroup + for range 4 { + calls.Go(func() { + if err := p.Cancel(context.Background()); err != nil { + t.Error(err) + } + }) + } + calls.Wait() + select { + case <-s.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("transferred Session did not finish") + } + waitPreparedRelease(t, p, root) + got := p.CancellationOutcome() + want := proto.TokenUsage{InputTokens: 30, CachedInputTokens: 4, OutputTokens: 10, ReasoningOutputTokens: 2, TotalTokens: 40} + if got.Content != "observed partial answer" || got.Metadata[proto.DoneMetaAgentSessionID] != "fixture-native-thread" || got.Usage.Tokens == nil || *got.Usage.Tokens != want { + t.Fatalf("observed outcome lost: %+v", got) + } + if !reflect.DeepEqual(got, s.CancellationOutcome()) { + t.Fatal("preparation and Session exposed different outcomes") + } + interrupts := 0 + for _, frame := range preparationFrames(t, root) { + if frame.Method == "turn/interrupt" { + interrupts++ + } + } + if interrupts != 1 { + t.Fatal("native cancellation was missing or repeated", interrupts) + } +} + +func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { + req, cfg, root := preparationFixture(t) + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Cancel(context.Background()) + entered, release := make(chan struct{}), make(chan struct{}) + allowCleanup := sync.OnceFunc(func() { close(release) }) + defer allowCleanup() + cleanup := p.session.cleanup + p.session.cleanup = sync.OnceFunc(func() { close(entered); <-release; cleanup() }) + p.plan.Cleanup = p.session.cleanup + out := make(chan proto.Envelope, 16) + if _, err := p.Start(t.Context(), "run", "prompt", out); err != nil { + t.Fatal(err) + } + waitPreparationMethod(t, root, "turn/start") + finished := make(chan error, 1) + go func() { finished <- p.Cancel(context.Background()) }() + select { + case <-entered: + case <-time.After(4 * time.Second): + t.Fatal("transferred cancellation did not begin cleanup") + } + for range out { + } + select { + case <-finished: + t.Fatal("cancellation returned after output closure but before local cleanup") + case <-p.session.waitDone: + t.Fatal("Session finished before local cleanup") + default: + } + allowCleanup() + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("cancellation did not finish after local cleanup") + } + waitPreparedRelease(t, p, root) +} + +func TestPreparedCancelRacingTransfer(t *testing.T) { + for range 8 { + req, cfg, root := preparationFixture(t) + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + begin := make(chan struct{}) + var calls sync.WaitGroup + calls.Go(func() { + <-begin + _, _ = p.Start(t.Context(), "run", "prompt", make(chan proto.Envelope, 16)) + }) + calls.Go(func() { <-begin; _ = p.Cancel(context.Background()) }) + calls.Go(func() { <-begin; _ = p.Close() }) + close(begin) + calls.Wait() + if err := p.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + if p.started { + select { + case <-p.session.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("racing Session was retained") + } + } else { + assertPreparationOnly(t, root) + assertUnstartedCancellation(t, p) + } + waitPreparedRelease(t, p, root) + if s, err := p.Start(t.Context(), "again", "must not execute", make(chan proto.Envelope, 8)); err == nil || s != nil { + t.Fatal("cancelled preparation started again") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_test.go b/apps/parsar-daemon/internal/agent/codex/prepared_test.go new file mode 100644 index 000000000..3a21e368b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/prepared_test.go @@ -0,0 +1,240 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "resumed"}[resume], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + if resume { + req.AgentSessionID = "fixture-native-thread" + } + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + assertPreparationOnly(t, root) + if len(preparedCatalogs(t, root)) != 1 { + t.Fatal("preparation did not retain its model catalog") + } + pid := p.session.rpc.cmd.Process.Pid + // Caller-owned data cannot revise the prepared native configuration. + req.AgentOptions["model"] = "different-model" + req.AgentSessionID = "different-thread" + req.RemoteEnvironment.WorkspaceDirectory = "/different-executor" + copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) + out := make(chan proto.Envelope, 8) + startCtx, stopStart := context.WithCancel(t.Context()) + started, err := p.Start(startCtx, "actual-run", "actual prompt", out) + stopStart() + if err != nil { + t.Fatal(err) + } + session := started.(*Session) + defer session.Cancel(context.Background()) + if session.rpc != p.session.rpc || session.rpc.cmd.Process.Pid != pid { + t.Fatal("start replaced the prepared native resource") + } + if err := p.Close(); err != nil || !session.rpc.Alive() { + t.Fatal("close cancelled transferred resource", err) + } + if again, err := p.Start(t.Context(), "second", "second prompt", out); err == nil || again != nil { + t.Fatal("preparation started twice", err) + } + frames := waitPreparationMethod(t, root, "turn/start") + counts := map[string]int{} + for _, frame := range frames { + counts[frame.Method]++ + if frame.PID != pid { + t.Fatal("preparation and start used different children") + } + var params struct { + Model string `json:"model"` + ThreadID string `json:"threadId"` + DynamicTools []dynamicFunctionTool `json:"dynamicTools"` + Environments []EnvironmentSelection `json:"environments"` + } + if err := json.Unmarshal(frame.Params, ¶ms); err != nil { + t.Fatal(err) + } + if frame.Method == "thread/start" { + if params.Model != "fixture-model" || len(params.DynamicTools) != 1 || !strings.Contains(string(params.DynamicTools[0].InputSchema), "integer") { + t.Fatal("prepared configuration changed", string(frame.Params)) + } + } + if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { + t.Fatal("prepared resume changed") + } + if frame.Method == "turn/start" && (len(params.Environments) != 1 || params.Environments[0].Cwd != "/executor-only") { + t.Fatal("prepared environment changed") + } + } + expectedThread := "thread/start" + if resume { + expectedThread = "thread/resume" + } + if counts["initialize"] != 1 || counts["environment/info"] != 1 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { + t.Fatal("unexpected native setup/start count", counts) + } + if err := session.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-session.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("started session did not release") + } + waitPreparedRelease(t, p, root) + }) + } +} + +func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { + for _, reason := range []string{"close", "owner cancelled", "rpc exited", "executor disconnected", "start cancelled"} { + t.Run(reason, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + owner, cancelOwner := context.WithCancel(t.Context()) + defer cancelOwner() + p, err := newPreparation(owner, req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + startCtx := t.Context() + switch reason { + case "close": + if err := p.Close(); err != nil { + t.Fatal(err) + } + case "owner cancelled": + cancelOwner() + case "rpc exited": + if err := p.session.rpc.Close(); err != nil { + t.Fatal(err) + } + case "executor disconnected": + if err := os.WriteFile(filepath.Join(root, "remote-status"), []byte("disconnected"), 0o600); err != nil { + t.Fatal(err) + } + case "start cancelled": + var cancel context.CancelFunc + startCtx, cancel = context.WithCancel(t.Context()) + cancel() + } + if reason == "owner cancelled" || reason == "rpc exited" || reason == "close" { + // Release must happen without a later Start driving cleanup. + waitPreparedRelease(t, p, root) + } + out := make(chan proto.Envelope, 8) + if started, err := p.Start(startCtx, "late-run", "must not start", out); err == nil || started != nil { + t.Fatal("abandoned preparation started", err) + } + waitPreparedRelease(t, p, root) + assertPreparationOnly(t, root) + if len(out) != 0 { + t.Fatal("failed preparation emitted run output") + } + count := 0 + for _, frame := range preparationFrames(t, root) { + if frame.Method == "environment/info" { + count++ + } + } + if count != 1 { + t.Fatal("failed start reconnected the native environment", count) + } + }) + } +} + +func TestPreparedSessionConcurrentStartAndClose(t *testing.T) { + req, cfg, root := preparationFixture(t) + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + begin := make(chan struct{}) + var wg sync.WaitGroup + started := make(chan *Session, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-begin + s, err := p.start(t.Context(), "run", "prompt", make(chan proto.Envelope, 8)) + if err == nil { + started <- s + } + }() + } + wg.Add(1) + go func() { defer wg.Done(); <-begin; _ = p.Close() }() + close(begin) + wg.Wait() + close(started) + count := 0 + for session := range started { + count++ + _ = session.Cancel(context.Background()) + select { + case <-session.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("concurrent start retained a session") + } + } + if count > 1 { + t.Fatal("multiple ownership transfers", count) + } + waitPreparedRelease(t, p, root) + turns := 0 + for _, frame := range preparationFrames(t, root) { + if frame.Method == "turn/start" { + turns++ + } + } + if turns > 1 { + t.Fatal("multiple native Turns", turns) + } +} + +func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("PARSAR_PREPARATION_BLOCK", "1") + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + p, err := newPreparation(owner, req, cfg) + if p != nil { + _ = p.Close() + } + result <- err + }() + waitPreparationMethod(t, root, "environment/info") + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("cancelled readiness succeeded") + } + case <-time.After(4 * time.Second): + t.Fatal("cancelled readiness did not finish") + } + if len(preparedCatalogs(t, root)) != 0 { + t.Fatal("failed readiness leaked model catalog") + } + assertPreparationOnly(t, root) +} diff --git a/apps/parsar-daemon/internal/agent/codex/private_harness.go b/apps/parsar-daemon/internal/agent/codex/private_harness.go new file mode 100644 index 000000000..d07582089 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/private_harness.go @@ -0,0 +1,112 @@ +package codex + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// privateHarness owns only this child's local IPC directory. It neither grants +// Files authority nor settles remote operations when the child exits. +type privateHarness struct { + parent string + root string + environment string + readMu sync.Mutex + reading bool + uncertain bool +} + +func configurePrivateHarness(cfg *JSONRPCConfig, binary string, remote *proto.RemoteEnvironment) (*privateHarness, error) { + if binary == "" || remote == nil { + return nil, nil + } + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + return nil, errors.New("codex: private harness requires Linux amd64") + } + if !filepath.IsAbs(binary) || filepath.Clean(binary) != binary { + return nil, errors.New("codex: private harness requires a clean absolute binary path") + } + info, err := os.Stat(binary) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { + return nil, errors.New("codex: private harness executable unavailable") + } + helper, err := exec.LookPath(cfg.Binary) + if err != nil { + return nil, errors.New("codex: stock native helper unavailable") + } + helper, err = filepath.Abs(helper) + if err != nil { + return nil, err + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + if !filepath.IsAbs(home) { + return nil, errors.New("codex: private harness requires an absolute home directory") + } + // Native admission requires canonical ~/.parsar and trusted ancestors. Keep + // this path short independently of a potentially deep PARSAR_HOME profile. + base, err := filepath.EvalSymlinks(filepath.Join(home, ".parsar")) + if err != nil { + return nil, fmt.Errorf("codex: private harness state root: %w", err) + } + parent, err := os.MkdirTemp(base, "ch-") + if err != nil { + return nil, err + } + harness := &privateHarness{parent: parent, root: filepath.Join(parent, "native"), environment: remote.ID} + if len(filepath.Join(harness.root, "files.sock")) >= 104 { + harness.cleanup() + return nil, errors.New("codex: private harness socket path is too long") + } + cfg.Binary = binary + cfg.Env = append(cfg.Env, + "PARSAR_CODEX_HARNESS_NATIVE="+helper, + "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER="+os.Getenv("PARSAR_CODEX_DIRECTORY_HELPER"), + "PARSAR_CODEX_HARNESS_ENVIRONMENT="+remote.ID, + "PARSAR_CODEX_HARNESS_WORKSPACE="+remote.WorkspaceDirectory, + "PARSAR_CODEX_HARNESS_IPC_ROOT="+harness.root) + return harness, nil +} + +func (h *privateHarness) verify() error { + if h == nil { + return nil + } + info, err := os.Lstat(filepath.Join(h.root, "files.sock")) + if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 { + return errors.New("codex: private harness metadata endpoint unavailable") + } + return nil +} + +// Release only after the same RPC child has been reaped, including failed +// initialization and a Close deadline. A spawn failure owns no child. +func (h *privateHarness) releaseWith(rpc *JSONRPCClient) { + if h == nil { + return + } + if rpc.cmd == nil { + h.cleanup() + return + } + go func() { + <-rpc.Done() + h.cleanup() + }() +} + +func (h *privateHarness) cleanup() { + // Never recursively delete unexpected contents or another owner's directory. + _ = os.Remove(filepath.Join(h.root, "files.sock")) + _ = os.Remove(h.root) + _ = os.Remove(h.parent) +} diff --git a/apps/parsar-daemon/internal/agent/codex/private_harness_test.go b/apps/parsar-daemon/internal/agent/codex/private_harness_test.go new file mode 100644 index 000000000..bfb666813 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/private_harness_test.go @@ -0,0 +1,230 @@ +package codex + +import ( + "context" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func privateHarnessTestHome(t *testing.T) string { + t.Helper() + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("private Linux amd64 artifact") + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + base := filepath.Join(home, ".parsar") + if err = os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + home, err = os.MkdirTemp(base, "ht-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + t.Setenv("HOME", home) + base = filepath.Join(home, ".parsar") + if err = os.Mkdir(base, 0700); err != nil { + t.Fatal(err) + } + return base +} + +func privateHarnessEnv(env []string, name string) string { + value := "" + for _, entry := range env { + if strings.HasPrefix(entry, name+"=") { + value = strings.TrimPrefix(entry, name+"=") + } + } + return value +} + +func awaitPrivateHarnessCleanup(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Lstat(path); os.IsNotExist(err) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("private harness directory retained after child release") +} + +func TestPrivateHarnessBindingAndDefaultSelection(t *testing.T) { + base := privateHarnessTestHome(t) + cfg := JSONRPCConfig{Binary: "/missing-stock", Env: []string{"unchanged=value"}} + if h, err := configurePrivateHarness(&cfg, "relative-invalid", nil); h != nil || err != nil || cfg.Binary != "/missing-stock" || len(cfg.Env) != 1 { + t.Fatal("nonremote default changed") + } + remote := &proto.RemoteEnvironment{ID: "fixture", WorkspaceDirectory: "/remote"} + if h, err := configurePrivateHarness(&cfg, "", remote); h != nil || err != nil || len(cfg.Env) != 1 { + t.Fatal("stock remote default changed") + } + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"relative", filepath.Join(base, "missing"), base} { + if _, err := configurePrivateHarness(&cfg, path, remote); err == nil { + t.Fatal("invalid artifact admitted", path) + } + } + if _, err := configurePrivateHarness(&cfg, binary, remote); err == nil { + t.Fatal("missing helper admitted") + } + cfg.Binary = binary + t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/trusted/directory-helper") + cfg.Env = append(cfg.Env, "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER=/caller/override", "PARSAR_CODEX_HARNESS_ENVIRONMENT=wrong", "PARSAR_CODEX_HARNESS_IPC_ROOT=/wrong") + h, err := configurePrivateHarness(&cfg, binary, remote) + if err != nil { + t.Fatal(err) + } + defer h.cleanup() + if privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER") != "/trusted/directory-helper" { + t.Fatal("directory helper was not selected by operator") + } + if cfg.Binary != binary || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_NATIVE") != binary || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_ENVIRONMENT") != remote.ID || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_WORKSPACE") != remote.WorkspaceDirectory || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_IPC_ROOT") != h.root { + t.Fatal("private binding not derived from operator and request") + } + if filepath.Dir(h.parent) != base { + t.Fatal("private IPC escaped home") + } + if _, err := os.Lstat(h.root); !os.IsNotExist(err) { + t.Fatal("native socket root already exists") + } + if info, err := os.Stat(h.parent); err != nil || info.Mode().Perm() != 0700 { + t.Fatal("IPC parent is not private") + } + if h.verify() == nil { + t.Fatal("missing endpoint accepted") + } +} + +func TestPrivateHarnessPreparationTransferAndRelease(t *testing.T) { + for _, start := range []bool{false, true} { + t.Run(map[bool]string{false: "unused", true: "transferred"}[start], func(t *testing.T) { + privateHarnessTestHome(t) + req, cfg, root := preparationFixture(t) + cfg.harnessBinary = cfg.codexBinary + t.Setenv("PARSAR_PRIVATE_HARNESS_FAKE", "1") + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + p, err := newPreparation(owner, req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Cancel(context.Background()) + ipc := privateHarnessEnv(p.session.rpc.cfg.Env, "PARSAR_CODEX_HARNESS_IPC_ROOT") + if ipc == "" { + t.Fatal("adapter did not configure the artifact") + } + assertPreparationOnly(t, root) + if start { + out := make(chan proto.Envelope, 32) + if _, err = p.Start(t.Context(), "run", "hello", out); err != nil { + t.Fatal(err) + } + waitPreparationMethod(t, root, "turn/start") + if _, err = p.Start(t.Context(), "second", "hello", out); err == nil { + t.Fatal("second Start accepted") + } + if err = p.Close(); err != nil { + t.Fatal(err) + } + if _, err = os.Lstat(ipc); err != nil { + t.Fatal("transfer lost IPC before Session release") + } + } + if err = p.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + awaitPrivateHarnessCleanup(t, filepath.Dir(ipc)) + waitPreparedRelease(t, p, root) + }) + } +} + +func TestPrivateHarnessRejectsOrdinaryBinaryBeforeStart(t *testing.T) { + base := privateHarnessTestHome(t) + req, cfg, root := preparationFixture(t) + cfg.harnessBinary = cfg.codexBinary + t.Setenv("PARSAR_PRIVATE_HARNESS_FAKE", "") + if p, err := newPreparation(t.Context(), req, cfg); err == nil { + _ = p.Close() + t.Fatal("ordinary binary admitted as integrated artifact") + } + assertPreparationOnly(t, root) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("failed initialization retained private IPC allocation") +} + +func TestPrivateHarnessCleanupWaitsForRPCSettlement(t *testing.T) { + base := privateHarnessTestHome(t) + for _, spawned := range []bool{false, true} { + parent, err := os.MkdirTemp(base, "ch-") + if err != nil { + t.Fatal(err) + } + h := &privateHarness{parent: parent, root: filepath.Join(parent, "native")} + rpc := NewJSONRPCClient(JSONRPCConfig{}) + if spawned { + rpc.cmd = exec.Command("controlled-unreaped-owner") + } + h.releaseWith(rpc) + if spawned { + if _, err = os.Stat(parent); err != nil { + t.Fatal("allocation removed before child settlement") + } + close(rpc.doneCh) + } + awaitPrivateHarnessCleanup(t, parent) + } +} + +// Only the controlled native fixture uses this socket. Real artifact tests run +// the pinned executable and independently inspect actual remote metadata. +func fakePrivateHarnessEndpoint() { + if os.Getenv("PARSAR_PRIVATE_HARNESS_FAKE") != "1" { + return + } + root := os.Getenv("PARSAR_CODEX_HARNESS_IPC_ROOT") + if root == "" || os.Mkdir(root, 0700) != nil { + os.Exit(7) + } + path := filepath.Join(root, "files.sock") + listener, err := net.Listen("unix", path) + if err != nil || os.Chmod(path, 0600) != nil { + os.Exit(7) + } + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() +} diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/parsar-daemon/internal/agent/codex/protocol.go new file mode 100644 index 000000000..4dee13613 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/protocol.go @@ -0,0 +1,466 @@ +// Package codex is the agent_kind="codex" adapter. It drives the +// OpenAI Codex CLI (codex-rs / @openai/codex) via `codex app-server --stdio`, +// speaking the JSON-RPC 2.0 protocol that the app-server exposes over +// stdio. +// +// Codex differs from the claudecode and opencode adapters in two ways: +// +// 1. The wire protocol is JSON-RPC (request / response / notification / +// server-request) rather than NDJSON event-stream. See rpc.go. +// +// 2. Multi-turn context lives in a Codex "thread" identified by the +// thread_id returned in the first `thread/started` notification. +// The daemon stamps that id into DonePayload.Metadata["agent_session_id"] +// so the connector's RememberSession path persists it. +// Subsequent turns spawn a fresh app-server and call `thread/resume` +// with that id to graft the prior turn's context back in. +package codex + +import "encoding/json" + +import "fmt" + +// JsonRpcVersion is the JSON-RPC 2.0 marker carried on every outbound +// frame. Inbound frames omit the field per Codex's app-server convention, +// so the parser does not enforce it on responses. +const JsonRpcVersion = "2.0" + +// --------------------------------------------------------------------------- +// JSON-RPC envelopes (over stdio NDJSON) +// --------------------------------------------------------------------------- + +// JsonRpcRequest is an outbound RPC call (client → codex). id is a +// daemon-minted 16-hex string; an empty id marks a notification (no +// response expected). +type JsonRpcRequest struct { + JsonRpc string `json:"jsonrpc"` + ID string `json:"id,omitempty"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +// JsonRpcResponse is an outbound reply to a server-initiated request. +// Either Result or Error must be non-nil. +type JsonRpcResponse struct { + JsonRpc string `json:"jsonrpc"` + ID any `json:"id"` + Result any `json:"result,omitempty"` + Error *JsonRpcError `json:"error,omitempty"` +} + +// JsonRpcError carries a structured failure reply. Codes follow the +// JSON-RPC 2.0 spec (-32601 method-not-found, -32603 internal error, +// etc.). +type JsonRpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func (e *JsonRpcError) Error() string { return fmt.Sprintf("%d %s", e.Code, e.Message) } + +// --------------------------------------------------------------------------- +// initialize handshake +// --------------------------------------------------------------------------- + +// InitializeCapabilities mirrors codex-rs/app-server/src/protocol.rs. +// experimentalApi=true opts into the granular AskForApproval enum and +// the thread/* notification stream. +type InitializeCapabilities struct { + ExperimentalAPI bool `json:"experimentalApi"` + RequestAttestation bool `json:"requestAttestation"` + OptOutMethods *[]string `json:"optOutNotificationMethods,omitempty"` +} + +type InitializeClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type InitializeParams struct { + ClientInfo InitializeClientInfo `json:"clientInfo"` + Capabilities *InitializeCapabilities `json:"capabilities"` +} + +type InitializeResult struct { + UserAgent string `json:"userAgent"` + CodexHome string `json:"codexHome"` + PlatformFamily string `json:"platformFamily,omitempty"` + PlatformOs string `json:"platformOs,omitempty"` +} + +type SkillsExtraRootsSetParams struct { + ExtraRoots []string `json:"extraRoots"` +} + +// --------------------------------------------------------------------------- +// Approval / sandbox policies +// --------------------------------------------------------------------------- + +// GranularAskForApproval names each approval gate the codex agent can +// surface. All-false produces a fully silent run; turning any single +// field true makes the corresponding ServerRequest reach the daemon for +// human approval. +// +// JSON tags MUST stay snake_case — codex-rs deserialises this struct +// from a config TOML / RPC param with field names like sandbox_approval, +// and renaming silently breaks the wire. +type GranularAskForApproval struct { + SandboxApproval bool `json:"sandbox_approval"` + Rules bool `json:"rules"` + SkillApproval bool `json:"skill_approval"` + RequestPermissions bool `json:"request_permissions"` + MCPElicitations bool `json:"mcp_elicitations"` +} + +// AskForApproval is the discriminated union codex accepts on +// ThreadStartParams.approvalPolicy. The serialiser must emit EITHER +// {"granular": {...}} OR a bare string ("never" / "on-request" / +// "on-failure" / "untrusted"). See MarshalJSON in approval_policy.go. +type AskForApproval struct { + String string // "" when granular is set + Granular *GranularAskForApproval // nil when string is set +} + +// SandboxMode is the wire-level sandbox setting codex's v2 thread/start +// API accepts. Serializes to a kebab-case string per +// codex-rs/app-server-protocol/src/protocol/v2/shared.rs::SandboxMode. +// +// Earlier (~0.137-era) the field on ThreadStartParams was named +// `sandboxPolicy` and took a tagged object `{type: "dangerFullAccess"}`. +// 0.141.0 renamed it to `sandbox` and flattened it to one of these three +// strings. Sending the old shape no longer errors loudly — codex just +// silently falls back to read-only, which makes prompts terminate +// immediately with no agent output. Keep the constants pinned exactly +// to the kebab values upstream serializes. +type SandboxMode string + +const ( + SandboxReadOnly SandboxMode = "read-only" + SandboxWorkspaceWrite SandboxMode = "workspace-write" + SandboxDangerFullAcces SandboxMode = "danger-full-access" +) + +// SandboxPolicy is the legacy compound type kept for internal +// representation only — codex still echoes it back on some response +// shapes (turn_context inside rollout files, for example). It is NOT +// what ThreadStartParams.Sandbox takes on the wire. +type SandboxPolicy struct { + Type SandboxMode `json:"type"` + WritableRoots []string `json:"writableRoots,omitempty"` + NetworkAccess bool `json:"networkAccess,omitempty"` + ExcludeTmpdirEnvVar bool `json:"excludeTmpdirEnvVar,omitempty"` + ExcludeSlashTmp bool `json:"excludeSlashTmp,omitempty"` +} + +// --------------------------------------------------------------------------- +// thread/start, thread/resume, thread/list +// --------------------------------------------------------------------------- + +type ThreadStartParams struct { + Environments []EnvironmentSelection `json:"environments,omitempty"` + Cwd string `json:"cwd"` + Model string `json:"model,omitempty"` + ModelProvider string `json:"modelProvider,omitempty"` + ApprovalPolicy AskForApproval `json:"approvalPolicy"` + // Sandbox is the v0.141+ field name; previously called sandboxPolicy + // and took a tagged-enum object. Wire format now is a kebab-case + // string: "read-only" / "workspace-write" / "danger-full-access". + // Sending the old object shape causes codex to silently default to + // read-only, which terminates the turn before the model can reply. + Sandbox SandboxMode `json:"sandbox,omitempty"` + Permissions string `json:"permissions,omitempty"` + DeveloperInstructions string `json:"developerInstructions,omitempty"` + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` + DynamicTools []dynamicFunctionTool `json:"dynamicTools,omitempty"` +} + +type Thread struct { + ID string `json:"id"` + Cwd string `json:"cwd,omitempty"` + Preview string `json:"preview,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + Status any `json:"status,omitempty"` + Path string `json:"path,omitempty"` + CLIVersion string `json:"cliVersion,omitempty"` +} + +type ThreadStartResult struct { + Thread Thread `json:"thread"` + Model string `json:"model,omitempty"` + ApprovalPolicy *AskForApproval `json:"approvalPolicy,omitempty"` + Sandbox *SandboxPolicy `json:"sandbox,omitempty"` +} + +type ThreadResumeParams struct { + Cwd string `json:"cwd,omitempty"` + DeveloperInstructions string `json:"developerInstructions"` + ThreadID string `json:"threadId"` + ApprovalPolicy AskForApproval `json:"approvalPolicy"` + Sandbox SandboxMode `json:"sandbox,omitempty"` + Permissions string `json:"permissions,omitempty"` +} + +// --------------------------------------------------------------------------- +// turn/start, turn/interrupt +// --------------------------------------------------------------------------- + +// UserInputType discriminates a UserInput element. Codex accepts mixed +// arrays of text + image entries on a single turn. +type UserInputType string + +const ( + UserInputText UserInputType = "text" + UserInputLocalImage UserInputType = "localImage" + UserInputRemoteImg UserInputType = "image" +) + +// UserInput is one element of TurnStartParams.Input. Each variant uses a +// distinct field set; producers must populate only the fields that +// match Type. +type UserInput struct { + Type UserInputType `json:"type"` + // Text variant + Text string `json:"text,omitempty"` + TextElements []any `json:"text_elements,omitempty"` + // LocalImage variant + Path string `json:"path,omitempty"` + // Image (remote URL) variant + URL string `json:"url,omitempty"` +} + +type TurnStartParams struct { + Environments []EnvironmentSelection `json:"environments,omitempty"` + ThreadID string `json:"threadId"` + Input []UserInput `json:"input"` + CollaborationMode *CollaborationMode `json:"collaborationMode,omitempty"` +} + +type CollaborationModeKind string + +const ( + CollaborationModePlan CollaborationModeKind = "plan" + CollaborationModeDefault CollaborationModeKind = "default" +) + +type CollaborationMode struct { + Mode CollaborationModeKind `json:"mode"` + Settings CollaborationModeSettings `json:"settings"` +} + +type CollaborationModeSettings struct { + Model string `json:"model"` + DeveloperInstructions *string `json:"developer_instructions"` +} + +type TurnInterruptParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type TurnUsage struct { + observed bool + complete bool + ReasoningOutputTokens int `json:"reasoningOutputTokens,omitempty"` + InputTokens int `json:"inputTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` + CachedInputTokens int `json:"cachedInputTokens,omitempty"` + CacheReadInputTokens int `json:"cacheReadInputTokens,omitempty"` + TotalTokens int `json:"totalTokens,omitempty"` +} + +type Turn struct { + ID string `json:"id"` + Usage *TurnUsage `json:"usage,omitempty"` + Status string `json:"status,omitempty"` + // Failed Turns carry the native provider error here. + Error *TurnError `json:"error,omitempty"` +} + +// CodexErrorInfo is a native enum with string and object variants. +type TurnError struct { + Message string `json:"message,omitempty"` + CodexErrorInfo json.RawMessage `json:"codexErrorInfo,omitempty"` + AdditionalDetails *string `json:"additionalDetails,omitempty"` +} + +// --------------------------------------------------------------------------- +// ThreadItem (the variants we map; the rest fall through to a generic +// catch-all so codex upgrades that add new item kinds do not break +// parsing). +// --------------------------------------------------------------------------- + +// ThreadItem is decoded loosely: parser keeps the raw JSON around so +// future fields can be inspected via a second unmarshal without round- +// tripping every variant through a hand-written struct. +type ThreadItem struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + + // agentMessage / reasoning + Phase string `json:"phase,omitempty"` + Text string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + Content []string `json:"content,omitempty"` + SummaryText string `json:"summary_text,omitempty"` + + // commandExecution + Command string `json:"command,omitempty"` + Cwd string `json:"cwd,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + Status string `json:"status,omitempty"` + + // fileChange + Changes []map[string]any `json:"changes,omitempty"` + + // mcpToolCall + Server string `json:"server,omitempty"` + Tool string `json:"tool,omitempty"` + Arguments any `json:"arguments,omitempty"` + + // dynamicToolCall + Namespace string `json:"namespace,omitempty"` + + // webSearch + Query string `json:"query,omitempty"` +} + +// --------------------------------------------------------------------------- +// Notification params we subscribe to (param shapes only — method names +// live as constants in session.go's handler registration). +// --------------------------------------------------------------------------- + +type ThreadStartedNotification struct { + Thread Thread `json:"thread"` +} + +type TurnStartedNotification struct { + ThreadID string `json:"threadId"` + Turn Turn `json:"turn"` +} + +type TurnCompletedNotification struct { + ThreadID string `json:"threadId"` + Turn Turn `json:"turn"` +} + +type ItemStartedNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item ThreadItem `json:"item"` + StartedAtMs int64 `json:"startedAtMs,omitempty"` +} + +type ItemCompletedNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item ThreadItem `json:"item"` + CompletedAtMs int64 `json:"completedAtMs,omitempty"` +} + +type AgentMessageDeltaNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + Delta string `json:"delta"` +} + +type ReasoningDeltaNotification = AgentMessageDeltaNotification + +type ThreadTokenUsageUpdatedNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Usage *TurnUsage `json:"usage,omitempty"` // Legacy turn-level payload. + TokenUsage *struct { + Total *TurnUsage `json:"total"` + } `json:"tokenUsage,omitempty"` +} + +type ErrorNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Error *TurnError `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +// --------------------------------------------------------------------------- +// Approval and user-input ServerRequest params. Explicit requests defer their +// responses until Web or IM submits the decision, even with bypass defaults. +// --------------------------------------------------------------------------- + +type CommandExecutionRequestApprovalParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Reason *string `json:"reason,omitempty"` +} + +type FileChangeRequestApprovalParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + Reason *string `json:"reason,omitempty"` + GrantRoot *string `json:"grantRoot,omitempty"` +} + +type PermissionsRequestApprovalParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + Cwd string `json:"cwd"` + Reason *string `json:"reason,omitempty"` + Permissions map[string]any `json:"permissions,omitempty"` +} + +// CommandExecutionApprovalDecision is the verdict the daemon writes +// back to a Codex approval ServerRequest. "accept" / "decline" / "cancel" +// / "acceptForSession". +type CommandExecutionApprovalDecision = string + +// ApprovalDecisionResult is the result body for command-execution and +// file-change approvals. item/permissions/requestApproval uses the distinct +// PermissionsRequestApprovalResponse contract below. +type ApprovalDecisionResult struct { + Decision CommandExecutionApprovalDecision `json:"decision"` +} + +// PermissionsRequestApprovalResponse mirrors Codex app-server's dedicated +// response contract. Approving echoes the requested permission profile; +// denying returns an empty profile, which grants no additional capability. +type PermissionsRequestApprovalResponse struct { + Permissions map[string]any `json:"permissions"` + Scope string `json:"scope,omitempty"` +} + +type ToolRequestUserInputOption struct { + Label string `json:"label"` + Description string `json:"description"` +} + +type ToolRequestUserInputQuestion struct { + ID string `json:"id"` + Header string `json:"header"` + Question string `json:"question"` + Options []ToolRequestUserInputOption `json:"options,omitempty"` + IsOther bool `json:"isOther,omitempty"` + IsSecret bool `json:"isSecret,omitempty"` +} + +type ToolRequestUserInputParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + Questions []ToolRequestUserInputQuestion `json:"questions"` + AutoResolutionMs *uint64 `json:"autoResolutionMs,omitempty"` +} + +type ToolRequestUserInputAnswer struct { + Answers []string `json:"answers"` +} + +type ToolRequestUserInputResponse struct { + Answers map[string]ToolRequestUserInputAnswer `json:"answers"` +} diff --git a/apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go b/apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go new file mode 100644 index 000000000..d42b070bc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go @@ -0,0 +1,133 @@ +package codex + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestThreadStartParams_SandboxIsKebabString pins the v0.141+ wire +// format reviewer caught the hard way: codex's ThreadStartParams.sandbox +// is a kebab-case string ("read-only" / "workspace-write" / +// "danger-full-access"), NOT the legacy {"type":"dangerFullAccess"} +// tagged object on a sandboxPolicy field. +// +// Sending the legacy shape used to terminate every turn in <1s with an +// empty agent message body, because codex silently fell back to +// read-only when it didn't recognise the field name. +func TestThreadStartParams_SandboxIsKebabString(t *testing.T) { + cases := []struct { + name string + mode SandboxMode + want string + }{ + {"read-only", SandboxReadOnly, "read-only"}, + {"workspace-write", SandboxWorkspaceWrite, "workspace-write"}, + {"danger-full-access", SandboxDangerFullAcces, "danger-full-access"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + params := ThreadStartParams{ + Cwd: "/workspace", + Model: "gpt-5.5", + ApprovalPolicy: SilentGranularPolicy(), + Sandbox: tc.mode, + } + raw, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal: %v", err) + } + body := string(raw) + if !strings.Contains(body, `"sandbox":"`+tc.want+`"`) { + t.Fatalf("wire body missing sandbox=%q: %s", tc.want, body) + } + // The legacy field name MUST be gone. Even shipping it as + // "sandboxPolicy" alongside the new field would confuse + // future-version codex servers that strict-deserialise. + if strings.Contains(body, `"sandboxPolicy"`) { + t.Fatalf("legacy sandboxPolicy field leaked into wire: %s", body) + } + // And the kebab-case value must NOT collide with the old + // camelCase enum names. + for _, oldName := range []string{`"readOnly"`, `"workspaceWrite"`, `"dangerFullAccess"`} { + if strings.Contains(body, oldName) { + t.Fatalf("legacy camelCase enum value %s leaked: %s", oldName, body) + } + } + }) + } +} + +// TestThreadStartParams_OmitsEmptyOptionalFields pins that model_provider +// and other optional fields don't leak null/empty values into the wire. +// codex would reject malformed enum values otherwise. +func TestThreadStartParams_OmitsEmptyOptionalFields(t *testing.T) { + params := ThreadStartParams{ + Cwd: "/workspace", + ApprovalPolicy: SilentGranularPolicy(), + Sandbox: SandboxDangerFullAcces, + // Model, ModelProvider, DeveloperInstructions deliberately empty + } + raw, _ := json.Marshal(params) + body := string(raw) + for _, leak := range []string{ + `"model":""`, + `"modelProvider":""`, + `"developerInstructions":""`, + } { + if strings.Contains(body, leak) { + t.Errorf("empty optional field leaked: %s in %s", leak, body) + } + } +} + +// TestThreadStartParams_ModelProviderIsCamelCaseField confirms the +// model_provider override (used by injectCodexManagedModel to pin codex +// to the [model_providers.parsar] config block) actually reaches +// codex via the v2 thread/start params, not just via the -c CLI +// override. Sending it on both paths is belt + suspenders. +func TestThreadStartParams_ModelProviderIsCamelCaseField(t *testing.T) { + params := ThreadStartParams{ + Cwd: "/workspace", + Model: "gpt-5.5", + ModelProvider: "parsar", + ApprovalPolicy: SilentGranularPolicy(), + Sandbox: SandboxDangerFullAcces, + } + raw, _ := json.Marshal(params) + body := string(raw) + if !strings.Contains(body, `"modelProvider":"parsar"`) { + t.Fatalf("modelProvider missing or wrong case in wire: %s", body) + } + // snake_case would silently be ignored by codex's serde rename_all + // = camelCase, so guard against accidental drift. + if strings.Contains(body, `"model_provider"`) { + t.Fatalf("snake_case model_provider leaked: %s", body) + } +} + +func TestTurnStartParams_CollaborationModeUsesPlanWireShape(t *testing.T) { + developerInstructions := "stay within the configured workspace" + params := TurnStartParams{ + ThreadID: "thread-1", + Input: FirstUserInput("ask me a question"), + CollaborationMode: &CollaborationMode{ + Mode: CollaborationModePlan, + Settings: CollaborationModeSettings{ + Model: "MiniMax-M3", + DeveloperInstructions: &developerInstructions, + }, + }, + } + raw, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal: %v", err) + } + body := string(raw) + if !strings.Contains(body, `"collaborationMode":{"mode":"plan","settings":{"model":"MiniMax-M3","developer_instructions":"stay within the configured workspace"}}`) { + t.Fatalf("collaboration mode missing or malformed: %s", body) + } + if strings.Contains(body, `"collaboration_mode"`) { + t.Fatalf("snake_case collaboration_mode leaked: %s", body) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/provider_config.go b/apps/parsar-daemon/internal/agent/codex/provider_config.go new file mode 100644 index 000000000..238841446 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/provider_config.go @@ -0,0 +1,173 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// parsarProviderSlug is the model_provider key the daemon always writes +// into CODEX_HOME/config.toml. The session's thread/start request pins +// model_provider to this slug, so codex skips its builtin "openai" +// provider entirely and routes through the values we materialise here. +// Hardcoded (not configurable) on purpose — keeping it a constant makes +// the config.toml deterministic and removes a footgun where two prompts +// in the same CODEX_HOME could disagree on which provider to use. +const parsarProviderSlug = "parsar" + +// providerConfig is the daemon-internal view of the model provider the +// server-side injector resolved for this prompt. Flattened from +// agent_options["codex_provider"] (a string-keyed map) into a typed +// struct before TOML emission. Field names mirror the codex-rs +// ModelProviderInfo enum (model-provider-info/src/lib.rs) so the +// rendered TOML deserialises 1:1 against upstream. +type providerConfig struct { + // Name is the human-readable label shown in codex UI; defaults to + // "Parsar" when empty. + Name string + // BaseURL is the model provider's HTTPS endpoint, including any + // path prefix (e.g. /v1). Required. + BaseURL string + // BearerToken is the literal API key. Codex's `experimental_bearer_token` + // field accepts a string; we emit it verbatim. Required. + BearerToken string + // HTTPHeaders is forwarded as `[model_providers.parsar.http_headers]`. + // Keys / values rendered as TOML basic strings. + HTTPHeaders map[string]string + // QueryParams is forwarded as `[model_providers.parsar.query_params]`, + // used by Azure (api-version=2025-04-01-preview). + QueryParams map[string]string + // WireAPI is "responses" (codex Responses API) or "chat" (rejected + // upstream since codex-rs 0.140). Empty defaults to "responses". + WireAPI string + // RequestMaxRetries / StreamMaxRetries map to codex's + // request_max_retries / stream_max_retries config keys. 0 omits. + RequestMaxRetries int + StreamMaxRetries int +} + +// writeCodexProviderConfig writes (or appends to) /config.toml +// with a `[model_providers.]` block built from cfg. +// +// The file is appended to — writeCodexMCPConfig also writes config.toml +// for MCP servers, and both writers run on the same prompt. Order is +// not load-bearing on codex's side (TOML is a flat key-set), but we keep +// the provider block at the top so a human inspecting the scratch dir +// sees the auth + endpoint first. +// +// The provider block is rewritten on every prompt; manual edits to +// scratch CODEX_HOME files are lost on the next spawn. +func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { + if err := os.MkdirAll(codexHome, 0o700); err != nil { + return fmt.Errorf("codex: mkdir CODEX_HOME %s: %w", codexHome, err) + } + if strings.TrimSpace(cfg.BaseURL) == "" { + return fmt.Errorf("codex: provider base_url is required") + } + if strings.TrimSpace(cfg.BearerToken) == "" { + return fmt.Errorf("codex: provider bearer_token is required") + } + + var b strings.Builder + b.WriteString("# Generated by parsar-daemon (codex agent) — do not edit by hand.\n") + b.WriteString("# Rewritten on every prompt; manual changes will be lost.\n\n") + + b.WriteString("[model_providers.") + b.WriteString(parsarProviderSlug) + b.WriteString("]\n") + + name := strings.TrimSpace(cfg.Name) + if name == "" { + name = "Parsar" + } + b.WriteString("name = ") + b.WriteString(tomlQuoteString(name)) + b.WriteByte('\n') + + b.WriteString("base_url = ") + b.WriteString(tomlQuoteString(cfg.BaseURL)) + b.WriteByte('\n') + + b.WriteString("experimental_bearer_token = ") + b.WriteString(tomlQuoteString(cfg.BearerToken)) + b.WriteByte('\n') + + wireAPI := strings.TrimSpace(cfg.WireAPI) + if wireAPI == "" { + // Codex-rs has removed the "chat" variant; "responses" is the + // only accepted value. Default explicitly so a future enum + // addition can't silently flip the wire shape under us. + wireAPI = "responses" + } + b.WriteString("wire_api = ") + b.WriteString(tomlQuoteString(wireAPI)) + b.WriteByte('\n') + + if cfg.RequestMaxRetries > 0 { + fmt.Fprintf(&b, "request_max_retries = %d\n", cfg.RequestMaxRetries) + } + if cfg.StreamMaxRetries > 0 { + fmt.Fprintf(&b, "stream_max_retries = %d\n", cfg.StreamMaxRetries) + } + + if len(cfg.HTTPHeaders) > 0 { + keys := make([]string, 0, len(cfg.HTTPHeaders)) + for k := range cfg.HTTPHeaders { + keys = append(keys, k) + } + sort.Strings(keys) + b.WriteString("\n[model_providers.") + b.WriteString(parsarProviderSlug) + b.WriteString(".http_headers]\n") + for _, k := range keys { + b.WriteString(tomlQuoteString(k)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(cfg.HTTPHeaders[k])) + b.WriteByte('\n') + } + } + + if len(cfg.QueryParams) > 0 { + keys := make([]string, 0, len(cfg.QueryParams)) + for k := range cfg.QueryParams { + keys = append(keys, k) + } + sort.Strings(keys) + b.WriteString("\n[model_providers.") + b.WriteString(parsarProviderSlug) + b.WriteString(".query_params]\n") + for _, k := range keys { + b.WriteString(tomlQuoteString(k)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(cfg.QueryParams[k])) + b.WriteByte('\n') + } + } + + b.WriteByte('\n') + + path := filepath.Join(codexHome, "config.toml") + return appendConfigTOML(path, b.String()) +} + +// appendConfigTOML appends body to /config.toml, creating it +// when missing. Used so writeCodexProviderConfig and writeCodexMCPConfig +// (which target the same file) can cohabit without one truncating the +// other. +// +// File is opened O_APPEND so concurrent writers in the same prompt +// (today: at most one of each) don't race. 0o600 perms because the +// file carries the API bearer token in plaintext. +func appendConfigTOML(path string, body string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("codex: open %s: %w", path, err) + } + defer f.Close() + if _, err := f.WriteString(body); err != nil { + return fmt.Errorf("codex: append %s: %w", path, err) + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/provider_config_test.go b/apps/parsar-daemon/internal/agent/codex/provider_config_test.go new file mode 100644 index 000000000..1ec727a81 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/provider_config_test.go @@ -0,0 +1,241 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteCodexProviderConfig_Minimal(t *testing.T) { + dir := t.TempDir() + cfg := providerConfig{ + BaseURL: "https://platform-api.example.com/v1", + BearerToken: "sk-test", + } + if err := writeCodexProviderConfig(dir, cfg); err != nil { + t.Fatalf("write: %v", err) + } + body := mustReadFile(t, filepath.Join(dir, "config.toml")) + for _, want := range []string{ + `[model_providers.parsar]`, + `name = "Parsar"`, + `base_url = "https://platform-api.example.com/v1"`, + `experimental_bearer_token = "sk-test"`, + `wire_api = "responses"`, + } { + if !strings.Contains(body, want) { + t.Errorf("config.toml missing %q\n---\n%s", want, body) + } + } +} + +// TestWriteCodexProviderConfig_PinsResponsesWire confirms wire_api stays +// "responses" even when the caller forgot to set it. codex-rs removed +// the "chat" variant; emitting empty would fall back to upstream's +// default which today is "responses" but could drift. +func TestWriteCodexProviderConfig_PinsResponsesWire(t *testing.T) { + dir := t.TempDir() + cfg := providerConfig{ + BaseURL: "https://x/v1", + BearerToken: "sk-x", + // WireAPI deliberately empty + } + if err := writeCodexProviderConfig(dir, cfg); err != nil { + t.Fatalf("write: %v", err) + } + body := mustReadFile(t, filepath.Join(dir, "config.toml")) + if !strings.Contains(body, `wire_api = "responses"`) { + t.Fatalf("wire_api default not pinned: %s", body) + } +} + +func TestWriteCodexProviderConfig_FullProvider(t *testing.T) { + dir := t.TempDir() + cfg := providerConfig{ + Name: "mygw", + BaseURL: "https://platform-api.example.com/v1", + BearerToken: "sk-test-fixture", + WireAPI: "responses", + HTTPHeaders: map[string]string{ + "X-Sub-Module": "codex-internal", + "X-Request-ID": "abc", + }, + QueryParams: map[string]string{ + "api-version": "2025-04-01-preview", + }, + RequestMaxRetries: 4, + StreamMaxRetries: 3, + } + if err := writeCodexProviderConfig(dir, cfg); err != nil { + t.Fatalf("write: %v", err) + } + body := mustReadFile(t, filepath.Join(dir, "config.toml")) + for _, want := range []string{ + `name = "mygw"`, + `base_url = "https://platform-api.example.com/v1"`, + `experimental_bearer_token = "sk-test-fixture"`, + `request_max_retries = 4`, + `stream_max_retries = 3`, + `[model_providers.parsar.http_headers]`, + `"X-Sub-Module" = "codex-internal"`, + `"X-Request-ID" = "abc"`, + `[model_providers.parsar.query_params]`, + `"api-version" = "2025-04-01-preview"`, + } { + if !strings.Contains(body, want) { + t.Errorf("config.toml missing %q\n---\n%s", want, body) + } + } +} + +func TestWriteCodexProviderConfig_RejectsMissingFields(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + cfg providerConfig + }{ + {"missing base_url", providerConfig{BearerToken: "sk-x"}}, + {"missing bearer_token", providerConfig{BaseURL: "https://x"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := writeCodexProviderConfig(dir, tc.cfg); err == nil { + t.Fatal("expected error for incomplete provider config") + } + }) + } +} + +// TestWriteCodexProviderConfig_AppendsAlongsideMCP verifies the two +// writers coexist on the same file. Without append semantics one would +// silently overwrite the other depending on call order. +func TestWriteCodexProviderConfig_AppendsAlongsideMCP(t *testing.T) { + dir := t.TempDir() + mcpCfg := map[string]mcpServerConfig{ + "docs": {Name: "docs", Command: "docs-server"}, + } + if err := writeCodexMCPConfig(dir, mcpCfg); err != nil { + t.Fatalf("mcp write: %v", err) + } + if err := writeCodexProviderConfig(dir, providerConfig{ + BaseURL: "https://x", BearerToken: "sk-x", + }); err != nil { + t.Fatalf("provider write: %v", err) + } + body := mustReadFile(t, filepath.Join(dir, "config.toml")) + if !strings.Contains(body, `[mcp_servers."docs"]`) { + t.Errorf("mcp_servers block lost after provider write:\n%s", body) + } + if !strings.Contains(body, `[model_providers.parsar]`) { + t.Errorf("model_providers block missing:\n%s", body) + } +} + +func TestNormaliseProviderConfig_FullRoundTrip(t *testing.T) { + raw := map[string]any{ + "name": "mygw", + "base_url": "https://x/v1", + "bearer_token": "sk-x", + "wire_api": "responses", + "http_headers": map[string]any{ + "X-Sub-Module": "codex-internal", + }, + "query_params": map[string]any{ + "api-version": "2025-04-01-preview", + }, + "request_max_retries": float64(4), // JSON numbers arrive as float64 + } + cfg, hasProvider, err := normaliseProviderConfig(raw) + if err != nil { + t.Fatalf("normalise: %v", err) + } + if !hasProvider { + t.Fatal("hasProvider must be true for non-nil raw") + } + if cfg.Name != "mygw" || cfg.BaseURL != "https://x/v1" || cfg.BearerToken != "sk-x" { + t.Fatalf("scalar fields wrong: %+v", cfg) + } + if cfg.HTTPHeaders["X-Sub-Module"] != "codex-internal" { + t.Fatalf("headers lost: %+v", cfg.HTTPHeaders) + } + if cfg.QueryParams["api-version"] != "2025-04-01-preview" { + t.Fatalf("query_params lost: %+v", cfg.QueryParams) + } + if cfg.RequestMaxRetries != 4 { + t.Fatalf("request_max_retries = %d, want 4", cfg.RequestMaxRetries) + } +} + +func TestNormaliseProviderConfig_Nil(t *testing.T) { + cfg, hasProvider, err := normaliseProviderConfig(nil) + if err != nil { + t.Fatalf("normalise nil: %v", err) + } + if hasProvider { + t.Fatal("hasProvider must be false for nil") + } + _ = cfg +} + +func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { + plan, err := BuildSessionPlan("run-x", "conv-1/agent-1/codex", "", map[string]any{ + "codex_provider": map[string]any{ + "base_url": "https://x/v1", + "bearer_token": "sk-x", + }, + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.ModelProvider != "parsar" { + t.Fatalf("plan.ModelProvider = %q, want parsar", plan.ModelProvider) + } + found := false + for _, kv := range plan.ExtraConfig { + if kv[0] == "model_provider" && kv[1] == `"parsar"` { + found = true + break + } + } + if !found { + t.Fatalf("ExtraConfig missing model_provider override: %+v", plan.ExtraConfig) + } + // And the config.toml was actually written. + codexHome := "" + for _, kv := range plan.Env { + if strings.HasPrefix(kv, "CODEX_HOME=") { + codexHome = strings.TrimPrefix(kv, "CODEX_HOME=") + } + } + body := mustReadFile(t, filepath.Join(codexHome, "config.toml")) + if !strings.Contains(body, `[model_providers.parsar]`) { + t.Fatalf("config.toml missing provider block:\n%s", body) + } +} + +func TestBuildSessionPlan_NoProviderLeavesBuiltinDefault(t *testing.T) { + plan, err := BuildSessionPlan("run-y", "conv-1/agent-1/codex", "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.ModelProvider != "" { + t.Fatalf("plan.ModelProvider = %q, want empty when no provider configured", plan.ModelProvider) + } + for _, kv := range plan.ExtraConfig { + if kv[0] == "model_provider" { + t.Fatalf("model_provider override leaked into ExtraConfig: %+v", plan.ExtraConfig) + } + } +} + +func mustReadFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} diff --git a/apps/parsar-daemon/internal/agent/codex/recovery.go b/apps/parsar-daemon/internal/agent/codex/recovery.go new file mode 100644 index 000000000..49ef3d301 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/recovery.go @@ -0,0 +1,71 @@ +package codex + +import ( + "bytes" + "encoding/json" + "errors" + "path/filepath" + "strings" +) + +func SupportsNativeSessionRecovery(version string) bool { + return strings.TrimSpace(version) == "codex-cli 0.153.4" +} + +func (s *Session) recoverRoot(plan SessionPlan) (string, error) { + var home string + for _, value := range plan.Env { + if strings.HasPrefix(value, "CODEX_HOME=") { + home = strings.TrimPrefix(value, "CODEX_HOME=") + } + } + if !filepath.IsAbs(home) || !filepath.IsAbs(plan.Cwd) { + return "", errors.New("codex: recovery requires private native history") + } + var root string + for _, archived := range []bool{false, true} { + params := struct { + Limit int `json:"limit"` + ModelProviders []string `json:"modelProviders"` + SourceKinds []string `json:"sourceKinds"` + Archived bool `json:"archived"` + UseStateDBOnly bool `json:"useStateDbOnly"` + }{2, []string{}, []string{}, archived, false} + raw, err := s.rpc.Request(s.cancelCtx, "thread/list", params) + if err != nil { + return "", errors.New("codex: native history lookup unavailable") + } + var page struct { + Data []struct { + ID string `json:"id"` + Parent json.RawMessage `json:"parentThreadId"` + Fork json.RawMessage `json:"forkedFromId"` + Ephemeral *bool `json:"ephemeral"` + Source string `json:"source"` + Cwd string `json:"cwd"` + Path string `json:"path"` + } `json:"data"` + NextCursor json.RawMessage `json:"nextCursor"` + } + if json.Unmarshal(raw, &page) != nil || page.Data == nil || !bytes.Equal(bytes.TrimSpace(page.NextCursor), []byte("null")) { + return "", errors.New("codex: native history lookup incomplete") + } + if len(page.Data) > 1 || (archived && len(page.Data) != 0) { + return "", errors.New("codex: native history is ambiguous or archived") + } + for _, row := range page.Data { + rel, err := filepath.Rel(filepath.Join(home, "sessions"), row.Path) + if strings.TrimSpace(row.ID) == "" || row.Ephemeral == nil || *row.Ephemeral || + !bytes.Equal(bytes.TrimSpace(row.Parent), []byte("null")) || !bytes.Equal(bytes.TrimSpace(row.Fork), []byte("null")) || + (row.Source != "vscode" && row.Source != "cli" && row.Source != "appServer") || + row.Cwd != plan.Cwd || !filepath.IsAbs(row.Path) || err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("codex: native history ownership is unverified") + } + root = row.ID + } + } + if root == "" { + return "", errors.New("codex: required native history is missing") + } + return root, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/recovery_test.go b/apps/parsar-daemon/internal/agent/codex/recovery_test.go new file mode 100644 index 000000000..98dfdaff7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/recovery_test.go @@ -0,0 +1,197 @@ +package codex + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +func TestNativeSessionRecoveryRequiresPinnedNative(t *testing.T) { + for _, version := range []string{"", "codex-cli 0.153.3", "codex-cli 0.153.4", "codex-cli 0.154.0"} { + if SupportsNativeSessionRecovery(version) != (version == "codex-cli 0.153.4") { + t.Fatalf("unexpected recovery capability for %q", version) + } + } +} + +func TestRequiredHistoryResolution(t *testing.T) { + for _, scenario := range []string{"recover", "fresh", "known", "missing", "ambiguous", "paged", "omitted-cursor", "missing-parent", "child", "fork", "ephemeral", "wrong-cwd", "wrong-home", "archived", "lookup-error", "resume-error", "malformed"} { + t.Run(scenario, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + home := t.TempDir() + plan := SessionPlan{Cwd: "/workspace", Env: []string{"CODEX_HOME=" + home}} + row := map[string]any{"id": "original", "parentThreadId": nil, "forkedFromId": nil, "ephemeral": false, "source": "vscode", "cwd": plan.Cwd, "path": filepath.Join(home, "sessions", "rollout.jsonl")} + switch scenario { + case "missing-parent": + delete(row, "parentThreadId") + case "child": + row["parentThreadId"] = "parent" + case "fork": + row["forkedFromId"] = "old" + case "ephemeral": + row["ephemeral"] = true + case "wrong-cwd": + row["cwd"] = "/another" + case "wrong-home": + row["path"] = "/another/sessions/rollout.jsonl" + } + req := proto.PromptRequestPayload{StrictResume: true, RequireExistingNativeSession: true} + if scenario == "fresh" { + req.RequireExistingNativeSession = false + } + if scenario == "known" { + req.AgentSessionID = "original" + } + session := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "recovery-test")}} + methods := make(chan []string, 1) + go func() { + decoder, encoder := json.NewDecoder(server.FromClient), json.NewEncoder(server.ToClient) + var seen []string + defer func() { methods <- seen }() + for { + var frame struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if decoder.Decode(&frame) != nil { + return + } + seen = append(seen, frame.Method) + response := map[string]any{"id": frame.ID} + switch frame.Method { + case "thread/list": + if frame.Params["limit"] != float64(2) || frame.Params["useStateDbOnly"] != false { + return + } + data := []any{row} + if frame.Params["archived"] == true || scenario == "missing" { + data = []any{} + } + if scenario == "archived" && frame.Params["archived"] == true { + data = []any{row} + } + if scenario == "ambiguous" { + data = append(data, row) + } + page := map[string]any{"data": data, "nextCursor": nil} + if scenario == "paged" { + page["nextCursor"] = "next" + } + if scenario == "omitted-cursor" { + delete(page, "nextCursor") + } + response["result"] = page + if scenario == "malformed" { + response["result"] = "invalid" + } + if scenario == "lookup-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -1, "message": "failed"} + } + case "thread/resume": + if frame.Params["threadId"] != "original" { + return + } + response["result"] = map[string]any{"thread": map[string]string{"id": "original"}} + if scenario == "resume-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -1, "message": "failed"} + } + case "thread/start": + response["result"] = map[string]any{"thread": map[string]string{"id": "fresh"}} + default: + return + } + if encoder.Encode(response) != nil { + return + } + } + }() + err := session.resolveThread(req, plan) + wantSuccess := scenario == "recover" || scenario == "fresh" || scenario == "known" + if (err == nil) != wantSuccess { + t.Fatalf("resolution error=%v", err) + } + cleanup() + seen := <-methods + for _, method := range seen { + if method == "thread/start" && scenario != "fresh" { + t.Fatal("silently created fresh history", seen) + } + if method == "thread/list" && scenario == "known" { + t.Fatal("searched instead of using authoritative ID", seen) + } + } + if scenario == "recover" && (len(seen) != 3 || seen[2] != "thread/resume" || session.currentThreadID() != "original") { + t.Fatal("did not resume exact root", seen) + } + }) + } +} + +func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.RequireExistingNativeSession = true + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + assertPreparationOnly(t, root) + out := make(chan proto.Envelope, 16) + session, err := p.Start(t.Context(), "recovery-run", "continue", out) + if err != nil { + t.Fatal(err) + } + defer session.Cancel(context.Background()) + select { + case <-p.session.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("recovery did not terminate") + } + found := false + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/list" { + found = true + } + if frame.Method == "thread/start" || frame.Method == "turn/start" { + t.Fatal("missing history started work", frame.Method) + } + } + if !found { + t.Fatal("prepared start lost recovery requirement") + } +} + +func TestRecoveryRequiresStrictPrivateExecution(t *testing.T) { + for _, mode := range []string{"non-strict", "no-state", "read-only"} { + t.Run(mode, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.RequireExistingNativeSession = true + switch mode { + case "non-strict": + req.StrictResume = false + case "no-state": + req.AgentStateKey = "" + case "read-only": + req.WorkspaceReadOnly = true + } + if p, err := newPreparation(t.Context(), req, cfg); err == nil { + p.Close() + t.Fatal("invalid recovery admitted") + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("invalid recovery launched native process") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/resume.go b/apps/parsar-daemon/internal/agent/codex/resume.go new file mode 100644 index 000000000..0161f3a12 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/resume.go @@ -0,0 +1,37 @@ +package codex + +import ( + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) resolveThread(req proto.PromptRequestPayload, plan SessionPlan) error { + if strings.TrimSpace(req.AgentSessionID) != "" { + if err := s.resumeThread(req.AgentSessionID, plan); err == nil { + return nil + } else if req.StrictResume { + return fmt.Errorf("codex: thread/resume: %w", err) + } else { + s.cfg.logger.Warn("codex: thread/resume failed; starting fresh", "run_id", s.runID, "thread_id", req.AgentSessionID, "err", err) + } + } + if req.RequireExistingNativeSession { + if !req.StrictResume { + return fmt.Errorf("codex: recovery requires strict resume") + } + id, err := s.recoverRoot(plan) + if err != nil { + return err + } + if err := s.resumeThread(id, plan); err != nil { + return fmt.Errorf("codex: recovered thread/resume: %w", err) + } + return nil + } + if err := s.startThread(plan); err != nil { + return fmt.Errorf("codex: thread/start: %w", err) + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/resume_test.go b/apps/parsar-daemon/internal/agent/codex/resume_test.go new file mode 100644 index 000000000..5c95d854f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/resume_test.go @@ -0,0 +1,74 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +func TestStrictResumeDoesNotStartFresh(t *testing.T) { + for _, strict := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "strict"}[strict], func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "resume-test")}} + result := make(chan error, 1) + go func() { + result <- s.resolveThread(proto.PromptRequestPayload{AgentSessionID: "existing", StrictResume: strict}, SessionPlan{}) + }() + var req struct { + ID string `json:"id"` + Method string `json:"method"` + } + decoder := json.NewDecoder(server.FromClient) + encoder := json.NewEncoder(server.ToClient) + if err := decoder.Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "thread/resume" { + t.Fatal(req.Method) + } + if err := encoder.Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32600, "message": "native history unavailable"}}); err != nil { + t.Fatal(err) + } + if !strict { + if err := decoder.Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "thread/start" { + t.Fatal(req.Method) + } + if err := encoder.Encode(map[string]any{"id": req.ID, "result": map[string]any{"thread": map[string]string{"id": "fresh"}}}); err != nil { + t.Fatal(err) + } + } + select { + case err := <-result: + if (err != nil) != strict { + t.Fatalf("strict=%v err=%v", strict, err) + } + case <-ctx.Done(): + t.Fatal("thread resolution did not finish") + } + }) + } +} + +func TestCancellationKeepsConsumedTerminalOutputAndUsage(t *testing.T) { + out := make(chan proto.Envelope, 8) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: sessionConfig{logger: log.With("component", "cancel-test")}} + s.setThreadID("native") + s.onTurnStarted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn"}}`)) + s.appendFinalText("Already produced") + s.onTurnCompleted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn","status":"interrupted","usage":{"inputTokens":31,"outputTokens":7}}}`)) + snapshot := s.CancellationOutcome() + if snapshot.Content != "Already produced" || snapshot.Usage.InputTokens != 31 || snapshot.Usage.OutputTokens != 7 || snapshot.Metadata[proto.DoneMetaAgentSessionID] != "native" { + t.Fatalf("incomplete terminal snapshot: %+v", snapshot) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc.go b/apps/parsar-daemon/internal/agent/codex/rpc.go new file mode 100644 index 000000000..81bb0a851 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc.go @@ -0,0 +1,527 @@ +package codex + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os/exec" + "sync" + "time" + + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// rpcDefaultRequestTimeout caps a single JSON-RPC request waiting for a +// response. 60s is the same default mini-captain uses; it must be long +// enough for `thread/start` (which can spin up a fresh model context), +// short enough that a dead app-server doesn't pin the prompt forever. +const rpcDefaultRequestTimeout = 60 * time.Second + +// rpcInitTimeout caps the initial JSON-RPC `initialize` handshake. +// Shorter than per-request so misconfigured environments fail fast. +const rpcInitTimeout = 10 * time.Second + +// rpcKillTimeout bounds child teardown waits. +const rpcKillTimeout = 3 * time.Second + +// rpcStdoutBufferMax caps a single NDJSON line on stdout. Codex's +// aggregated_output frames can run large; 16 MiB matches the opencode +// adapter and is well above any realistic single-line payload. +const rpcStdoutBufferMax = 16 * 1024 * 1024 + +// JSONRPCConfig configures a JSONRPCClient. Zero-value fields fall +// through to sensible defaults. +type JSONRPCConfig struct { + // Binary is the codex executable to spawn. Defaults to defaultBinary(): + // the bare name "codex" for a PATH lookup, or the PARSAR_CODEX_BIN + // override. + Binary string + // Args added before "app-server --stdio". Useful for `-c key=value` + // overrides without forcing CODEX_HOME indirection. + ExtraArgs []string + // EnableFeatures translates to repeated `--enable ` flags. + EnableFeatures []string + // DisableFeatures translates to repeated `--disable ` flags. + DisableFeatures []string + // Cwd is the working directory for the child process. Empty + // inherits the daemon's cwd. + Cwd string + // Env is layered ON TOP of os.Environ() — set CODEX_HOME / OPENAI_API_KEY + // here. Empty values are not filtered (codex distinguishes empty + // from unset for some keys). + Env []string + // LogTag is the prefix carried on every internal log line. + LogTag string + // RequestTimeout overrides rpcDefaultRequestTimeout. + RequestTimeout time.Duration + // Logger is the structured logger to use. nil falls back to obslog.Bg(). + Logger *slog.Logger +} + +// JSONRPCClient drives one `codex app-server --stdio` child process, +// speaking JSON-RPC 2.0 over stdin/stdout NDJSON. Three inbound message +// shapes are supported: +// +// - response (id + result|error) → matched to a pending request +// - notification (method + params, no id) → routed to a per-method handler +// - server request (id + method + params) → routed to a per-method handler, +// reply is sent automatically +// unless the handler returns +// DeferReply. +// +// stderr is line-pumped to the logger and never merged with stdout. +type JSONRPCClient struct { + cfg JSONRPCConfig + + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + + mu sync.Mutex + alive bool + exitCode *int + + pendingMu sync.Mutex + pending map[string]*pendingRequest + + handlersMu sync.RWMutex + notifHandlers map[string][]NotificationHandler + serverReqHandlers map[string]ServerRequestHandler + anyNotifHandler NotificationHandler + + closeOnce sync.Once + doneCh chan struct{} +} + +// NotificationHandler is invoked for every inbound notification of a +// registered method. Returning an error logs but does not kill the +// session. +type NotificationHandler func(params json.RawMessage) + +// ServerRequestHandler handles a server-initiated request. Return +// (DeferReply, nil) to take ownership of the reply (e.g. when a human +// approval card sits between request and response). Otherwise, the +// returned value is marshalled into the JSON-RPC response. Returning an +// error sends a JSON-RPC error response with code -32603. +type ServerRequestHandler func(params json.RawMessage, id any) (any, error) + +// DeferReply is the sentinel a ServerRequestHandler returns to take +// ownership of the eventual reply (call SendServerReply / SendServerError +// later). errors.Is(err, DeferReply) is false; the sentinel is matched +// by value identity, NOT errors.Is, so it's not an `error`. +type deferReplySentinel struct{} + +// DeferReply, when returned from a ServerRequestHandler, signals that +// the handler will reply later via SendServerReply. +var DeferReply = deferReplySentinel{} + +type pendingRequest struct { + method string + resp chan rpcResponse + timer *time.Timer + onResult func(json.RawMessage) error +} + +type rpcResponse struct { + result json.RawMessage + err error +} + +// NewJSONRPCClient builds a client. The child is not spawned until +// Start is called. +func NewJSONRPCClient(cfg JSONRPCConfig) *JSONRPCClient { + if cfg.Binary == "" { + cfg.Binary = defaultBinary() + } + if cfg.LogTag == "" { + cfg.LogTag = "codex-rpc" + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = rpcDefaultRequestTimeout + } + if cfg.Logger == nil { + cfg.Logger = obslog.Bg() + } + return &JSONRPCClient{ + cfg: cfg, + pending: make(map[string]*pendingRequest), + notifHandlers: make(map[string][]NotificationHandler), + serverReqHandlers: make(map[string]ServerRequestHandler), + doneCh: make(chan struct{}), + } +} + +// Start spawns the child, completes the JSON-RPC `initialize` handshake, +// and returns the server's InitializeResult. +// +// Three failure paths: +// +// - exec.LookPath / Start failure → returns the spawn error verbatim +// - initialize timeout → kills the child, returns context.DeadlineExceeded +// - JSON-RPC error on initialize → kills the child, returns the error +func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (InitializeResult, error) { + args := append([]string{}, c.cfg.ExtraArgs...) + args = append(args, "app-server", "--stdio") + for _, f := range c.cfg.EnableFeatures { + args = append(args, "--enable", f) + } + for _, f := range c.cfg.DisableFeatures { + args = append(args, "--disable", f) + } + + cmd := exec.CommandContext(ctx, c.cfg.Binary, args...) + cmd.Dir = c.cfg.Cwd + if len(c.cfg.Env) > 0 { + cmd.Env = append([]string{}, c.cfg.Env...) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return InitializeResult{}, fmt.Errorf("codex rpc: stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return InitializeResult{}, fmt.Errorf("codex rpc: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return InitializeResult{}, fmt.Errorf("codex rpc: stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return InitializeResult{}, fmt.Errorf("codex rpc: spawn %q: %w", c.cfg.Binary, err) + } + + c.cmd = cmd + c.stdin = stdin + c.stdout = stdout + c.stderr = stderr + c.mu.Lock() + c.alive = true + c.mu.Unlock() + + go c.readStdoutLoop() + go c.pumpStderr() + go c.waitChild() + + initCtx, cancel := context.WithTimeout(ctx, rpcInitTimeout) + defer cancel() + rawResult, err := c.Request(initCtx, "initialize", init) + if err != nil { + _ = c.Close() + return InitializeResult{}, fmt.Errorf("codex rpc: initialize: %w", err) + } + var result InitializeResult + if len(rawResult) > 0 { + if err := json.Unmarshal(rawResult, &result); err != nil { + _ = c.Close() + return InitializeResult{}, fmt.Errorf("codex rpc: decode initialize result: %w", err) + } + } + c.cfg.Logger.Info("codex rpc initialized", + "tag", c.cfg.LogTag, "user_agent", result.UserAgent, "codex_home", result.CodexHome) + return result, nil +} + +// Alive reports whether the child is still running and stdin is open. +func (c *JSONRPCClient) Alive() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.alive +} + +// Done is closed when the child exits. Use to coordinate teardown. +func (c *JSONRPCClient) Done() <-chan struct{} { + return c.doneCh +} + +// Notify is fire-and-forget: no id, no reply. +func (c *JSONRPCClient) Notify(method string, params any) error { + if !c.Alive() { + return errors.New("codex rpc: client not alive") + } + return c.writeFrame(JsonRpcRequest{JsonRpc: JsonRpcVersion, Method: method, Params: params}) +} + +// SendServerReply is called by a ServerRequestHandler that returned +// DeferReply to send the response later. +func (c *JSONRPCClient) SendServerReply(id any, result any) error { + return c.writeFrame(JsonRpcResponse{JsonRpc: JsonRpcVersion, ID: id, Result: result}) +} + +// SendServerError sends an error reply for a deferred server request. +func (c *JSONRPCClient) SendServerError(id any, code int, message string, data any) error { + return c.writeFrame(JsonRpcResponse{ + JsonRpc: JsonRpcVersion, + ID: id, + Error: &JsonRpcError{Code: code, Message: message, Data: data}, + }) +} + +// OnNotification registers h for inbound notifications of method. Multiple +// handlers may register against the same method; all are invoked in +// registration order. +func (c *JSONRPCClient) OnNotification(method string, h NotificationHandler) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.notifHandlers[method] = append(c.notifHandlers[method], h) +} + +// OnAnyNotification registers a fallback handler. Used by session.go for +// debug logging of unmodeled methods. +func (c *JSONRPCClient) OnAnyNotification(h NotificationHandler) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.anyNotifHandler = h +} + +// OnServerRequest registers h for inbound server requests with method. +// Only one handler per method is permitted; later Registers override. +func (c *JSONRPCClient) OnServerRequest(method string, h ServerRequestHandler) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.serverReqHandlers[method] = h +} + +// --------------------------------------------------------------------------- +// internals +// --------------------------------------------------------------------------- + +func (c *JSONRPCClient) writeFrame(frame any) error { + body, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + body = append(body, '\n') + c.mu.Lock() + stdin := c.stdin + alive := c.alive + c.mu.Unlock() + if !alive || stdin == nil { + return errors.New("codex rpc: client not alive") + } + if _, err := stdin.Write(body); err != nil { + return fmt.Errorf("write: %w", err) + } + return nil +} + +func (c *JSONRPCClient) readStdoutLoop() { + sc := bufio.NewScanner(c.stdout) + sc.Buffer(make([]byte, 0, 64*1024), rpcStdoutBufferMax) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + c.dispatchFrame(line) + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + c.mu.Lock() + alive := c.alive + c.mu.Unlock() + if alive { + c.cfg.Logger.Warn("codex rpc stdout scan err", "tag", c.cfg.LogTag, "err", err) + } + } +} + +// dispatchFrame classifies an inbound frame and routes it. +// +// Inbound shapes: +// +// {"id": ..., "result": ...} → response (success) +// {"id": ..., "error": ...} → response (failure) +// {"id": ..., "method": ...} → server request +// {"method": ..., "params": ..} → notification (no id) +func (c *JSONRPCClient) dispatchFrame(line []byte) { + var probe struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(line, &probe); err != nil { + c.cfg.Logger.Warn("codex rpc non-json frame", + "tag", c.cfg.LogTag, "preview", string(truncate(line, 200))) + return + } + hasID := len(probe.ID) > 0 && string(probe.ID) != "null" + hasMethod := probe.Method != "" + hasResp := len(probe.Result) > 0 || len(probe.Error) > 0 + + switch { + case hasID && hasResp: + c.handleResponse(probe.ID, probe.Result, probe.Error) + case hasID && hasMethod: + c.handleServerRequest(line, probe.ID, probe.Method) + case hasMethod: + c.handleNotification(probe.Method, line) + default: + c.cfg.Logger.Warn("codex rpc unrecognised frame", "tag", c.cfg.LogTag, "preview", string(truncate(line, 200))) + } +} + +func (c *JSONRPCClient) handleResponse(rawID, rawResult, rawError json.RawMessage) { + var id string + if err := json.Unmarshal(rawID, &id); err != nil { + // id may be a number on some replies; we only ever generate + // hex string ids ourselves, so a numeric reply is orphan. + c.cfg.Logger.Warn("codex rpc response with non-string id", "tag", c.cfg.LogTag, "raw_id", string(rawID)) + return + } + c.pendingMu.Lock() + p, ok := c.pending[id] + if ok { + delete(c.pending, id) + } + c.pendingMu.Unlock() + if !ok { + c.cfg.Logger.Warn("codex rpc orphan response", "tag", c.cfg.LogTag, "id", id) + return + } + if p.timer != nil { + p.timer.Stop() + } + if len(rawError) > 0 && string(rawError) != "null" { + var errBody JsonRpcError + if err := json.Unmarshal(rawError, &errBody); err != nil { + p.resp <- rpcResponse{err: fmt.Errorf("codex rpc: malformed error reply on %s: %w", p.method, err)} + return + } + p.resp <- rpcResponse{err: fmt.Errorf("codex rpc: %s: %w", p.method, &errBody)} + return + } + if p.onResult != nil { + if err := p.onResult(rawResult); err != nil { + p.resp <- rpcResponse{err: err} + return + } + } + p.resp <- rpcResponse{result: rawResult} +} + +func (c *JSONRPCClient) handleNotification(method string, rawFrame []byte) { + var env struct { + Params json.RawMessage `json:"params"` + } + _ = json.Unmarshal(rawFrame, &env) + c.handlersMu.RLock() + handlers := append([]NotificationHandler{}, c.notifHandlers[method]...) + any := c.anyNotifHandler + c.handlersMu.RUnlock() + if len(handlers) == 0 && any == nil { + c.cfg.Logger.Debug("codex rpc unhandled notification", "tag", c.cfg.LogTag, "method", method) + return + } + for _, h := range handlers { + safeInvoke(h, env.Params, c.cfg.Logger, c.cfg.LogTag, "notification "+method) + } + if any != nil { + safeInvoke(any, env.Params, c.cfg.Logger, c.cfg.LogTag, "notification "+method) + } +} + +func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessage, method string) { + var env struct { + Params json.RawMessage `json:"params"` + } + _ = json.Unmarshal(rawFrame, &env) + var id any + _ = json.Unmarshal(rawID, &id) + + c.handlersMu.RLock() + h, ok := c.serverReqHandlers[method] + c.handlersMu.RUnlock() + if !ok { + // Method-not-found per JSON-RPC 2.0 — codex hangs if we drop it. + _ = c.SendServerError(id, -32601, fmt.Sprintf("no handler for %s", method), nil) + return + } + go func() { + defer func() { + if r := recover(); r != nil { + _ = c.SendServerError(id, -32603, fmt.Sprintf("panic in %s handler: %v", method, r), nil) + } + }() + result, err := h(env.Params, id) + if err != nil { + _ = c.SendServerError(id, -32603, err.Error(), nil) + return + } + if _, deferred := result.(deferReplySentinel); deferred { + return + } + if err := c.SendServerReply(id, result); err != nil { + c.cfg.Logger.Warn("codex rpc reply failed", "tag", c.cfg.LogTag, "method", method, "err", err) + } + }() +} + +func (c *JSONRPCClient) pumpStderr() { + sc := bufio.NewScanner(c.stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + c.cfg.Logger.Warn("codex stderr", "tag", c.cfg.LogTag, "line", sc.Text()) + } +} + +func (c *JSONRPCClient) waitChild() { + defer close(c.doneCh) + err := c.cmd.Wait() + c.mu.Lock() + c.alive = false + if c.cmd.ProcessState != nil { + code := c.cmd.ProcessState.ExitCode() + c.exitCode = &code + } + c.mu.Unlock() + _ = c.drainPending(fmt.Errorf("codex app-server exited: %v", err)) +} + +func (c *JSONRPCClient) drainPending(cause error) error { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + for id, p := range c.pending { + if p.timer != nil { + p.timer.Stop() + } + // non-blocking send — resp channel is buffered=1. + select { + case p.resp <- rpcResponse{err: cause}: + default: + } + delete(c.pending, id) + } + return nil +} + +func safeInvoke(h NotificationHandler, params json.RawMessage, log *slog.Logger, tag, where string) { + defer func() { + if r := recover(); r != nil { + log.Warn("codex rpc handler panic", "tag", tag, "where", where, "panic", fmt.Sprintf("%v", r)) + } + }() + h(params) +} + +func newRequestID() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func truncate(b []byte, n int) []byte { + if len(b) <= n { + return b + } + return b[:n] +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close.go b/apps/parsar-daemon/internal/agent/codex/rpc_close.go new file mode 100644 index 000000000..94901ad7e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close.go @@ -0,0 +1,52 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Close initiates shutdown once and waits for the owned child to be reaped. +func (c *JSONRPCClient) Close() error { + defer func() { _ = c.drainPending(errors.New("codex rpc: client closed")) }() + c.closeOnce.Do(func() { + c.mu.Lock() + cmd := c.cmd + stdin := c.stdin + c.alive = false + c.mu.Unlock() + if stdin != nil { + _ = stdin.Close() + } + if cmd != nil && cmd.Process != nil { + grace := time.NewTimer(250 * time.Millisecond) + defer grace.Stop() + select { + case <-c.doneCh: + case <-grace.C: + _ = cmd.Process.Kill() + } + } + }) + c.mu.Lock() + cmd := c.cmd + c.mu.Unlock() + if cmd == nil || cmd.Process == nil { + return nil + } + wait := time.NewTimer(rpcKillTimeout) + defer wait.Stop() + select { + case <-c.doneCh: + return nil + case <-wait.C: + select { + case <-c.doneCh: + return nil + default: + c.cfg.Logger.Warn("codex rpc child did not exit after kill", "tag", c.cfg.LogTag) + return fmt.Errorf("codex rpc: waiting for child exit: %w", context.DeadlineExceeded) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go new file mode 100644 index 000000000..7584cf4b6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go @@ -0,0 +1,106 @@ +package codex + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "os/exec" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestJSONRPCClientFakeCodexProcess$", "--") + cmd.Env = append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + client := NewJSONRPCClient(JSONRPCConfig{}) + input := &countedCloseWriter{WriteCloser: stdin} + client.cmd, client.stdin, client.stdout, client.alive = cmd, input, stdout, true + var reap sync.Once + t.Cleanup(func() { + _ = cmd.Process.Kill() + reap.Do(client.waitChild) + }) + if _, err := io.WriteString(stdin, "{\"id\":\"close-test\"}\n"); err != nil { + t.Fatal(err) + } + if _, err := bufio.NewReader(stdout).ReadBytes('\n'); err != nil { + t.Fatal(err) + } + pending := &pendingRequest{resp: make(chan rpcResponse, 1)} + client.pending["pending"] = pending + + // Keep Wait under test control so a killed child remains unacknowledged. + if err := client.Close(); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("close before reaping: got %v, want deadline exceeded", err) + } + if client.Alive() { + t.Fatal("closed client still admits requests") + } + select { + case response := <-pending.resp: + if response.err == nil { + t.Fatal("pending request succeeded after close") + } + default: + t.Fatal("close left a pending request") + } + select { + case <-client.Done(): + t.Fatal("Done closed before reaping") + default: + } + + closeConcurrently := func(wantTimeout bool) { + t.Helper() + results := make(chan error, 4) + for range cap(results) { + go func() { results <- client.Close() }() + } + deadline := time.NewTimer(8 * time.Second) + defer deadline.Stop() + for range cap(results) { + select { + case err := <-results: + if wantTimeout && !errors.Is(err, context.DeadlineExceeded) || !wantTimeout && err != nil { + t.Fatalf("concurrent Close: timeout=%t, error=%v", wantTimeout, err) + } + case <-deadline.C: + t.Fatal("concurrent Close did not finish") + } + } + } + closeConcurrently(true) + reap.Do(client.waitChild) + closeConcurrently(false) + if client.cmd != cmd || cmd.ProcessState == nil { + t.Fatal("Close did not retain and reap its original child") + } + if got := input.closes.Load(); got != 1 { + t.Fatalf("stdin closed %d times, want one shutdown initiation", got) + } +} + +type countedCloseWriter struct { + io.WriteCloser + closes atomic.Int32 +} + +func (w *countedCloseWriter) Close() error { + w.closes.Add(1) + return w.WriteCloser.Close() +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_process_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_process_test.go new file mode 100644 index 000000000..eb0a7647d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_process_test.go @@ -0,0 +1,110 @@ +package codex + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" +) + +func TestJSONRPCClientCloseReapsChildProcess(t *testing.T) { + cfg := JSONRPCConfig{ + Binary: os.Args[0], + ExtraArgs: []string{"-test.run=TestJSONRPCClientFakeCodexProcess", "--"}, + Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1"), + LogTag: "codex-rpc-process-test", + RequestTimeout: 2 * time.Second, + } + client := NewJSONRPCClient(cfg) + + _, err := client.Start(context.Background(), InitializeParams{ + ClientInfo: InitializeClientInfo{Name: "test", Version: "0"}, + }) + if err != nil { + t.Fatalf("start fake process: %v", err) + } + if client.cmd == nil || client.cmd.Process == nil { + t.Fatal("client did not spawn a child process") + } + + if err := client.Close(); err != nil { + t.Fatalf("close: %v", err) + } + select { + case <-client.Done(): + case <-time.After(2 * time.Second): + t.Fatal("child process was not reaped") + } + if client.cmd.ProcessState == nil { + t.Fatalf("process state not exited after close: %#v", client.cmd.ProcessState) + } +} + +func TestJSONRPCClientResponseTimeoutStartsAfterWrite(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + client.cfg.RequestTimeout = 100 * time.Millisecond + responseDone := make(chan error, 1) + go func() { + // Block the pipe write for longer than the response timeout. + time.Sleep(200 * time.Millisecond) + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + responseDone <- err + return + } + time.Sleep(10 * time.Millisecond) + responseDone <- json.NewEncoder(server.ToClient).Encode(JsonRpcResponse{ + JsonRpc: JsonRpcVersion, ID: request.ID, Result: "ok", + }) + }() + result, err := client.Request(context.Background(), "echo", nil) + if responseErr := <-responseDone; responseErr != nil { + t.Fatalf("send response: %v", responseErr) + } + if err != nil || string(result) != `"ok"` { + t.Fatalf("response after blocked write: result=%s error=%v", result, err) + } +} + +func TestJSONRPCClientFakeCodexProcess(t *testing.T) { + if os.Getenv("CODEX_RPC_FAKE_PROCESS") != "1" { + return + } + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadBytes('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "read initialize: %v\n", err) + os.Exit(2) + } + var req struct { + ID string `json:"id"` + } + if err := json.Unmarshal(line, &req); err != nil { + fmt.Fprintf(os.Stderr, "decode initialize: %v\n", err) + os.Exit(2) + } + resp := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": map[string]any{ + "userAgent": "fake-codex", + "codexHome": "/tmp/fake-codex-home", + }, + } + body, _ := json.Marshal(resp) + fmt.Printf("%s\n", body) + if os.Getenv("CODEX_RPC_FAKE_BLOCK_WRITE") == "1" { + // Initialization already completed; consume only a prefix of the next frame. + if _, err := reader.ReadByte(); err != nil { + os.Exit(4) + } + fmt.Println(`{"jsonrpc":"2.0","method":"test/write_blocked"}`) + } + for { + time.Sleep(time.Second) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_request.go b/apps/parsar-daemon/internal/agent/codex/rpc_request.go new file mode 100644 index 000000000..6a0b7be86 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_request.go @@ -0,0 +1,88 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Request sends a JSON-RPC call and blocks until the response arrives, +// the deadline fires, or the child exits. Returns the raw result JSON +// so the caller can pick its decode shape. +func (c *JSONRPCClient) Request(ctx context.Context, method string, params any) (json.RawMessage, error) { + return c.request(ctx, method, params, c.writeFrame) +} + +func (c *JSONRPCClient) request(ctx context.Context, method string, params any, write func(any) error) (json.RawMessage, error) { + return c.requestWithTimeout(ctx, method, params, write, c.cfg.RequestTimeout, nil) +} + +// The result hook runs on the read loop before any following notification. +func (c *JSONRPCClient) requestWithResult(ctx context.Context, method string, params any, onResult func(json.RawMessage) error) (json.RawMessage, error) { + return c.requestWithTimeout(ctx, method, params, c.writeFrame, c.cfg.RequestTimeout, onResult) +} + +func (c *JSONRPCClient) requestWithTimeout(ctx context.Context, method string, params any, write func(any) error, timeout time.Duration, onResult func(json.RawMessage) error) (json.RawMessage, error) { + if !c.Alive() { + return nil, errors.New("codex rpc: client not alive") + } + id, err := newRequestID() + if err != nil { + return nil, fmt.Errorf("codex rpc: id: %w", err) + } + pending := &pendingRequest{ + method: method, + resp: make(chan rpcResponse, 1), + onResult: onResult, + } + c.pendingMu.Lock() + c.pending[id] = pending + c.pendingMu.Unlock() + + frame := JsonRpcRequest{JsonRpc: JsonRpcVersion, ID: id, Method: method, Params: params} + if err := write(frame); err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("codex rpc: write %s: %w", method, err) + } + + var deadline <-chan time.Time + if timeout > 0 { + c.pendingMu.Lock() + timer := time.NewTimer(timeout) + if c.pending[id] == pending { + pending.timer = timer + } else { + timer.Stop() + } + c.pendingMu.Unlock() + defer timer.Stop() + deadline = timer.C + } + + select { + case r := <-pending.resp: + return r.result, r.err + case <-deadline: + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("codex rpc: %s timed out after %s", method, timeout) + case <-ctx.Done(): + // A durable response can arrive while its write-phase notification is sent. + if timeout == 0 { + select { + case r := <-pending.resp: + return r.result, r.err + default: + } + } + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_test.go new file mode 100644 index 000000000..dacd1c9bd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_test.go @@ -0,0 +1,102 @@ +package codex_test + +import ( + "encoding/json" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" +) + +// TestJSONRPCClient_NotificationDispatch verifies the dispatch loop +// routes inbound notifications to a registered handler. +func TestJSONRPCClient_NotificationDispatch(t *testing.T) { + tc, srv, cleanup := codex.NewTestClient() + defer cleanup() + + var observed atomic.Int64 + tc.OnNotification("turn/started", func(p json.RawMessage) { + observed.Add(1) + }) + + if err := codex.SendNotification(srv, "turn/started", map[string]any{"turn": map[string]any{"id": "t1"}}); err != nil { + t.Fatalf("send: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if observed.Load() >= 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("notification handler never fired") +} + +// TestJSONRPCClient_ServerRequestRoundTrip exercises the inbound server- +// request branch: codex sends a request, the daemon's handler returns a +// result, the reply gets written back on stdin (the test reads it from +// the FromClient side). +func TestJSONRPCClient_ServerRequestRoundTrip(t *testing.T) { + tc, srv, cleanup := codex.NewTestClient() + defer cleanup() + + tc.OnServerRequest("item/commandExecution/requestApproval", + func(_ json.RawMessage, _ any) (any, error) { + return map[string]any{"decision": "accept"}, nil + }) + + if err := codex.SendServerRequest(srv, "req-1", "item/commandExecution/requestApproval", + map[string]any{"command": "ls"}); err != nil { + t.Fatalf("send: %v", err) + } + + decoder := json.NewDecoder(srv.FromClient) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + var reply map[string]any + if err := decoder.Decode(&reply); err == nil { + if reply["id"] != "req-1" { + t.Fatalf("reply id = %v", reply["id"]) + } + result, _ := reply["result"].(map[string]any) + if result["decision"] != "accept" { + t.Fatalf("reply result = %v", reply) + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("no reply observed within deadline") +} + +// TestJSONRPCClient_UnhandledServerRequestReplies_MethodNotFound +// confirms the daemon doesn't leave codex hanging when a server-request +// arrives for an unregistered method — it must reply with -32601. +func TestJSONRPCClient_UnhandledServerRequestReplies_MethodNotFound(t *testing.T) { + _, srv, cleanup := codex.NewTestClient() + defer cleanup() + + if err := codex.SendServerRequest(srv, "req-2", "no/such/method", nil); err != nil { + t.Fatalf("send: %v", err) + } + + decoder := json.NewDecoder(srv.FromClient) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + var reply map[string]any + if err := decoder.Decode(&reply); err == nil { + errBody, _ := reply["error"].(map[string]any) + if errBody == nil { + t.Fatalf("no error body: %v", reply) + } + if code, _ := errBody["code"].(float64); int(code) != -32601 { + t.Fatalf("error code = %v (want -32601)", errBody["code"]) + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("no error reply observed within deadline") +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_write.go b/apps/parsar-daemon/internal/agent/codex/rpc_write.go new file mode 100644 index 000000000..ce658348f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_write.go @@ -0,0 +1,26 @@ +package codex + +import "context" + +func (c *JSONRPCClient) writeFrameContext(ctx context.Context, frame any) error { + if err := ctx.Err(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- c.writeFrame(frame) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + select { + case err := <-done: + return err + default: + } + // A partial frame cannot be retracted. Close only a blocked write; + // a response timeout after a complete write leaves the process alive. + _ = c.Close() + <-done + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/parsar-daemon/internal/agent/codex/server_requests.go new file mode 100644 index 000000000..69aa69d3c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/server_requests.go @@ -0,0 +1,376 @@ +package codex + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type pendingCodexPermission struct { + rpcID any + kind codexPermissionKind + permissions map[string]any + timeout time.Duration + timer *time.Timer +} + +type pendingCodexAsk struct { + rpcID any + questionIDs []string + answerKeys []string + timeout time.Duration + timer *time.Timer +} + +const codexInteractionTimeout = 10 * time.Minute + +type codexPermissionKind uint8 + +const ( + codexDecisionApproval codexPermissionKind = iota + codexPermissionsApproval + codexMCPApproval +) + +type pendingCodexInteractions struct { + mu sync.Mutex + permissions map[string]pendingCodexPermission + asks map[string]pendingCodexAsk +} + +func newPendingCodexInteractions() *pendingCodexInteractions { + return &pendingCodexInteractions{ + permissions: make(map[string]pendingCodexPermission), + asks: make(map[string]pendingCodexAsk), + } +} + +func codexInteractionID(prefix string) string { + var bytes [8]byte + if _, err := rand.Read(bytes[:]); err == nil { + return prefix + "_" + hex.EncodeToString(bytes[:]) + } + return fmt.Sprintf("%s_fallback", prefix) +} + +func (s *Session) handleCodexCommandApproval(raw json.RawMessage, rpcID any) (any, error) { + var params CommandExecutionRequestApprovalParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode command approval: %w", err) + } + title := stringPointer(params.Command) + if title == "" { + title = "Run command" + } + return s.deferCodexPermission(rpcID, codexDecisionApproval, "command_execution", title, stringPointer(params.Reason), raw, nil) +} + +func (s *Session) handleCodexFileApproval(raw json.RawMessage, rpcID any) (any, error) { + var params FileChangeRequestApprovalParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode file approval: %w", err) + } + title := stringPointer(params.GrantRoot) + if title == "" { + title = "Apply file changes" + } + return s.deferCodexPermission(rpcID, codexDecisionApproval, "file_change", title, stringPointer(params.Reason), raw, nil) +} + +func (s *Session) handleCodexPermissionsApproval(raw json.RawMessage, rpcID any) (any, error) { + var params PermissionsRequestApprovalParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode permissions approval: %w", err) + } + title := strings.TrimSpace(params.Cwd) + if title == "" { + title = "Grant additional permissions" + } + return s.deferCodexPermission(rpcID, codexPermissionsApproval, "permission_request", title, stringPointer(params.Reason), raw, params.Permissions) +} + +func (s *Session) deferCodexPermission(rpcID any, kind codexPermissionKind, tool, title, detail string, raw json.RawMessage, permissions map[string]any) (any, error) { + requestID := codexInteractionID("perm") + payload := map[string]any{} + _ = json.Unmarshal(raw, &payload) + pending := pendingCodexPermission{ + rpcID: rpcID, kind: kind, permissions: permissions, + timeout: codexInteractionTimeout, + } + s.interactions.mu.Lock() + // Start the timer while holding the table lock. Even a future very short + // timeout then blocks in expireCodexPermission until the entry is visible, + // rather than firing before insertion and leaving an immortal request. + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexPermission(requestID) }) + s.interactions.permissions[requestID] = pending + s.interactions.mu.Unlock() + env, err := proto.NewEnvelope(proto.TypePermissionRequest, s.runID, proto.PermissionRequestPayload{ + RequestID: requestID, Tool: tool, Title: title, Detail: detail, Payload: payload, + }) + if err != nil { + s.interactions.mu.Lock() + pending, ok := s.interactions.permissions[requestID] + if ok { + delete(s.interactions.permissions, requestID) + } + s.interactions.mu.Unlock() + if ok && pending.timer != nil { + pending.timer.Stop() + } + return nil, err + } + s.trySend(env) + return DeferReply, nil +} + +func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, error) { + var params ToolRequestUserInputParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode requestUserInput: %w", err) + } + if len(params.Questions) == 0 { + return nil, errors.New("requestUserInput contains no questions") + } + askID := codexInteractionID("ask") + questions := make([]proto.PromptForUserChoiceQuestion, 0, len(params.Questions)) + questionIDs := make([]string, 0, len(params.Questions)) + answerKeys := make([]string, 0, len(params.Questions)) + for index, question := range params.Questions { + options := make([]proto.PromptForUserChoiceOption, 0, len(question.Options)) + for _, option := range question.Options { + options = append(options, proto.PromptForUserChoiceOption{Label: option.Label, Description: option.Description}) + } + header := strings.TrimSpace(question.Header) + if header == "" { + header = fmt.Sprintf("q%d", index) + } + questionID := strings.TrimSpace(question.ID) + if questionID == "" { + questionID = header + } + questionIDs = append(questionIDs, questionID) + answerKeys = append(answerKeys, header) + questions = append(questions, proto.PromptForUserChoiceQuestion{ + ID: questionID, Header: header, Question: question.Question, Options: options, + IsOther: question.IsOther, IsSecret: question.IsSecret, + }) + } + timeout := codexInteractionTimeout + if params.AutoResolutionMs != nil && *params.AutoResolutionMs > 0 { + const maxMillis = uint64(^uint64(0)>>1) / uint64(time.Millisecond) + if *params.AutoResolutionMs <= maxMillis { + timeout = time.Duration(*params.AutoResolutionMs) * time.Millisecond + } + } + pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, answerKeys: answerKeys, timeout: timeout} + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) + s.interactions.asks[askID] = pending + s.interactions.mu.Unlock() + env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, s.runID, proto.PromptForUserChoicePayload{ + AskID: askID, Questions: questions, AutoResolutionMs: params.AutoResolutionMs, + }) + if err != nil { + s.interactions.mu.Lock() + pending, ok := s.interactions.asks[askID] + if ok { + delete(s.interactions.asks, askID) + } + s.interactions.mu.Unlock() + if ok && pending.timer != nil { + pending.timer.Stop() + } + return nil, err + } + s.trySend(env) + return DeferReply, nil +} + +func (s *Session) submitCodexPermission(requestID string, decision proto.PermissionDecisionPayload) error { + s.interactions.mu.Lock() + pending, ok := s.interactions.permissions[requestID] + if ok { + delete(s.interactions.permissions, requestID) + } + s.interactions.mu.Unlock() + if !ok { + return agent.ErrUnknownPermission + } + if pending.timer != nil { + pending.timer.Stop() + } + if err := s.sendCodexPermissionReply(pending, decision.Approved); err != nil { + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexPermission(requestID) }) + s.interactions.permissions[requestID] = pending + s.interactions.mu.Unlock() + return err + } + return nil +} + +func (s *Session) sendCodexPermissionReply(pending pendingCodexPermission, approved bool) error { + if pending.kind == codexPermissionsApproval { + permissions := map[string]any{} + if approved && pending.permissions != nil { + permissions = pending.permissions + } + return s.rpc.SendServerReply(pending.rpcID, PermissionsRequestApprovalResponse{ + Permissions: permissions, + Scope: "turn", + }) + } + value := "decline" + if approved { + value = "accept" + } + if pending.kind == codexMCPApproval { + var content map[string]any + if approved { + content = map[string]any{} + } + return s.rpc.SendServerReply(pending.rpcID, mcpElicitationResponse{Action: value, Content: content}) + } + return s.rpc.SendServerReply(pending.rpcID, ApprovalDecisionResult{Decision: value}) +} + +func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + s.interactions.mu.Lock() + pending, ok := s.interactions.asks[askID] + if ok { + delete(s.interactions.asks, askID) + } + s.interactions.mu.Unlock() + if !ok { + return agent.ErrUnknownAsk + } + if pending.timer != nil { + pending.timer.Stop() + } + if decision.Cancelled { + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = "user cancelled input request" + } + if err := s.rpc.SendServerError(pending.rpcID, -32001, reason, nil); err != nil { + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) + s.interactions.asks[askID] = pending + s.interactions.mu.Unlock() + return err + } + return nil + } + byID := make(map[string][]string, len(decision.QuestionAnswers)) + byHeader := make(map[string][]string, len(decision.QuestionAnswers)) + for _, answer := range decision.QuestionAnswers { + values := answer.Answers + if len(values) == 0 { + values = splitCodexAnswers(answer.Answer) + } + if answer.QuestionID != "" { + byID[answer.QuestionID] = values + } + if answer.Header != "" { + byHeader[answer.Header] = values + } + } + result := ToolRequestUserInputResponse{Answers: make(map[string]ToolRequestUserInputAnswer, len(pending.questionIDs))} + for index, questionID := range pending.questionIDs { + values := byID[questionID] + if len(values) == 0 && index < len(pending.answerKeys) { + values = byHeader[pending.answerKeys[index]] + } + if len(values) == 0 && index < len(decision.QuestionAnswers) { + values = decision.QuestionAnswers[index].Answers + if len(values) == 0 { + values = splitCodexAnswers(decision.QuestionAnswers[index].Answer) + } + } + if len(values) == 0 && index == 0 && len(decision.Answers) > 0 { + values = decision.Answers + } + result.Answers[questionID] = ToolRequestUserInputAnswer{Answers: values} + } + if err := s.rpc.SendServerReply(pending.rpcID, result); err != nil { + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) + s.interactions.asks[askID] = pending + s.interactions.mu.Unlock() + return err + } + return nil +} + +func (s *Session) expireCodexPermission(requestID string) { + s.interactions.mu.Lock() + pending, ok := s.interactions.permissions[requestID] + if ok { + delete(s.interactions.permissions, requestID) + } + s.interactions.mu.Unlock() + if ok { + if pending.timer != nil { + pending.timer.Stop() + } + _ = s.sendCodexPermissionReply(pending, false) + } +} + +func (s *Session) expireCodexAsk(askID string) { + s.interactions.mu.Lock() + pending, ok := s.interactions.asks[askID] + if ok { + delete(s.interactions.asks, askID) + } + s.interactions.mu.Unlock() + if ok { + if pending.timer != nil { + pending.timer.Stop() + } + _ = s.rpc.SendServerError(pending.rpcID, -32001, "input request timed out", nil) + } +} + +func (s *Session) stopCodexInteractionTimers() { + s.interactions.mu.Lock() + defer s.interactions.mu.Unlock() + for id, pending := range s.interactions.permissions { + if pending.timer != nil { + pending.timer.Stop() + } + delete(s.interactions.permissions, id) + } + for id, pending := range s.interactions.asks { + if pending.timer != nil { + pending.timer.Stop() + } + delete(s.interactions.asks, id) + } +} + +func splitCodexAnswers(answer string) []string { + parts := strings.Split(strings.TrimSpace(answer), ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + out = append(out, value) + } + } + return out +} + +func stringPointer(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go b/apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go new file mode 100644 index 000000000..4cb4de00e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go @@ -0,0 +1,38 @@ +package codex + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +type mcpElicitationParams struct { + ServerName string `json:"serverName"` + Mode string `json:"mode"` + Message string `json:"message"` + Schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + } `json:"requestedSchema"` +} + +type mcpElicitationResponse struct { + Action string `json:"action"` + Content map[string]any `json:"content"` +} + +func (s *Session) handleCodexMCPElicitation(raw json.RawMessage, rpcID any) (any, error) { + var params mcpElicitationParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode MCP elicitation: %w", err) + } + if params.Mode != "form" || params.Schema.Type != "object" || params.Schema.Properties == nil || len(params.Schema.Properties) != 0 || len(params.Schema.Required) != 0 { + return nil, errors.New("MCP elicitation requires unsupported input; only empty confirmation forms are supported") + } + if strings.TrimSpace(params.ServerName) == "" || strings.TrimSpace(params.Message) == "" { + return nil, errors.New("MCP confirmation requires a server name and message") + } + return s.deferCodexPermission(rpcID, codexMCPApproval, "mcp:"+params.ServerName, params.Message, "", raw, nil) +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go b/apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go new file mode 100644 index 000000000..7be84ee5e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go @@ -0,0 +1,100 @@ +package codex + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCodexMCPConfirmationUsesPermissionLifecycle(t *testing.T) { + for _, action := range []string{"approve", "deny", "expire"} { + t.Run(action, func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + defer s.stopCodexInteractionTimers() + params := json.RawMessage(`{"threadId":"thread-1","turnId":"turn-1","serverName":"qa-service-desk","mode":"form","message":"Allow get_test_ticket?","requestedSchema":{"type":"object","properties":{}},"_meta":{"codex_approval_kind":"mcp_tool_call","persist":["session","always"]}}`) + if err := SendServerRequest(srv, "rpc-mcp", "mcpServer/elicitation/request", params); err != nil { + t.Fatal(err) + } + var env proto.Envelope + select { + case env = <-out: + case <-time.After(2 * time.Second): + t.Fatal("MCP confirmation was not surfaced") + } + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatal(err) + } + if env.Type != proto.TypePermissionRequest || env.ID != "run-test" || request.RequestID == "" || request.Tool != "mcp:qa-service-desk" || request.Title != "Allow get_test_ticket?" { + t.Fatalf("incorrect confirmation: %+v / %+v", env, request) + } + done := make(chan error, 1) + go func() { + if action == "expire" { + s.expireCodexPermission(request.RequestID) + done <- nil + return + } + done <- s.SubmitPermission(t.Context(), request.RequestID, proto.PermissionDecisionPayload{Approved: action == "approve"}) + }() + var reply struct { + ID string `json:"id"` + Result map[string]json.RawMessage `json:"result"` + } + decodeCodexReply(t, srv, &reply) + if err := <-done; err != nil { + t.Fatal(err) + } + wantAction, wantContent := `"decline"`, `null` + if action == "approve" { + wantAction, wantContent = `"accept"`, `{}` + } + if reply.ID != "rpc-mcp" || len(reply.Result) != 2 || string(reply.Result["action"]) != wantAction || string(reply.Result["content"]) != wantContent { + t.Fatalf("incorrect MCP response: %+v", reply) + } + if err := s.SubmitPermission(t.Context(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("resolved confirmation accepted again: %v", err) + } + }) + } +} + +func TestCodexMCPElicitationRejectsUnsupportedInputWithoutApproval(t *testing.T) { + for _, params := range []string{ + `{"serverName":"qa","mode":"form","message":"Enter name","requestedSchema":{"type":"object","properties":{"name":{"type":"string"}}}}`, + `{"serverName":"qa","mode":"url","message":"Sign in","url":"https://example.test/login","elicitationId":"login"}`, + `{"serverName":"qa","mode":"form","message":"Confirm","requestedSchema":{"type":"object","properties":{},"required":["name"]}}`, + `{"serverName":"qa","mode":"form","message":"Confirm","requestedSchema":{"type":"object"}}`, + `{"mode":"form","requestedSchema":{"type":"object","properties":{}}}`, + } { + t.Run(params, func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + defer s.stopCodexInteractionTimers() + if err := SendServerRequest(srv, "rpc-unsupported", "mcpServer/elicitation/request", json.RawMessage(params)); err != nil { + t.Fatal(err) + } + var reply struct { + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + decodeCodexReply(t, srv, &reply) + if reply.Error == nil || reply.Error.Code != -32603 { + t.Fatalf("unsupported input was not rejected: %+v", reply) + } + select { + case env := <-out: + t.Fatalf("unsupported input became an approval: %+v", env) + default: + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go new file mode 100644 index 000000000..0665a6187 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go @@ -0,0 +1,349 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func newInteractionTestSession(rpc *JSONRPCClient) (*Session, <-chan proto.Envelope) { + out := make(chan proto.Envelope, 1) + s := &Session{ + runID: "run-test", + out: out, + rpc: rpc, + cancelCtx: context.Background(), + interactions: newPendingCodexInteractions(), + } + s.registerHandlers() + return s, out +} + +func TestCodexPermissionRequestWaitsForHumanDecision(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + + command := "go test ./..." + reason := "requires process execution" + if err := SendServerRequest(srv, "rpc-perm-1", "item/commandExecution/requestApproval", CommandExecutionRequestApprovalParams{ + ThreadID: "thread-1", TurnID: "turn-1", ItemID: "item-1", Command: &command, Reason: &reason, + }); err != nil { + t.Fatalf("send server request: %v", err) + } + + var env proto.Envelope + select { + case env = <-out: + case <-time.After(2 * time.Second): + t.Fatal("permission request was not surfaced to Parsar") + } + if env.Type != proto.TypePermissionRequest { + t.Fatalf("envelope type = %q, want %q", env.Type, proto.TypePermissionRequest) + } + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission payload: %v", err) + } + if env.ID != "run-test" || request.RequestID == "" { + t.Fatalf("permission correlation = env.ID %q request.ID %q", env.ID, request.RequestID) + } + if request.Tool != "command_execution" || request.Title != command || request.Detail != reason { + t.Fatalf("permission payload = %+v", request) + } + + submitDone := make(chan error, 1) + go func() { + submitDone <- s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}) + }() + reply := readCodexServerReply(t, srv) + if err := <-submitDone; err != nil { + t.Fatalf("submit permission: %v", err) + } + if reply.ID != "rpc-perm-1" || reply.Result.Decision != "accept" { + t.Fatalf("reply = %+v", reply) + } +} + +func TestCodexPermissionsApprovalUsesDedicatedResponseSchema(t *testing.T) { + for _, tt := range []struct { + name string + approved bool + wantGrants bool + }{ + {name: "approve echoes requested grants", approved: true, wantGrants: true}, + {name: "deny grants nothing", approved: false, wantGrants: false}, + } { + t.Run(tt.name, func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + requested := map[string]any{ + "network": map[string]any{"enabled": true}, + "fileSystem": map[string]any{"write": []any{"/workspace"}}, + } + if err := SendServerRequest(srv, "rpc-permissions", "item/permissions/requestApproval", PermissionsRequestApprovalParams{ + Cwd: "/workspace", Permissions: requested, + }); err != nil { + t.Fatalf("send permissions request: %v", err) + } + env := <-out + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission payload: %v", err) + } + done := make(chan error, 1) + go func() { + done <- s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: tt.approved}) + }() + var reply struct { + ID string `json:"id"` + Result struct { + Permissions map[string]any `json:"permissions"` + Scope string `json:"scope"` + Decision string `json:"decision"` + } `json:"result"` + } + decodeCodexReply(t, srv, &reply) + if err := <-done; err != nil { + t.Fatalf("submit permissions: %v", err) + } + if reply.Result.Scope != "turn" || reply.Result.Decision != "" { + t.Fatalf("permissions reply = %+v", reply.Result) + } + _, granted := reply.Result.Permissions["network"] + if granted != tt.wantGrants { + t.Fatalf("permissions = %+v, want grants=%v", reply.Result.Permissions, tt.wantGrants) + } + }) + } +} + +func TestCodexUserInputMapsAnswersByQuestionID(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + + autoResolutionMs := uint64(120_000) + if err := SendServerRequest(srv, "rpc-ask-1", "item/tool/requestUserInput", ToolRequestUserInputParams{ + ThreadID: "thread-1", TurnID: "turn-1", ItemID: "item-ask", + AutoResolutionMs: &autoResolutionMs, + Questions: []ToolRequestUserInputQuestion{ + {ID: "deployment", Header: "Deploy", Question: "Where?", IsOther: true, Options: []ToolRequestUserInputOption{{Label: "Staging"}, {Label: "Production"}}}, + {ID: "checks", Header: "Checks", Question: "Which checks?", IsSecret: true, Options: []ToolRequestUserInputOption{{Label: "Unit"}, {Label: "E2E"}}}, + }, + }); err != nil { + t.Fatalf("send server request: %v", err) + } + + var env proto.Envelope + select { + case env = <-out: + case <-time.After(2 * time.Second): + t.Fatal("requestUserInput was not surfaced to Parsar") + } + if env.Type != proto.TypePromptForUserChoice { + t.Fatalf("envelope type = %q, want %q", env.Type, proto.TypePromptForUserChoice) + } + var request proto.PromptForUserChoicePayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode user input payload: %v", err) + } + if len(request.Questions) != 2 || request.Questions[0].ID != "deployment" || request.Questions[1].ID != "checks" || request.Questions[0].Header != "Deploy" { + t.Fatalf("user input payload = %+v", request) + } + if !request.Questions[0].IsOther || !request.Questions[1].IsSecret || request.AutoResolutionMs == nil || *request.AutoResolutionMs != autoResolutionMs { + t.Fatalf("user input metadata = %+v", request) + } + s.interactions.mu.Lock() + pending := s.interactions.asks[request.AskID] + s.interactions.mu.Unlock() + if pending.timeout != 2*time.Minute { + t.Fatalf("ask timeout = %v, want 2m", pending.timeout) + } + + submitDone := make(chan error, 1) + go func() { + submitDone <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ + {QuestionID: "checks", Answers: []string{"Unit", "E2E"}}, + {QuestionID: "deployment", Answers: []string{"Staging"}}, + }, + }) + }() + + var reply struct { + ID string `json:"id"` + Result ToolRequestUserInputResponse `json:"result"` + } + decodeCodexReply(t, srv, &reply) + if err := <-submitDone; err != nil { + t.Fatalf("submit user input: %v", err) + } + if reply.ID != "rpc-ask-1" { + t.Fatalf("reply id = %q", reply.ID) + } + if got := reply.Result.Answers["deployment"].Answers; len(got) != 1 || got[0] != "Staging" { + t.Fatalf("deployment answers = %v", got) + } + if got := reply.Result.Answers["checks"].Answers; len(got) != 2 || got[0] != "Unit" || got[1] != "E2E" { + t.Fatalf("checks answers = %v", got) + } +} + +func TestCodexUserInputCancellationReturnsErrorInsteadOfEmptyAnswers(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + + if err := SendServerRequest(srv, "rpc-ask-cancel", "item/tool/requestUserInput", ToolRequestUserInputParams{ + Questions: []ToolRequestUserInputQuestion{{ID: "confirm", Header: "Confirm", Question: "Continue?"}}, + }); err != nil { + t.Fatalf("send server request: %v", err) + } + var env proto.Envelope + select { + case env = <-out: + case <-time.After(2 * time.Second): + t.Fatal("requestUserInput was not surfaced to Parsar") + } + var request proto.PromptForUserChoicePayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode user input payload: %v", err) + } + submitDone := make(chan error, 1) + go func() { + submitDone <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ + Cancelled: true, Reason: "cancelled by user", + }) + }() + + var reply struct { + ID string `json:"id"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + decodeCodexReply(t, srv, &reply) + if err := <-submitDone; err != nil { + t.Fatalf("cancel user input: %v", err) + } + if reply.ID != "rpc-ask-cancel" || reply.Error == nil || reply.Error.Code != -32001 { + t.Fatalf("cancel reply = %+v", reply) + } +} + +func TestCodexInteractionExpiryUnblocksRuntime(t *testing.T) { + t.Run("permission declines", func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + command := "deploy" + if err := SendServerRequest(srv, "rpc-perm-timeout", "item/commandExecution/requestApproval", CommandExecutionRequestApprovalParams{Command: &command}); err != nil { + t.Fatalf("send permission request: %v", err) + } + env := <-out + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission payload: %v", err) + } + expireDone := make(chan struct{}) + go func() { s.expireCodexPermission(request.RequestID); close(expireDone) }() + reply := readCodexServerReply(t, srv) + <-expireDone + if reply.Result.Decision != "decline" { + t.Fatalf("expiry decision = %q", reply.Result.Decision) + } + if err := s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("late permission error = %v, want ErrUnknownPermission", err) + } + }) + + t.Run("permission profile grants nothing", func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + if err := SendServerRequest(srv, "rpc-profile-timeout", "item/permissions/requestApproval", PermissionsRequestApprovalParams{ + Permissions: map[string]any{"network": map[string]any{"enabled": true}}, + }); err != nil { + t.Fatalf("send permission profile request: %v", err) + } + env := <-out + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission payload: %v", err) + } + done := make(chan struct{}) + go func() { s.expireCodexPermission(request.RequestID); close(done) }() + var reply struct { + Result PermissionsRequestApprovalResponse `json:"result"` + } + decodeCodexReply(t, srv, &reply) + <-done + if len(reply.Result.Permissions) != 0 || reply.Result.Scope != "turn" { + t.Fatalf("expiry permissions response = %+v", reply.Result) + } + }) + + t.Run("user input returns timeout error", func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + if err := SendServerRequest(srv, "rpc-ask-timeout", "item/tool/requestUserInput", ToolRequestUserInputParams{ + Questions: []ToolRequestUserInputQuestion{{ID: "q1", Header: "Confirm", Question: "Continue?"}}, + }); err != nil { + t.Fatalf("send input request: %v", err) + } + var request proto.PromptForUserChoicePayload + if err := (<-out).DecodePayload(&request); err != nil { + t.Fatalf("decode input request: %v", err) + } + expireDone := make(chan struct{}) + go func() { s.expireCodexAsk(request.AskID); close(expireDone) }() + var reply struct { + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + decodeCodexReply(t, srv, &reply) + <-expireDone + if reply.Error == nil || reply.Error.Code != -32001 { + t.Fatalf("expiry reply = %+v", reply) + } + if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"yes"}}); !errors.Is(err, agent.ErrUnknownAsk) { + t.Fatalf("late input error = %v, want ErrUnknownAsk", err) + } + }) +} + +type approvalReply struct { + ID string `json:"id"` + Result ApprovalDecisionResult `json:"result"` +} + +func readCodexServerReply(t *testing.T, srv ServerSide) approvalReply { + t.Helper() + var reply approvalReply + decodeCodexReply(t, srv, &reply) + return reply +} + +func decodeCodexReply(t *testing.T, srv ServerSide, target any) { + t.Helper() + done := make(chan error, 1) + go func() { done <- json.NewDecoder(srv.FromClient).Decode(target) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("decode Codex reply: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Codex reply timed out") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go new file mode 100644 index 000000000..59b7e1bad --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -0,0 +1,483 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// terminalSendTimeout caps how long the session waits to deliver the +// final done / error envelope on the upstream channel. Matches the +// claudecode + opencode safety net. +const terminalSendTimeout = 2 * time.Second + +// sessionConfig is the cross-cutting knob bag — production callers go +// through Factory which uses defaults. +type sessionConfig struct { + codexBinary string + harnessBinary string + permissionProfile string + runtimeNetworkAccess string + logger *slog.Logger + killTimeout time.Duration +} + +func defaultSessionConfig() sessionConfig { + return sessionConfig{ + codexBinary: defaultBinary(), + harnessBinary: os.Getenv("PARSAR_CODEX_HARNESS_BIN"), + permissionProfile: os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE"), + runtimeNetworkAccess: os.Getenv("PARSAR_RUNTIME_NETWORK_ACCESS"), + logger: obslog.Bg(), + killTimeout: rpcKillTimeout, + } +} + +// Factory implements agent.Factory for agent_kind="codex". Spawns one +// codex app-server child per prompt. The run stream closes when the turn +// completes; the router retains the child until the conversation's idle +// window expires or cancellation shuts it down sooner. +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultSessionConfig()) +} + +// Session implements agent.Session. State lifecycle: +// +// 1. Preparation initializes RPC and verifies the selected environment. +// 2. Start transfers that RPC and wires notification/server-request handlers. +// 3. thread/start or thread/resume runs (resume falls back to start). +// 4. turn/start delivers the user prompt; subsequent stream notifications +// fan out to proto.Envelope via session_items.go. +// 5. turn/completed emits TypeDone + closes out. Cancel can short-cut +// this by killing the child early. +type Session struct { + toolEnvironment bool + subagents *subagentObservations + observeSubagentIdentities bool + functions *functionCalls + observeMessages bool + observeTools bool + observeToolObservations bool + runID string + cfg sessionConfig + out chan<- proto.Envelope + rpc *JSONRPCClient + harness *privateHarness + + cancelCtx context.Context + cancelFn context.CancelFunc + + cancelOnce sync.Once + cancelled atomic.Bool + terminal atomic.Bool + closeOutOnce sync.Once + outMu sync.RWMutex + outClosed bool + waitDone chan struct{} + cleanup func() + + threadIDMu sync.Mutex + threadID string + steering steeringTurn + + deltaSeq atomic.Uint64 + thinkingSeq atomic.Uint64 + + bufs *ItemBuffers + + usageMu sync.Mutex + latestUsage *TurnUsage + usageTotal TurnUsage + usageBaseline TurnUsage + usageTurnID string + resumeUsageThreadID string + resumeUsageTotal *TurnUsage + resolvedModel string + + finalTextMu sync.Mutex + finalText string + lastErrText string + + interactions *pendingCodexInteractions + outcome cancellationOutcomeState +} + +var _ agent.Session = (*Session)(nil) + +// SubmitPermission completes the deferred Codex app-server request that +// produced the Parsar permission envelope. +func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { + return s.submitCodexPermission(permID, decision) +} + +// SubmitPromptForUserChoice maps Parsar's header/answer pairs back to +// Codex's question-id keyed requestUserInput response. +func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + return s.submitCodexUserInput(askID, decision) +} + +// --------------------------------------------------------------------------- +// notification handlers +// --------------------------------------------------------------------------- + +func (s *Session) registerHandlers() { + rpc := s.rpc + + rpc.OnNotification("thread/started", func(_ json.RawMessage) {}) + rpc.OnNotification("turn/started", s.onTurnStarted) + rpc.OnNotification("turn/completed", s.onTurnCompleted) + rpc.OnNotification("turn/failed", s.onTurnFailed) + rpc.OnNotification("item/started", s.onItemStarted) + rpc.OnNotification("item/updated", func(_ json.RawMessage) {}) // silenced + rpc.OnNotification("item/completed", s.onItemCompleted) + rpc.OnNotification("item/agentMessage/delta", s.onAgentDelta) + rpc.OnNotification("item/commandExecution/outputDelta", s.onCommandOutput) + rpc.OnNotification("item/reasoning/textDelta", s.onReasoningDelta) + rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) + rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) + rpc.OnNotification("error", s.onErrorNotif) + rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) + + rpc.OnServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) + rpc.OnServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) + rpc.OnServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) + // Older app-server releases used this unseparated method name. + rpc.OnServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) + rpc.OnServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) + rpc.OnServerRequest("item/tool/call", s.handleFunctionCall) + rpc.OnServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) +} + +func (s *Session) onTurnStarted(raw json.RawMessage) { + var p TurnStartedNotification + if json.Unmarshal(raw, &p) == nil { + s.beginRootTurn(p.ThreadID, p.Turn.ID) + } +} + +func (s *Session) onReasoningDelta(raw json.RawMessage) { + var p AgentMessageDeltaNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { + return + } + _ = FoldDeltaIntoBuffer(s.bufs, "reasoning", p.ItemID, p.Delta) + seq := s.thinkingSeq.Add(1) + env, err := proto.NewEnvelope(proto.TypeThinking, s.runID, proto.ThinkingPayload{Text: p.Delta, Sequence: seq}) + if err != nil { + return + } + s.trySend(env) +} + +func (s *Session) onTurnCompleted(raw json.RawMessage) { + s.outcome.notificationMu.Lock() + defer s.outcome.notificationMu.Unlock() + var p TurnCompletedNotification + if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { + return + } + s.stopSteering() + + usage := p.Turn.Usage + if usage == nil { + s.usageMu.Lock() + usage = s.latestUsage + s.usageMu.Unlock() + } + if usage != nil { + s.usageMu.Lock() + s.latestUsage = usage + s.usageMu.Unlock() + s.emitUsage(*usage) + } + + status := strings.ToLower(p.Turn.Status) + finalText := s.takeFinalText() + errText := s.takeLastErrText() + // Always log the turn outcome — operators need this when a prompt + // "completes" with no agent message (e.g. codex bailed before the + // model ran because the sandbox mode was misinterpreted as + // read-only) so the empty body in the upstream Done frame can be + // correlated with the turn status that produced it. + s.cfg.logger.Info("codex: turn/completed", + "run_id", s.runID, + "turn_id", p.Turn.ID, + "status", p.Turn.Status, + "final_text_len", len(finalText), + "buffered_err_text_len", len(errText), + "raw_payload", string(raw)) + if status == "failed" { + // Body precedence on failure: + // 1. agent's final text (rare on hard failures but exists for + // partial completions that still surface a message) + // 2. codex's turn.error.message — this is where gateway / + // provider errors land (e.g. an upstream gateway's "X-Sub-Module is + // not allowed for this API key"). Without forwarding it + // the upstream connector reports "empty final output" and + // operators can't see why a key was rejected. + // 3. buffered text from the "error" notification stream + // (sandbox warnings, late stream packets) — last because + // it's noisier than turn.error. + body := finalText + if turnErrMsg := turnErrorMessage(p.Turn.Error); turnErrMsg != "" { + body = appendOnNewline(body, turnErrMsg) + } + if errText != "" { + body = appendOnNewline(body, errText) + } + s.emitTerminal(body, true) + s.finishAfterTerminal() + return + } + s.emitDone(finalText, usage) + s.finishAfterTerminal() +} + +// turnErrorMessage extracts a human-readable error string from +// codex's TurnError. Some gateways (an OpenAI-Responses-style proxy +// is one) JSON-encode the upstream error body and stuff it +// into Message verbatim; in that case unwrap one layer so the +// operator sees the inner code/message instead of escaped JSON. +func turnErrorMessage(te *TurnError) string { + if te == nil { + return "" + } + raw := strings.TrimSpace(te.Message) + if raw == "" { + return "" + } + // Try to peel off a {"error":{"code","message","type"}} wrapper. + var inner struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &inner); err == nil && inner.Error.Message != "" { + if inner.Error.Code != "" { + return fmt.Sprintf("%s: %s", inner.Error.Code, inner.Error.Message) + } + return inner.Error.Message + } + return raw +} + +func appendOnNewline(base, extra string) string { + if base == "" { + return extra + } + if extra == "" { + return base + } + return base + "\n\n" + extra +} + +func (s *Session) onTurnFailed(raw json.RawMessage) { + var p TurnCompletedNotification + if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { + return + } + // turn/failed carries no payload detail today; the actual cause + // usually arrived earlier on the "error" notification stream and is + // already buffered in lastErrText. Log both so post-mortems can + // correlate the failure to whatever upstream codex saw. + s.cfg.logger.Warn("codex: turn/failed received", + "run_id", s.runID, + "turn_id", p.Turn.ID, + "turn_status", p.Turn.Status, + "last_err_text_present", s.peekLastErrText() != "") + s.emitTerminal("codex: turn failed", true) + s.finishAfterTerminal() +} + +func (s *Session) onErrorNotif(raw json.RawMessage) { + var p ErrorNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) { + return + } + if p.Error != nil { + p.Message = turnErrorMessage(p.Error) + } + if p.Message == "" { + return + } + // Always log: codex's `error` notification is the *only* channel that + // surfaces gateway / model-provider failures (401 on a custom header, + // 400 from Azure missing api-version, etc.). Buffering it for the + // eventual turn/completed message body is correct, but without a log + // the daemon shows 13s of silence then a TypeError that the upstream + // can't decode. + s.cfg.logger.Warn("codex: error notification received", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message", p.Message) + s.finalTextMu.Lock() + s.lastErrText = p.Message + s.finalTextMu.Unlock() +} + +// peekLastErrText reads lastErrText without consuming it, used by +// loggers that want to record "we have a buffered upstream error" +// without racing with the takeLastErrText path that emitDone uses. +func (s *Session) peekLastErrText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + return s.lastErrText +} + +// --------------------------------------------------------------------------- +// envelope emit helpers +// --------------------------------------------------------------------------- + +func (s *Session) emitDone(content string, usage *TurnUsage) { + if !s.terminal.CompareAndSwap(false, true) { + return + } + s.stopSteering() + doneMeta := map[string]any{} + if tid := s.currentThreadID(); tid != "" { + doneMeta[proto.DoneMetaAgentSessionID] = tid + doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" + } + payload := proto.DonePayload{Content: content, Metadata: doneMeta} + if usage != nil { + payload.Usage = s.usagePayload(*usage) + } + payload = s.rememberOutcome(payload) + env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) + if err != nil { + return + } + s.sendTerminal(env) +} + +func (s *Session) emitUsage(u TurnUsage) { + env, err := proto.NewEnvelope(proto.TypeUsage, s.runID, proto.UsagePayload{ + Usage: s.usagePayload(u), + }) + if err != nil { + return + } + s.trySend(env) +} + +func (s *Session) emitTerminal(message string, asError bool) { + if !s.terminal.CompareAndSwap(false, true) { + return + } + s.stopSteering() + // Always log: this is the only place the daemon decides "the prompt is + // over, here's what went wrong (if anything)". Without this, post- + // mortem requires correlating server-side TypeError frames against + // daemon timestamps with no message body anywhere. + if asError { + s.cfg.logger.Warn("codex: emitting terminal error", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message", message) + } else { + s.cfg.logger.Info("codex: emitting terminal done", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message_len", len(message)) + } + var events []proto.Envelope + if asError { + env, err := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: message}) + if err == nil { + events = append(events, env) + } + } + doneMeta := map[string]any{} + if tid := s.currentThreadID(); tid != "" { + doneMeta[proto.DoneMetaAgentSessionID] = tid + doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" + } + payload := proto.DonePayload{ + Content: message, + Metadata: doneMeta, + } + payload = s.rememberOutcome(payload) + env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) + if err != nil { + return + } + s.sendTerminal(append(events, env)...) +} + +func (s *Session) closeOut() { + s.stopSteering() + s.closeOutOnce.Do(func() { + s.outMu.Lock() + s.outClosed = true + close(s.out) + s.outMu.Unlock() + }) +} + +func (s *Session) finishAfterTerminal() { + if s.subagents == nil { + s.closeOut() + } +} + +// --------------------------------------------------------------------------- +// small accessors +// --------------------------------------------------------------------------- + +func (s *Session) currentThreadID() string { + s.threadIDMu.Lock() + defer s.threadIDMu.Unlock() + return s.threadID +} + +func (s *Session) setThreadID(id string) { + s.threadIDMu.Lock() + if s.threadID == "" { + s.threadID = id + } + s.threadIDMu.Unlock() +} + +func (s *Session) appendFinalText(text string) { + s.finalTextMu.Lock() + if s.finalText != "" { + s.finalText = s.finalText + "\n\n" + text + } else { + s.finalText = text + } + s.finalTextMu.Unlock() +} + +func (s *Session) takeFinalText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + t := s.finalText + s.finalText = "" + return t +} + +func (s *Session) takeLastErrText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + e := s.lastErrText + s.lastErrText = "" + return e +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel.go b/apps/parsar-daemon/internal/agent/codex/session_cancel.go new file mode 100644 index 000000000..cc7d8a709 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel.go @@ -0,0 +1,26 @@ +package codex + +import ( + "context" + "time" +) + +func (s *Session) Cancel(_ context.Context) error { + s.cancelled.Store(true) + s.cancelOnce.Do(func() { + turnID, active := s.stopSteering() + s.stopCodexInteractionTimers() + // Best effort: a known Turn must use its native identity. An explicit + // empty ID invokes native startup cancellation before turn/started. + if threadID := s.currentThreadID(); threadID != "" && active { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, _ = s.rpc.request(ctx, "turn/interrupt", TurnInterruptParams{ThreadID: threadID, TurnID: turnID}, func(frame any) error { + return s.rpc.writeFrameContext(ctx, frame) + }) + } + s.cancelFn() + _ = s.rpc.Close() + }) + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/session_cancel_test.go new file mode 100644 index 000000000..023b708cb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel_test.go @@ -0,0 +1,154 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "io" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type interruptRequest struct { + ID string `json:"id"` + Method string `json:"method"` + Params struct { + ThreadID string `json:"threadId"` + TurnID *string `json:"turnId"` + } `json:"params"` +} + +func TestCancelUsesNativeTurnIdentity(t *testing.T) { + for _, name := range []string{"running", "foreign notification", "startup", "completed", "before thread", "rejected", "response timeout"} { + t.Run(name, func(t *testing.T) { + s, client, server := cancellationTestSession(t) + if name != "before thread" { + s.setThreadID("native-thread") + } + if name != "startup" && name != "before thread" { + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + } + if name == "foreign notification" { + s.onTurnStarted(json.RawMessage(`{"threadId":"foreign-thread","turn":{"id":"foreign-turn"}}`)) + } + if name == "completed" { + s.onTurnCompleted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn","status":"completed"}}`)) + } + requests := collectCancellationRequests(t, server, name) + var calls sync.WaitGroup + for range 3 { + calls.Add(1) + go func() { + defer calls.Done() + if err := s.Cancel(context.Background()); err != nil { + t.Errorf("best-effort cancel: %v", err) + } + }() + } + finished := make(chan struct{}) + go func() { calls.Wait(); close(finished) }() + select { + case <-finished: + case <-time.After(4 * time.Second): + t.Fatal("cancellation did not finish after the response deadline") + } + if client.Alive() || s.cancelCtx.Err() == nil { + t.Fatal("cancellation did not close the native client and context") + } + got := <-requests + if name == "completed" || name == "before thread" { + if len(got) != 0 { + t.Fatalf("unexpected native interrupt: %+v", got) + } + } else { + want := "native-turn" + if name == "startup" { + want = "" + } + if len(got) != 1 || got[0].Method != "turn/interrupt" || got[0].Params.ThreadID != "native-thread" || got[0].Params.TurnID == nil || *got[0].Params.TurnID != want { + t.Fatalf("incorrect or repeated native cancellation: %+v", got) + } + } + if name != "before thread" && s.CancellationOutcome().Metadata[proto.DoneMetaAgentSessionID] != "native-thread" { + t.Fatal("cancellation lost native continuation identity") + } + }) + } +} + +func TestCancelRacingTurnStartedKeepsValidNativeTarget(t *testing.T) { + for range 32 { + s, _, server := cancellationTestSession(t) + s.setThreadID("native-thread") + requests := collectCancellationRequests(t, server, "running") + start := make(chan struct{}) + observed := make(chan struct{}) + go func() { + <-start + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + close(observed) + }() + close(start) + if err := s.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + <-observed + got := <-requests + if len(got) != 1 || got[0].Params.ThreadID != "native-thread" || got[0].Params.TurnID == nil { + t.Fatalf("missing cancellation identity: %+v", got) + } + if id := *got[0].Params.TurnID; id != "" && id != "native-turn" { + t.Fatalf("foreign native Turn ID: %q", id) + } + if id, active := s.stopSteering(); active || id != *got[0].Params.TurnID { + t.Fatalf("late notification changed a stopped target: %q, %v", id, active) + } + } +} + +func cancellationTestSession(t *testing.T) (*Session, *TestClient, ServerSide) { + t.Helper() + client, server, cleanup := NewTestClient() + t.Cleanup(cleanup) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cancelFn: cancel, + cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), out: make(chan proto.Envelope, 8), bufs: NewItemBuffers()} + return s, client, server +} + +func collectCancellationRequests(t *testing.T, server ServerSide, mode string) <-chan []interruptRequest { + t.Helper() + done := make(chan []interruptRequest, 1) + go func() { + var requests []interruptRequest + defer func() { done <- requests }() + decoder := json.NewDecoder(server.FromClient) + for { + var request interruptRequest + if err := decoder.Decode(&request); err != nil { + if !errors.Is(err, io.EOF) { + t.Errorf("read cancellation: %v", err) + } + return + } + requests = append(requests, request) + if mode == "response timeout" { + continue + } + reply := map[string]any{"id": request.ID, "result": map[string]any{}} + if mode == "rejected" { + delete(reply, "result") + reply["error"] = map[string]any{"code": -32600, "message": "no active turn to interrupt"} + } + if err := json.NewEncoder(server.ToClient).Encode(reply); err != nil { + t.Errorf("reply to cancellation: %v", err) + return + } + } + }() + return done +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go b/apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go new file mode 100644 index 000000000..7c0eed406 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go @@ -0,0 +1,185 @@ +package codex + +import ( + "context" + "encoding/json" + "io" + "os" + "strings" + "testing" + "time" +) + +func TestCancelReleasesBlockedControlWrite(t *testing.T) { + for _, concurrent := range []bool{false, true} { + name := "interrupt" + if concurrent { + name = "another write" + } + t.Run(name, func(t *testing.T) { + s, client, server := cancellationTestSession(t) + s.setThreadID("native-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + _, peer, peerServer := cancellationTestSession(t) + cancelDone := make(chan struct{}) + writeDone := make(chan struct{}) + var writeErr error + t.Cleanup(func() { + _ = client.Close() + select { + case <-cancelDone: + case <-time.After(4 * time.Second): + t.Error("cancellation did not stop after test cleanup") + } + if concurrent { + select { + case <-writeDone: + case <-time.After(4 * time.Second): + t.Error("concurrent writer did not stop after test cleanup") + } + } + }) + if concurrent { + go func() { + writeErr = client.Notify("blocked", nil) + close(writeDone) + }() + readControlPrefix(t, server.FromClient) + } + go func() { + defer close(cancelDone) + if err := s.Cancel(context.Background()); err != nil { + t.Errorf("best-effort cancellation: %v", err) + } + }() + if !concurrent { + readControlPrefix(t, server.FromClient) + } + // The pipe remains undrained after one byte, so the write cannot complete. + select { + case <-cancelDone: + case <-time.After(4 * time.Second): + t.Fatal("blocked control write exceeded the interrupt budget without cleanup") + } + if client.Alive() || s.cancelCtx.Err() == nil { + t.Fatal("cancellation did not close its client and context") + } + client.pendingMu.Lock() + pending := len(client.pending) + client.pendingMu.Unlock() + if pending != 0 { + t.Fatal("blocked cancellation left pending requests") + } + if concurrent { + select { + case <-writeDone: + if writeErr == nil { + t.Fatal("partial concurrent write reported success") + } + case <-time.After(time.Second): + t.Fatal("blocked concurrent writer was not released") + } + } + replied := make(chan error, 1) + go func() { + _, err := SendCannedResponse(t.Context(), peerServer, "alive") + replied <- err + }() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result, err := peer.Request(ctx, "echo", nil) + if err != nil || string(result) != `"alive"` || !peer.Alive() { + t.Fatal("cancellation affected an independent client", err) + } + if err := <-replied; err != nil { + t.Fatal(err) + } + }) + } +} + +func readControlPrefix(t *testing.T, reader io.Reader) { + t.Helper() + read := make(chan error, 1) + go func() { + var prefix [1]byte + _, err := io.ReadFull(reader, prefix[:]) + read <- err + }() + select { + case err := <-read: + if err != nil { + t.Fatal("control write did not start", err) + } + case <-time.After(4 * time.Second): + t.Fatal("control write did not enter the pipe") + } +} + +func TestCancelReleasesBlockedNativeProcess(t *testing.T) { + client := NewJSONRPCClient(JSONRPCConfig{ + Binary: os.Args[0], ExtraArgs: []string{"-test.run=TestJSONRPCClientFakeCodexProcess", "--"}, + Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "CODEX_RPC_FAKE_BLOCK_WRITE=1", "GORACE=atexit_sleep_ms=0"), + LogTag: "cancel-blocked-process", RequestTimeout: 2 * time.Second, + }) + t.Cleanup(func() { _ = client.Close() }) + blocked := make(chan struct{}) + client.OnNotification("test/write_blocked", func(json.RawMessage) { close(blocked) }) + if _, err := client.Start(t.Context(), InitializeParams{ClientInfo: InitializeClientInfo{Name: "test", Version: "0"}}); err != nil { + t.Fatal(err) + } + written := make(chan error, 1) + go func() { written <- client.Notify("blocked", strings.Repeat("x", 1<<20)) }() + t.Cleanup(func() { + _ = client.Close() + select { + case <-written: + case <-time.After(4 * time.Second): + t.Error("native pipe writer did not stop during cleanup") + } + }) + select { + case <-blocked: + case <-time.After(4 * time.Second): + t.Fatal("native child did not stop reading the oversized frame") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &Session{rpc: client, cancelCtx: ctx, cancelFn: cancel, + cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), bufs: NewItemBuffers()} + s.setThreadID("native-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + cancelDone := make(chan struct{}) + go func() { _ = s.Cancel(context.Background()); close(cancelDone) }() + t.Cleanup(func() { + _ = client.Close() + select { + case <-cancelDone: + case <-time.After(4 * time.Second): + t.Error("native cancellation did not stop during cleanup") + } + }) + select { + case <-cancelDone: + case <-time.After(4 * time.Second): + t.Fatal("blocked native stdin prevented cancellation cleanup") + } + select { + case <-client.Done(): + case <-time.After(time.Second): + t.Fatal("cancelled native child was not reaped") + } + if client.Alive() || client.cmd.ProcessState == nil || ctx.Err() == nil { + t.Fatal("native process or cancellation context remained active") + } + // The cleanup consumes the writer's result after proving it was released. + select { + case err := <-written: + written <- err + if err == nil { + t.Fatal("undrained native frame reported success") + } + case <-time.After(time.Second): + t.Fatal("cancelled native writer remained blocked") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_command_output.go b/apps/parsar-daemon/internal/agent/codex/session_command_output.go new file mode 100644 index 000000000..e48de64cd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_command_output.go @@ -0,0 +1,21 @@ +package codex + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) onCommandOutput(raw json.RawMessage) { + if !s.observeToolObservations { + return + } + var p AgentMessageDeltaNotification + if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.TurnID) || p.ItemID == "" || p.Delta == "" { + return + } + env, err := proto.NewEnvelope(proto.TypeCommandOutput, s.runID, proto.CommandOutputPayload{ID: p.ItemID, Delta: p.Delta}) + if err == nil { + s.trySend(env) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_command_output_test.go b/apps/parsar-daemon/internal/agent/codex/session_command_output_test.go new file mode 100644 index 000000000..4524408fb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_command_output_test.go @@ -0,0 +1,49 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCommandOutputRequiresOptInAndRootTurn(t *testing.T) { + for _, enabled := range []bool{false, true} { + out := make(chan proto.Envelope, 10) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{}), observeToolObservations: enabled} + s.registerHandlers() + s.setThreadID("root") + s.beginRootTurn("root", "turn") + for _, raw := range []string{ + `{"threadId":"child","turnId":"turn","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"old","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"turn","delta":"missing identity"}`, + `{"threadId":"root","turnId":"turn","itemId":"cmd","delta":null}`, + `{"threadId":42}`, `{}`, + } { + scopeNotification(t, s, "item/commandExecution/outputDelta", raw) + } + if len(out) != 0 { + t.Fatal("invalid output reached root") + } + for _, fragment := range []string{"same\n", "same\n", "结束\n"} { + raw, _ := json.Marshal(map[string]string{"threadId": "root", "turnId": "turn", "itemId": "cmd", "delta": fragment}) + scopeNotification(t, s, "item/commandExecution/outputDelta", string(raw)) + if !enabled { + if len(out) != 0 { + t.Fatal("product frame sequence changed") + } + continue + } + if len(out) != 1 { + t.Fatal("missing registered command notification") + } + env := <-out + var p proto.CommandOutputPayload + if env.DecodePayload(&p) != nil || env.Type != proto.TypeCommandOutput || env.ID != "run" || p.ID != "cmd" || p.Delta != fragment { + t.Fatal("command fragment changed", env) + } + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_items.go b/apps/parsar-daemon/internal/agent/codex/session_items.go new file mode 100644 index 000000000..140816eb3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_items.go @@ -0,0 +1,234 @@ +package codex + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// ItemBuffers holds the per-itemId text accumulators used to fold +// delta notifications back into a single chunk on item/completed. +// Codex streams reasoning and agentMessage in two channels — the +// {reasoning,agentMessage}/delta notifications, plus the final item +// body on item/completed. We keep both: deltas drive incremental UI +// (TypeDelta / TypeThinking), and completed-item bodies anchor the +// final text for the done event. +type ItemBuffers struct { + Reasoning map[string]string + AgentText map[string]string +} + +// NewItemBuffers returns an empty buffer set. +func NewItemBuffers() *ItemBuffers { + return &ItemBuffers{ + Reasoning: map[string]string{}, + AgentText: map[string]string{}, + } +} + +// DispatchStartedItem maps the item.started variants we care about +// (tool_call surfaces) into proto envelopes. The function never returns +// terminal envelopes; per Codex, only item/completed + turn/completed +// can finish a turn. +// +// item types we silence by intent: +// +// - reasoning / agentMessage / userMessage: text is streamed via deltas +// and re-stamped in item/completed +// - error: surfaced by turn/completed.status="failed" instead +func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error) { + switch item.Type { + case "commandExecution": + return wrapToolCall(runID, item.ID, "Bash", map[string]any{ + "command": item.Command, + "cwd": item.Cwd, + }) + case "fileChange": + return wrapToolCall(runID, item.ID, "Edit", map[string]any{ + "changes": item.Changes, + }) + case "mcpToolCall": + name := fmt.Sprintf("mcp__%s__%s", item.Server, item.Tool) + return wrapToolCall(runID, item.ID, name, argMap(item.Arguments)) + case "dynamicToolCall": + // dynamicToolCall has no server; tool name is namespaced via + // item.Namespace when present. + name := item.Tool + if item.Namespace != "" { + name = item.Namespace + "::" + item.Tool + } + return wrapToolCall(runID, item.ID, name, argMap(item.Arguments)) + case "webSearch": + return wrapToolCall(runID, item.ID, "WebSearch", map[string]any{ + "query": item.Query, + }) + } + return nil, nil +} + +// DispatchCompletedItem folds an item.completed payload into envelopes. +// Reasoning and agentMessage produce a final TypeThinking / TypeDelta +// (delta+sequence=0) so the buffer drained by upstream deltas can be +// flushed; tool-call variants produce an "after" envelope so the UI +// gets a stage transition. +// +// emitFinalDelta=true asks the dispatch to emit a synthetic full-text +// TypeDelta whose Sequence will be set by the caller using a session- +// level monotonic counter; this is only needed when no deltas were +// observed (item.completed arrived without any item/agentMessage/delta). +// +// Returns the agent text body for agentMessage items so the session can +// stamp it into DonePayload.Content. Empty string for everything else. +func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (envelopes []proto.Envelope, agentText string, err error) { + switch item.Type { + case "reasoning": + body := bufs.Reasoning[item.ID] + delete(bufs.Reasoning, item.ID) + if body == "" { + body = fallbackReasoningText(item) + } + if body == "" { + return nil, "", nil + } + env, err := proto.NewEnvelope(proto.TypeThinking, runID, proto.ThinkingPayload{Text: body}) + if err != nil { + return nil, "", fmt.Errorf("codex: thinking envelope: %w", err) + } + return []proto.Envelope{env}, "", nil + case "agentMessage": + body := bufs.AgentText[item.ID] + delete(bufs.AgentText, item.ID) + if body == "" { + body = item.Text + } + return nil, body, nil + case "commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch": + // Surface a tool_call "after" so the UI flips status. + name := toolNameForItem(item) + result := map[string]any{} + if item.ExitCode != nil { + result["exit_code"] = *item.ExitCode + } + if item.Status != "" { + result["status"] = item.Status + } + envs, err := wrapToolCallStage(runID, item.ID, name, nil, result, "after") + if err != nil { + return nil, "", err + } + return envs, "", nil + } + return nil, "", nil +} + +// FoldDeltaIntoBuffer accumulates a delta string under the matching +// itemId. Returns the running prefix so the caller can emit a +// progressive TypeDelta / TypeThinking envelope for streaming UI. +func FoldDeltaIntoBuffer(bufs *ItemBuffers, kind, itemID, delta string) string { + if delta == "" || itemID == "" { + return "" + } + switch kind { + case "agent": + bufs.AgentText[itemID] += delta + return bufs.AgentText[itemID] + case "reasoning": + bufs.Reasoning[itemID] += delta + return bufs.Reasoning[itemID] + } + return "" +} + +func toolNameForItem(item ThreadItem) string { + switch item.Type { + case "commandExecution": + return "Bash" + case "fileChange": + return "Edit" + case "mcpToolCall": + return fmt.Sprintf("mcp__%s__%s", item.Server, item.Tool) + case "dynamicToolCall": + if item.Namespace != "" { + return item.Namespace + "::" + item.Tool + } + return item.Tool + case "webSearch": + return "WebSearch" + } + return item.Type +} + +func wrapToolCall(runID, id, name string, args map[string]any) ([]proto.Envelope, error) { + return wrapToolCallStage(runID, id, name, args, nil, "before") +} + +func wrapToolCallStage(runID, id, name string, args, result map[string]any, stage string) ([]proto.Envelope, error) { + env, err := proto.NewEnvelope(proto.TypeToolCall, runID, proto.ToolCallPayload{ + ID: id, + Name: name, + Stage: stage, + Args: args, + Result: result, + }) + if err != nil { + return nil, fmt.Errorf("codex: tool_call envelope: %w", err) + } + return []proto.Envelope{env}, nil +} + +// argMap normalises ThreadItem.Arguments — which arrives as +// any (typically map[string]any decoded from JSON) — into a +// map suitable for ToolCallPayload.Args. Non-object args are wrapped +// under "value" to avoid silently dropping arrays / strings. +func argMap(v any) map[string]any { + if v == nil { + return nil + } + if m, ok := v.(map[string]any); ok { + return m + } + // Round-trip through JSON for any unexpected shape so the wire + // payload stays structured. Falls back to {"value": } when + // it's not an object. + raw, err := json.Marshal(v) + if err != nil { + return map[string]any{"value": fmt.Sprintf("%v", v)} + } + var obj map[string]any + if json.Unmarshal(raw, &obj) == nil { + return obj + } + return map[string]any{"value": json.RawMessage(raw)} +} + +func fallbackReasoningText(item ThreadItem) string { + if item.Text != "" { + return item.Text + } + if item.SummaryText != "" { + return item.SummaryText + } + if len(item.Summary) > 0 { + var b strings.Builder + for i, s := range item.Summary { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(s) + } + return b.String() + } + if len(item.Content) > 0 { + var b strings.Builder + for i, s := range item.Content { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(s) + } + return b.String() + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_items_test.go b/apps/parsar-daemon/internal/agent/codex/session_items_test.go new file mode 100644 index 000000000..500653e1c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_items_test.go @@ -0,0 +1,194 @@ +package codex + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestDispatchStartedItem_Bash(t *testing.T) { + envs, err := DispatchStartedItem("run-1", ThreadItem{ + Type: "commandExecution", ID: "c1", Command: "ls /tmp", Cwd: "/tmp", + }) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(envs) != 1 { + t.Fatalf("len envs = %d, want 1", len(envs)) + } + if envs[0].Type != proto.TypeToolCall { + t.Fatalf("type = %s, want tool_call", envs[0].Type) + } + var p proto.ToolCallPayload + if err := envs[0].DecodePayload(&p); err != nil { + t.Fatalf("decode: %v", err) + } + if p.Name != "Bash" || p.Stage != "before" { + t.Fatalf("payload = %+v", p) + } + if cmd, _ := p.Args["command"].(string); cmd != "ls /tmp" { + t.Fatalf("args.command = %v", p.Args["command"]) + } +} + +func TestDispatchStartedItem_McpToolCall(t *testing.T) { + envs, err := DispatchStartedItem("run-1", ThreadItem{ + Type: "mcpToolCall", ID: "m1", Server: "docs", Tool: "search", + Arguments: map[string]any{"q": "hello"}, + }) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(envs) != 1 { + t.Fatalf("len envs = %d", len(envs)) + } + var p proto.ToolCallPayload + if err := envs[0].DecodePayload(&p); err != nil { + t.Fatalf("decode: %v", err) + } + if p.Name != "mcp__docs__search" { + t.Fatalf("tool name = %q", p.Name) + } + if got, _ := p.Args["q"].(string); got != "hello" { + t.Fatalf("args.q = %v", p.Args["q"]) + } +} + +func TestDispatchStartedItem_UnknownSilent(t *testing.T) { + envs, err := DispatchStartedItem("run-1", ThreadItem{Type: "userMessage", ID: "u1"}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if envs != nil { + t.Fatalf("unknown / silent type must produce no envelopes, got %d", len(envs)) + } +} + +func TestDispatchCompletedItem_Reasoning_UsesBuffer(t *testing.T) { + bufs := NewItemBuffers() + FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "Hello ") + FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "world") + + envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ + Type: "reasoning", ID: "r1", + }, bufs) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if text != "" { + t.Fatalf("reasoning must not produce agent text, got %q", text) + } + if len(envs) != 1 || envs[0].Type != proto.TypeThinking { + t.Fatalf("envs = %+v", envs) + } + var p proto.ThinkingPayload + _ = envs[0].DecodePayload(&p) + if p.Text != "Hello world" { + t.Fatalf("thinking text = %q", p.Text) + } + // Buffer must be drained so a re-completion doesn't double-emit. + if got := bufs.Reasoning["r1"]; got != "" { + t.Fatalf("buffer not drained: %q", got) + } +} + +func TestDispatchCompletedItem_Reasoning_FallbackToItemBody(t *testing.T) { + // No deltas observed — must fall back to item.text / summary. + bufs := NewItemBuffers() + envs, _, err := DispatchCompletedItem("run-1", ThreadItem{ + Type: "reasoning", ID: "r1", Text: "fallback body", + }, bufs) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var p proto.ThinkingPayload + _ = envs[0].DecodePayload(&p) + if p.Text != "fallback body" { + t.Fatalf("fallback path = %q", p.Text) + } +} + +func TestDispatchCompletedItem_AgentMessage_BufferIsFinalText(t *testing.T) { + bufs := NewItemBuffers() + FoldDeltaIntoBuffer(bufs, "agent", "a1", "Hello ") + FoldDeltaIntoBuffer(bufs, "agent", "a1", "world") + + envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ + Type: "agentMessage", ID: "a1", Text: "fallback", + }, bufs) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(envs) != 0 { + t.Fatalf("agentMessage must not produce envelopes (final text emits via Done), got %+v", envs) + } + if text != "Hello world" { + // The buffered concatenation must win over item.Text fallback + // when deltas were observed. + t.Fatalf("agent text = %q, want buffered concatenation", text) + } +} + +func TestDispatchCompletedItem_AgentMessage_FallbackToItemText(t *testing.T) { + // No deltas — agent text comes from item.text directly. + bufs := NewItemBuffers() + _, text, err := DispatchCompletedItem("run-1", ThreadItem{ + Type: "agentMessage", ID: "a1", Text: "no-delta body", + }, bufs) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if text != "no-delta body" { + t.Fatalf("fallback path = %q", text) + } +} + +func TestDispatchCompletedItem_ToolCallEmitsAfterStage(t *testing.T) { + exit := 0 + envs, _, err := DispatchCompletedItem("run-1", ThreadItem{ + Type: "commandExecution", ID: "c1", Status: "completed", ExitCode: &exit, + }, NewItemBuffers()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(envs) != 1 || envs[0].Type != proto.TypeToolCall { + t.Fatalf("envs = %+v", envs) + } + var p proto.ToolCallPayload + _ = envs[0].DecodePayload(&p) + if p.Stage != "after" { + t.Fatalf("stage = %q, want after", p.Stage) + } + if status, _ := p.Result["status"].(string); status != "completed" { + t.Fatalf("result.status = %v", p.Result["status"]) + } +} + +func TestFoldDeltaIntoBuffer_AccumulatesPerItem(t *testing.T) { + bufs := NewItemBuffers() + if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", "hi"); got != "hi" { + t.Fatalf("fold returned %q", got) + } + if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", " there"); got != "hi there" { + t.Fatalf("fold accumulation = %q", got) + } + // Different itemId is a different accumulator. + if got := FoldDeltaIntoBuffer(bufs, "agent", "a2", "other"); got != "other" { + t.Fatalf("cross-item bleed: got %q", got) + } +} + +func TestArgMap_NonObjectValueWrapped(t *testing.T) { + // dynamicToolCall.arguments arrives as any; arrays must not be + // silently dropped. + m := argMap([]any{"a", "b"}) + if _, ok := m["value"]; !ok { + t.Fatalf("non-object args must surface under value: %+v", m) + } + raw, _ := json.Marshal(m["value"]) + if !strings.Contains(string(raw), `"a"`) || !strings.Contains(string(raw), `"b"`) { + t.Fatalf("wrapped value lost array data: %s", raw) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go b/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go new file mode 100644 index 000000000..02c8a71b9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go @@ -0,0 +1,42 @@ +package codex + +import ( + "context" + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "testing" + "time" +) + +func TestResumeRefreshesReferenceContext(t *testing.T) { + for _, prompt := range []string{"updated knowledge reference", ""} { + t.Run(prompt, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "knowledge-test")}} + done := make(chan error, 1) + go func() { done <- s.resumeThread("same-thread", SessionPlan{SystemPrompt: prompt}) }() + var request struct { + ID string `json:"id"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if value, ok := request.Params["developerInstructions"]; !ok || value != prompt { + t.Fatal("resume did not replace the old instructions") + } + if request.Params["threadId"] != "same-thread" { + t.Fatal("lost history") + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]string{"id": "same-thread"}}}); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_log_test.go b/apps/parsar-daemon/internal/agent/codex/session_log_test.go new file mode 100644 index 000000000..5c307ac4d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_log_test.go @@ -0,0 +1,256 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestEmitTerminal_LogsErrorMessage pins the diagnostic invariant: +// every time the codex session decides "this prompt is over with an +// error", the error message MUST land in the daemon's structured log. +// +// Before this fix, emitTerminal silently sent a TypeError envelope to +// the upstream channel. If the upstream connector logged only the +// envelope type (not the body), debugging required guessing what the +// session had decided to surface. With the fix the daemon log carries +// the exact message string, the run_id, and the thread_id at the same +// timestamp the TypeError frame was emitted. +func TestEmitTerminal_LogsErrorMessage(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + out := make(chan proto.Envelope, 4) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Session{ + runID: "run-test-123", + cfg: sessionConfig{logger: logger}, + out: out, + cancelCtx: ctx, + } + s.setThreadID("thread-abc") + + const message = "codex: thread/start: bad provider config" + s.emitTerminal(message, true) + + logs := buf.String() + for _, want := range []string{ + `"msg":"codex: emitting terminal error"`, + `"run_id":"run-test-123"`, + `"thread_id":"thread-abc"`, + `"message":"` + message + `"`, + `"level":"WARN"`, + } { + if !strings.Contains(logs, want) { + t.Errorf("log missing %q\n--- log ---\n%s", want, logs) + } + } + + // Side-effects on out channel: TypeError + TypeDone. + got := drainEnvelopes(out) + if len(got) != 2 { + t.Fatalf("envelope count = %d, want 2 (error + done); got=%+v", len(got), got) + } + if got[0].Type != proto.TypeError { + t.Fatalf("first envelope type = %q, want error", got[0].Type) + } + var errPayload proto.ErrorPayload + _ = json.Unmarshal(got[0].Payload, &errPayload) + if errPayload.Error != message { + t.Fatalf("error payload = %q, want %q", errPayload.Error, message) + } + if got[1].Type != proto.TypeDone { + t.Fatalf("second envelope type = %q, want done", got[1].Type) + } +} + +// TestEmitTerminal_LogsDoneMessage covers the success-path log so a +// future change that flips asError=false on a real prompt completion +// still leaves a trace in the daemon log. +func TestEmitTerminal_LogsDoneMessage(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + out := make(chan proto.Envelope, 4) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Session{ + runID: "run-test-456", + cfg: sessionConfig{logger: logger}, + out: out, + cancelCtx: ctx, + } + + s.emitTerminal("hello world", false) + + logs := buf.String() + for _, want := range []string{ + `"msg":"codex: emitting terminal done"`, + `"run_id":"run-test-456"`, + `"message_len":11`, + } { + if !strings.Contains(logs, want) { + t.Errorf("log missing %q\n--- log ---\n%s", want, logs) + } + } + // asError=false: only TypeDone, no TypeError. + got := drainEnvelopes(out) + if len(got) != 1 || got[0].Type != proto.TypeDone { + t.Fatalf("envelopes = %+v, want exactly 1 done", got) + } +} + +// TestOnErrorNotif_LogsAndBuffers verifies the codex `error` +// notification path stays inspectable. Codex's gateway / provider +// failures (a 401 from a misconfigured custom header, a 400 from +// Azure missing api-version) arrive on this single channel; without +// the log, the daemon shows ~13s of silence and then a TypeError +// envelope the upstream connector can't decode. +func TestOnErrorNotif_LogsAndBuffers(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + s := &Session{runID: "run-x", cfg: sessionConfig{logger: logger}} + s.setThreadID("thread-y") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread-y","turn":{"id":"turn"}}`)) + + raw, _ := json.Marshal(ErrorNotification{ThreadID: "thread-y", TurnID: "turn", Message: "401 from gateway: invalid X-Sub-Module header"}) + s.onErrorNotif(raw) + + if got := s.peekLastErrText(); !strings.Contains(got, "401") { + t.Fatalf("lastErrText not buffered: %q", got) + } + logs := buf.String() + for _, want := range []string{ + `"msg":"codex: error notification received"`, + `"thread_id":"thread-y"`, + `401 from gateway`, + } { + if !strings.Contains(logs, want) { + t.Errorf("log missing %q\n--- log ---\n%s", want, logs) + } + } +} + +// TestOnTurnFailed_LogsBufferedError pins that turn/failed includes +// "we already have buffered error text" in its log line. If onErrorNotif +// fired first and stashed the real cause, the post-mortem can correlate +// the two events by run_id alone. +func TestOnTurnFailed_LogsBufferedError(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + out := make(chan proto.Envelope, 4) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Session{ + runID: "run-z", + cfg: sessionConfig{logger: logger}, + out: out, + cancelCtx: ctx, + } + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"t-1"}}`)) + // Simulate codex sending the real error first… + rawErr, _ := json.Marshal(ErrorNotification{ThreadID: "thread", TurnID: "t-1", Message: "platform-api 500"}) + s.onErrorNotif(rawErr) + // …then turn/failed. + rawFail, _ := json.Marshal(TurnCompletedNotification{ThreadID: "thread", Turn: Turn{ID: "t-1", Status: "failed"}}) + s.onTurnFailed(rawFail) + + logs := buf.String() + if !strings.Contains(logs, `"msg":"codex: turn/failed received"`) { + t.Fatalf("turn/failed log missing\n%s", logs) + } + if !strings.Contains(logs, `"last_err_text_present":true`) { + t.Fatalf("turn/failed log must note that an upstream error was buffered\n%s", logs) + } +} + +func TestTerminalTurnKeepsRPCAliveUntilSessionCancel(t *testing.T) { + tests := []struct { + name string + run func(*Session) + }{ + { + name: "completed", + run: func(s *Session) { + raw, _ := json.Marshal(TurnCompletedNotification{ + ThreadID: "thread-completed", + Turn: Turn{ID: "turn-1", Status: "completed"}, + }) + s.onTurnCompleted(raw) + }, + }, + { + name: "failed", + run: func(s *Session) { + raw, _ := json.Marshal(TurnCompletedNotification{ + ThreadID: "thread-failed", + Turn: Turn{ID: "turn-2", Status: "failed"}, + }) + s.onTurnFailed(raw) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rpc, _, cleanup := NewTestClient() + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + out := make(chan proto.Envelope, 4) + s := &Session{ + runID: "run-terminal-" + tt.name, + cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, + out: out, + rpc: rpc.JSONRPCClient, + cancelCtx: ctx, + cancelFn: cancel, + } + s.setThreadID("thread-" + tt.name) + turnID := "turn-1" + if tt.name == "failed" { + turnID = "turn-2" + } + start, _ := json.Marshal(TurnStartedNotification{ThreadID: s.currentThreadID(), Turn: Turn{ID: turnID}}) + s.onTurnStarted(start) + + tt.run(s) + + if !rpc.Alive() { + t.Fatal("terminal turn must keep the codex RPC client alive during the idle window") + } + select { + case <-ctx.Done(): + t.Fatal("terminal turn must not cancel the session context") + default: + } + + }) + } +} + +func drainEnvelopes(out <-chan proto.Envelope) []proto.Envelope { + var got []proto.Envelope + for { + select { + case env := <-out: + got = append(got, env) + default: + return got + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_messages.go b/apps/parsar-daemon/internal/agent/codex/session_messages.go new file mode 100644 index 000000000..4c73f7d71 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_messages.go @@ -0,0 +1,80 @@ +package codex + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) onAgentDelta(raw json.RawMessage) { + var p AgentMessageDeltaNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { + return + } + _ = FoldDeltaIntoBuffer(s.bufs, "agent", p.ItemID, p.Delta) + seq := s.deltaSeq.Add(1) + payload := proto.DeltaPayload{Delta: p.Delta, Sequence: seq} + if s.observeMessages { + payload.ItemID = p.ItemID + } + env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, payload) + if err != nil { + return + } + s.trySend(env) +} + +func (s *Session) onItemStarted(raw json.RawMessage) { + var p ItemStartedNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Item.ID == "" { + return + } + s.observeMessage(p.Item, "in_progress", nil) + envs, err := DispatchStartedItem(s.runID, p.Item) + if err != nil { + s.cfg.logger.Warn("codex: dispatch started item failed", "run_id", s.runID, "err", err) + return + } + s.sendItemEvents(envs, raw) +} + +func (s *Session) onItemCompleted(raw json.RawMessage) { + s.observeSubagentIdentity(raw) + var p ItemCompletedNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Item.ID == "" { + return + } + envs, text, err := DispatchCompletedItem(s.runID, p.Item, s.bufs) + if err != nil { + s.cfg.logger.Warn("codex: dispatch completed item failed", "run_id", s.runID, "err", err) + return + } + s.sendItemEvents(envs, raw) + messageText := p.Item.Text + if messageText == "" { + messageText = text + } + s.observeMessage(p.Item, "completed", &messageText) + if text != "" { + s.appendFinalText(text) + } +} + +func (s *Session) observeMessage(item ThreadItem, status string, text *string) { + if !s.observeMessages || item.Type != "agentMessage" { + return + } + env, err := proto.NewEnvelope(proto.TypeOutputMessage, s.runID, proto.OutputMessagePayload{ID: item.ID, Status: status, Phase: item.Phase, Text: text}) + if err == nil { + s.trySend(env) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_messages_test.go b/apps/parsar-daemon/internal/agent/codex/session_messages_test.go new file mode 100644 index 000000000..dbb7d1e49 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_messages_test.go @@ -0,0 +1,72 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMessageObservationIsOptInAndKeepsNativeBoundaries(t *testing.T) { + for _, enabled := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "observed"}[enabled], func(t *testing.T) { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", observeMessages: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"a","delta":"first"}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"b","phase":"final_answer"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"b","delta":"second"}`)) + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary","text":"first complete"}}`)) + // b stays unfinished, as when the native request is cancelled before item/completed. + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"c","phase":"final_answer","text":"without deltas"}}`)) + var deltas []proto.DeltaPayload + var messages []proto.OutputMessagePayload + for len(out) > 0 { + env := <-out + switch env.Type { + case proto.TypeDelta: + var p proto.DeltaPayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + deltas = append(deltas, p) + case proto.TypeOutputMessage: + var p proto.OutputMessagePayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + messages = append(messages, p) + default: + t.Fatalf("unexpected frame: %s", env.Type) + } + } + if len(deltas) != 2 || deltas[0].Delta != "first" || deltas[1].Delta != "second" || deltas[1].Sequence != 2 { + t.Fatalf("legacy text changed: %+v", deltas) + } + if !enabled { + if len(messages) != 0 || deltas[0].ItemID != "" || deltas[1].ItemID != "" { + t.Fatal("legacy request gained observations") + } + return + } + if deltas[0].ItemID != "a" || deltas[1].ItemID != "b" || len(messages) != 4 { + t.Fatalf("identity lost: %+v %+v", deltas, messages) + } + if messages[0].ID != "a" || messages[0].Phase != "commentary" || messages[0].Status != "in_progress" || messages[0].Text != nil { + t.Fatalf("start: %+v", messages[0]) + } + if messages[1].ID != "b" || messages[1].Phase != "final_answer" || messages[1].Status != "in_progress" { + t.Fatalf("second start: %+v", messages[1]) + } + if messages[2].ID != "a" || messages[2].Status != "completed" || messages[2].Text == nil || *messages[2].Text != "first complete" { + t.Fatalf("completion: %+v", messages[2]) + } + if messages[3].ID != "c" || messages[3].Text == nil || *messages[3].Text != "without deltas" { + t.Fatalf("non-streamed message lost: %+v", messages[3]) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications.go b/apps/parsar-daemon/internal/agent/codex/session_notifications.go new file mode 100644 index 000000000..8818d5aca --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_notifications.go @@ -0,0 +1,48 @@ +package codex + +import ( + "encoding/json" + "fmt" +) + +func (s *Session) isRootThread(threadID string) bool { + return threadID != "" && threadID == s.currentThreadID() +} + +func (s *Session) isRootTurn(threadID, turnID string) bool { + if !s.isRootThread(threadID) || turnID == "" || s.terminal.Load() { + return false + } + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return turnID == s.steering.id +} + +func (s *Session) beginRootTurn(threadID, turnID string) { + if !s.startSteering(threadID, turnID) { + return + } + s.beginUsageTurn(turnID) + s.bufs = NewItemBuffers() +} + +func (s *Session) bindTurnResult(raw json.RawMessage) error { + var res struct { + Turn Turn `json:"turn"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return fmt.Errorf("codex: decode turn response: %w", err) + } + if res.Turn.ID == "" { + return fmt.Errorf("codex: turn response has missing identity") + } + s.steering.mu.Lock() + turnID := s.steering.id + s.steering.mu.Unlock() + if turnID != "" && turnID != res.Turn.ID { + return fmt.Errorf("codex: turn response does not match the active turn") + } + // Native turn/started can precede its RPC reply; duplicate starts retain buffers and usage. + s.beginRootTurn(s.currentThreadID(), res.Turn.ID) + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go b/apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go new file mode 100644 index 000000000..147985ca8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go @@ -0,0 +1,166 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestRootRPCNotificationOrdering(t *testing.T) { + for _, resume := range []bool{false, true} { + for _, notificationFirst := range []bool{false, true} { + name := map[bool]string{false: "start", true: "resume"}[resume] + "/" + map[bool]string{false: "reply first", true: "notification first"}[notificationFirst] + t.Run(name, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, rpc: client.JSONRPCClient, cancelCtx: ctx, bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.registerHandlers() + barrier := make(chan struct{}, 1) + client.OnNotification("test/barrier", func(json.RawMessage) { barrier <- struct{}{} }) + notify := func(method, params string) { + t.Helper() + if err := SendNotification(server, method, json.RawMessage(params)); err != nil { + t.Fatal(err) + } + } + reply := func(id string, result string) { + t.Helper() + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": id, "result": json.RawMessage(result)}); err != nil { + t.Fatal(err) + } + } + waitResult := func(result <-chan error) { + t.Helper() + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("RPC did not finish") + } + } + result := make(chan error, 1) + go func() { + if resume { + result <- s.resumeThread("root", SessionPlan{}) + } else { + result <- s.startThread(SessionPlan{}) + } + }() + decoder := json.NewDecoder(server.FromClient) + var request JsonRpcRequest + if err := decoder.Decode(&request); err != nil { + t.Fatal(err) + } + notify("thread/started", `{"thread":{"id":"unrelated","sessionId":"root"}}`) + notify("thread/tokenUsage/updated", `{"threadId":"unrelated","turnId":"previous","tokenUsage":{"total":{"inputTokens":999}}}`) + baseline := `{"threadId":"root","turnId":"previous","tokenUsage":{"total":{"inputTokens":100,"cachedInputTokens":20,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":110}}}` + if resume && notificationFirst { + notify("thread/tokenUsage/updated", baseline) + } + reply(request.ID, `{"thread":{"id":"root"},"model":"test-model"}`) + if resume && !notificationFirst { + notify("thread/tokenUsage/updated", baseline) + } + notify("thread/started", `{"thread":{"id":"child"}}`) + notify("test/barrier", `{}`) + <-barrier + waitResult(result) + if s.currentThreadID() != "root" || (resume && s.usageTotal.InputTokens != 100) { + t.Fatalf("root/resume baseline raced reply delivery: thread=%q usage=%+v", s.currentThreadID(), s.usageTotal) + } + go func() { + _, err := client.requestWithResult(ctx, "turn/start", TurnStartParams{ThreadID: "root"}, s.bindTurnResult) + result <- err + }() + if err := decoder.Decode(&request); err != nil { + t.Fatal(err) + } + if !notificationFirst { + reply(request.ID, `{"turn":{"id":"current"}}`) + } + notify("turn/started", `{"threadId":"root","turn":{"id":"current"}}`) + notify("item/agentMessage/delta", `{"threadId":"root","turnId":"current","itemId":"message","delta":"root answer"}`) + notify("thread/tokenUsage/updated", `{"threadId":"root","turnId":"current","tokenUsage":{"total":{"inputTokens":130,"cachedInputTokens":25,"outputTokens":20,"reasoningOutputTokens":3,"totalTokens":150}}}`) + notify("turn/started", `{"threadId":"child","turn":{"id":"child-turn"}}`) + notify("turn/completed", `{"threadId":"child","turn":{"id":"child-turn","status":"completed"}}`) + notify("item/completed", `{"threadId":"root","turnId":"current","item":{"type":"agentMessage","id":"message"}}`) + notify("turn/completed", `{"threadId":"root","turn":{"id":"current","status":"completed"}}`) + if notificationFirst { + reply(request.ID, `{"turn":{"id":"current"}}`) + } + notify("test/barrier", `{}`) + <-barrier + waitResult(result) + var doneCount int + for env := range out { + if env.Type != proto.TypeDone { + continue + } + doneCount++ + var done proto.DonePayload + if err := env.DecodePayload(&done); err != nil { + t.Fatal(err) + } + wantInput := int32(130) + if resume { + wantInput = 30 + } + if done.Content != "root answer" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.InputTokens != wantInput { + t.Fatalf("reply ordering lost root output/usage: %+v", done) + } + } + if doneCount != 1 { + t.Fatalf("root completion count = %d", doneCount) + } + }) + } + } +} + +func TestThreadRPCResponseRequiresRootIdentity(t *testing.T) { + for _, raw := range []string{`{}`, `{"thread":{"id":""}}`, `{"thread":{"sessionId":"root"}}`, `{"thread":{"id":42}}`, `{"thread":{"id":"other"}}`} { + t.Run(raw, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: defaultSessionConfig()} + s.registerHandlers() + result := make(chan error, 1) + go func() { result <- s.resumeThread("root", SessionPlan{}) }() + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if err := SendNotification(server, "thread/tokenUsage/updated", json.RawMessage(`{"threadId":"root","turnId":"old","tokenUsage":{"total":{"inputTokens":999}}}`)); err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": json.RawMessage(raw)}); err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err == nil || s.currentThreadID() != "" || s.usageTotal.InputTokens != 0 || s.resumeUsageTotal != nil { + t.Fatalf("invalid reply acquired root identity or usage: err=%v thread=%q usage=%+v", err, s.currentThreadID(), s.usageTotal) + } + case <-ctx.Done(): + t.Fatal("invalid reply did not settle the RPC") + } + }) + } + s := &Session{} + if err := s.bindThreadResult(json.RawMessage(`{"thread":{"id":"root"}}`), "root"); err != nil { + t.Fatal(err) + } + if err := s.bindThreadResult(json.RawMessage(`{"thread":{"id":"other"}}`), ""); err == nil || s.currentThreadID() != "root" { + t.Fatal("later reply replaced root") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go b/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go new file mode 100644 index 000000000..314d4b722 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go @@ -0,0 +1,183 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestRootNotificationIsolation(t *testing.T) { + for _, childStarted := range []bool{false, true} { + t.Run(map[bool]string{false: "child without thread started", true: "child thread started"}[childStarted], func(t *testing.T) { + out := make(chan proto.Envelope, 64) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true, + observeTools: true, observeToolObservations: true} + s.registerHandlers() + s.setThreadID("root") + notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } + notify("thread/tokenUsage/updated", `{"threadId":"root","turnId":"previous","tokenUsage":{"total":{"inputTokens":100,"cachedInputTokens":20,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":110}}}`) + notify("turn/started", `{"threadId":"root","turn":{"id":"root-turn"}}`) + notify("item/agentMessage/delta", `{"threadId":"root","turnId":"root-turn","itemId":"shared","delta":"root "}`) + notify("item/reasoning/textDelta", `{"threadId":"root","turnId":"root-turn","itemId":"thought","delta":"root thought"}`) + notify("error", `{"threadId":"root","turnId":"root-turn","message":"root retry"}`) + notify("thread/tokenUsage/updated", `{"threadId":"root","turnId":"root-turn","tokenUsage":{"total":{"inputTokens":120,"cachedInputTokens":25,"outputTokens":15,"reasoningOutputTokens":3,"totalTokens":135}}}`) + rootEvents := len(out) + if childStarted { + notify("thread/started", `{"thread":{"id":"child"}}`) + } + for _, coord := range []struct{ threadID, turnID string }{ + {"child", "child-turn"}, {"unrelated", "root-turn"}, {"root", "stale-turn"}, {"", "root-turn"}, {"root", ""}, + } { + params := func(tail string) string { + return `{"threadId":"` + coord.threadID + `","turnId":"` + coord.turnID + `",` + tail + `}` + } + notify("turn/started", params(`"turn":{"id":"`+coord.turnID+`"}`)) + notify("item/agentMessage/delta", params(`"itemId":"shared","delta":"foreign"`)) + notify("item/reasoning/summaryTextDelta", params(`"itemId":"thought","delta":"foreign"`)) + notify("item/started", params(`"item":{"type":"agentMessage","id":"shared","text":"foreign"}`)) + notify("item/completed", params(`"item":{"type":"agentMessage","id":"shared","text":"foreign"}`)) + notify("item/completed", params(`"item":{"type":"reasoning","id":"thought","text":"foreign"}`)) + notify("item/started", params(`"item":{"type":"commandExecution","id":"tool","command":"foreign"}`)) + notify("item/completed", params(`"item":{"type":"commandExecution","id":"tool","status":"completed"}`)) + notify("thread/tokenUsage/updated", params(`"tokenUsage":{"total":{"inputTokens":999,"cachedInputTokens":999,"outputTokens":999,"reasoningOutputTokens":999,"totalTokens":1998}}`)) + if coord.turnID != "" { + notify("thread/tokenUsage/updated", params(`"usage":{"inputTokens":999,"outputTokens":999}`)) + } + notify("error", params(`"message":"foreign error"`)) + notify("error", params(`"error":{"message":"foreign native error"},"willRetry":false`)) + notify("turn/completed", params(`"turn":{"id":"`+coord.turnID+`","status":"completed","usage":{"inputTokens":999,"outputTokens":999}}`)) + notify("turn/failed", params(`"turn":{"id":"`+coord.turnID+`","status":"failed"}`)) + } + for _, method := range []string{"turn/started", "turn/completed", "turn/failed", "error", "item/started", "item/completed", "item/agentMessage/delta", "thread/tokenUsage/updated"} { + notify(method, `{"threadId":42,"turnId":"root-turn","turn":{"id":"root-turn"}}`) + notify(method, `{}`) + } + if s.terminal.Load() || s.currentThreadID() != "root" || len(out) != rootEvents { + t.Fatalf("foreign notification changed Run ownership/output: terminal=%v thread=%q events=%d want=%d", s.terminal.Load(), s.currentThreadID(), len(out), rootEvents) + } + if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"] != "root thought" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { + t.Fatal("foreign notification changed root buffers") + } + if s.latestUsage == nil || s.latestUsage.InputTokens != 20 || s.latestUsage.OutputTokens != 5 || s.usageTurnID != "root-turn" { + t.Fatalf("foreign notification changed root usage: %+v", s.latestUsage) + } + s.steering.mu.Lock() + turnID, stopped := s.steering.id, s.steering.stopped + s.steering.mu.Unlock() + if stopped || turnID != "root-turn" { + t.Fatalf("foreign notification changed steering: %q stopped=%v", turnID, stopped) + } + notify("turn/started", `{"threadId":"root","turn":{"id":"root-turn"}}`) + notify("item/agentMessage/delta", `{"threadId":"root","turnId":"root-turn","itemId":"shared","delta":"result"}`) + notify("item/completed", `{"threadId":"root","turnId":"root-turn","item":{"type":"agentMessage","id":"shared","text":"root result"}}`) + notify("item/completed", `{"threadId":"root","turnId":"root-turn","item":{"type":"reasoning","id":"thought"}}`) + notify("turn/completed", `{"threadId":"root","turn":{"id":"root-turn","status":"completed"}}`) + notify("turn/completed", `{"threadId":"root","turn":{"id":"root-turn","status":"completed","usage":{"inputTokens":999}}}`) + var doneCount int + for env := range out { + if env.Type == proto.TypeDone { + doneCount++ + var done proto.DonePayload + if err := env.DecodePayload(&done); err != nil { + t.Fatal(err) + } + if done.Content != "root result" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.Tokens == nil || done.Usage.Tokens.TotalTokens != 25 { + t.Fatalf("incorrect root completion: %+v", done) + } + } + } + if doneCount != 1 || s.deltaSeq.Load() != 2 || s.thinkingSeq.Load() != 1 { + t.Fatalf("root output duplicated or polluted: done=%d delta=%d thinking=%d", doneCount, s.deltaSeq.Load(), s.thinkingSeq.Load()) + } + }) + } +} + +func TestNotificationsCannotEstablishRootIdentity(t *testing.T) { + s := &Session{cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers()} + s.registerHandlers() + scopeNotification(t, s, "thread/started", `{"thread":{"id":"unrelated"}}`) + scopeNotification(t, s, "turn/started", `{"threadId":"unrelated","turn":{"id":"unrelated-turn"}}`) + scopeNotification(t, s, "thread/tokenUsage/updated", `{"threadId":"unrelated","turnId":"unrelated-turn","tokenUsage":{"total":{"inputTokens":999}}}`) + scopeNotification(t, s, "turn/completed", `{"threadId":"unrelated","turn":{"id":"unrelated-turn","status":"completed"}}`) + if s.currentThreadID() != "" || s.usageTurnID != "" || s.usageTotal.InputTokens != 0 || s.terminal.Load() { + t.Fatal("notification acquired root identity or state before its RPC result") + } +} + +func TestRootNativeErrorNotification(t *testing.T) { + for name, errorJSON := range map[string]string{ + "object variant": `{"message":"native provider failure","codexErrorInfo":{"httpConnectionFailed":{"httpStatusCode":502}},"additionalDetails":null}`, + "string details": `{"message":"native provider failure","codexErrorInfo":"other","additionalDetails":"request failed"}`, + "null metadata": `{"message":"native provider failure","codexErrorInfo":null,"additionalDetails":null}`, + } { + for _, location := range []string{"error notification", "completion"} { + t.Run(name+"/"+location, func(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{})} + s.registerHandlers() + s.setThreadID("root") + scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + for _, threadID := range []string{"child", "root"} { + turnID := "turn" + if threadID == "root" { + turnID = "stale-turn" + } + scopeNotification(t, s, "error", `{"threadId":"`+threadID+`","turnId":"`+turnID+`","error":`+errorJSON+`,"willRetry":false}`) + scopeNotification(t, s, "turn/completed", `{"threadId":"`+threadID+`","turn":{"id":"`+turnID+`","status":"failed","error":`+errorJSON+`}}`) + } + if s.terminal.Load() || s.peekLastErrText() != "" || len(out) != 0 { + t.Fatal("foreign native failure changed the root Run") + } + completionError := "null" + if location == "error notification" { + scopeNotification(t, s, "error", `{"threadId":"root","turnId":"turn","error":`+errorJSON+`,"willRetry":false}`) + if s.peekLastErrText() != "native provider failure" || s.terminal.Load() { + t.Fatal("root error must be buffered until completion") + } + } else { + completionError = errorJSON + } + scopeNotification(t, s, "turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"failed","error":`+completionError+`}}`) + if !s.terminal.Load() { + t.Fatal("valid native failure did not settle the root Run") + } + var errorCount, doneCount int + for env := range out { + switch env.Type { + case proto.TypeError: + errorCount++ + var payload proto.ErrorPayload + if err := env.DecodePayload(&payload); err != nil || payload.Error != "native provider failure" { + t.Fatalf("native failure lost: %+v, %v", payload, err) + } + case proto.TypeDone: + doneCount++ + var done proto.DonePayload + if err := env.DecodePayload(&done); err != nil { + t.Fatal(err) + } + if done.Content != "native provider failure" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" { + t.Fatalf("native failure lost: %+v", done) + } + } + } + if errorCount != 1 || doneCount != 1 { + t.Fatalf("terminal counts: errors=%d done=%d", errorCount, doneCount) + } + }) + } + } +} + +func scopeNotification(t *testing.T, s *Session, method, params string) { + t.Helper() + frame, err := json.Marshal(map[string]any{"method": method, "params": json.RawMessage(params)}) + if err != nil { + t.Fatal(err) + } + s.rpc.dispatchFrame(frame) +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_output.go b/apps/parsar-daemon/internal/agent/codex/session_output.go new file mode 100644 index 000000000..012af3d60 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_output.go @@ -0,0 +1,30 @@ +package codex + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) trySend(env proto.Envelope) { + ctx, cancel := context.WithTimeout(s.cancelCtx, terminalSendTimeout) + defer cancel() + if !s.sendWithin(ctx, env) { + s.cfg.logger.Warn("codex: out send unavailable", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) sendWithin(ctx context.Context, env proto.Envelope) bool { + s.outMu.RLock() + defer s.outMu.RUnlock() + if s.outClosed { + return false + } + select { + case s.out <- env: + return true + case <-s.cancelCtx.Done(): + case <-ctx.Done(): + } + return false +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go new file mode 100644 index 000000000..aa4b8071d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -0,0 +1,93 @@ +package codex + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, string, error) { + profile, err := managedPermissionProfile(req, cfg) + if err != nil { + return SessionPlan{}, "", err + } + if req.WorkspaceReadOnly { + plan, err := workspaceReadPlan(req) + return plan, "", err + } + mcpServers, err := publicMCPHTTPServers(req) + if err != nil { + return SessionPlan{}, "", err + } + plan, err := BuildSessionPlan(req.RunID, req.AgentStateKey, req.WorkDir, req.AgentOptions) + if err != nil { + return SessionPlan{}, "", fmt.Errorf("codex: build session plan: %w", err) + } + if profile != "" { + plan.Sandbox = "" + plan.Permissions = profile + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"default_permissions", tomlQuoteString(profile)}) + configureRestrictedShellEnvironment(&plan) + // Native login-shell snapshots live outside the managed tool filesystem. + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) + } + + if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + plan.Env = append(plan.Env, "PARSAR_RUNTIME_TOOL_ENV=1") + if req.LocalEnvironment.SystemPackages { + if err := prepareSystemToolAnchor(); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + plan.Env = append(plan.Env, "PARSAR_RUNTIME_SYSTEM_PACKAGES=1") + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.hooks", "true"}) + } + + if req.DisableSubagents { + disableSubagents(&plan) + } + mcpBearerEnv := prepareMCPHTTPBearer(mcpServers, req.MCPHTTPServers) + if mcpServers != nil { + if err := configureMCPHTTP(&plan, mcpServers); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + } + + if stringOpt(req.AgentOptions, "model_verbosity") != "" { + if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + } + + if req.DisableExecutionEnvironment { + plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") + } + skillRoot := "" + if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { + err = verifyHostedSkills(req.LocalEnvironment.Skills) + if err == nil { + skillRoot = localworkspace.SkillDirectory + } + } else if !req.DisableExecutionEnvironment && req.RemoteEnvironment == nil { + skillRoot, err = prepareManagedSkills(ctx, cfg.logger, req) + } + if err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + + if req.RemoteEnvironment != nil { + configureRemoteEnvironment(&plan, *req.RemoteEnvironment) + } + plan.Env = append(plan.Env, mcpBearerEnv...) + return plan, skillRoot, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go new file mode 100644 index 000000000..b62ffd6be --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go @@ -0,0 +1,80 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { + for _, profile := range []string{"", "managed-workspace"} { + t.Run("profile="+profile, func(t *testing.T) { + for _, method := range []string{"thread/start", "thread/resume"} { + t.Run(method, func(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{permissionProfile: profile}) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + session := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "policy-test")}} + done := make(chan error, 1) + go func() { + if method == "thread/resume" { + done <- session.resumeThread("old-thread", plan) + } else { + done <- session.startThread(plan) + } + }() + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != method { + t.Fatalf("method = %q", request.Method) + } + if request.Params["approvalPolicy"] != "never" { + t.Fatal("approval policy changed") + } + if profile == "" { + if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { + t.Fatalf("default policy changed: %+v", request.Params) + } + } else { + if request.Params["sandbox"] != nil || request.Params["permissions"] != profile { + t.Fatalf("managed policy lost: %+v", request.Params) + } + } + if method == "thread/resume" && request.Params["threadId"] != "old-thread" { + t.Fatalf("resume lost thread ID: %+v", request.Params) + } + resolvedID := "resolved-thread" + if method == "thread/resume" { + resolvedID = "old-thread" + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]any{"id": resolvedID}}}); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if session.currentThreadID() != resolvedID { + t.Fatal("thread response was not applied") + } + }) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_run.go b/apps/parsar-daemon/internal/agent/codex/session_run.go new file mode 100644 index 000000000..5a86016b5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_run.go @@ -0,0 +1,70 @@ +package codex + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { + defer close(s.waitDone) + defer s.stopCodexInteractionTimers() + defer s.stopFunctionCalls() + defer s.cleanup() + defer s.closeRunOutput() + + if err := s.resolveThread(req, plan); err != nil { + s.emitTerminal(err.Error(), true) + return + } + + input := FirstUserInput(req.Prompt) + if len(input) == 0 { + s.emitTerminal("codex: empty prompt", true) + return + } + turnParams := TurnStartParams{ + ThreadID: s.currentThreadID(), + Input: input, + Environments: plan.Environments, + } + if plan.CollaborationMode != "" { + model := strings.TrimSpace(s.resolvedModel) + if model == "" { + s.emitTerminal("codex: collaboration mode requires a resolved model", true) + return + } + var developerInstructions *string + if plan.SystemPrompt != "" { + developerInstructions = &plan.SystemPrompt + } + turnParams.CollaborationMode = &CollaborationMode{ + Mode: plan.CollaborationMode, + Settings: CollaborationModeSettings{ + Model: model, + DeveloperInstructions: developerInstructions, + }, + } + } + turnCtx, turnCancel := context.WithTimeout(s.cancelCtx, 10*time.Second) + _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) + turnCancel() + if ackErr != nil { + s.cfg.logger.Warn("codex: turn/start ack failed", "run_id", s.runID, "err", ackErr) + s.emitTerminal(fmt.Sprintf("codex: turn/start: %v", ackErr), true) + return + } + + // Block until terminal handlers close the RPC child or cancellation arrives. + select { + case <-s.rpc.Done(): + if !s.cancelled.Load() && s.cancelCtx.Err() == nil { + s.emitTerminal("codex: connection closed before the run completed", true) + } + case <-s.cancelCtx.Done(): + _ = s.rpc.Close() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering.go b/apps/parsar-daemon/internal/agent/codex/session_steering.go new file mode 100644 index 000000000..9ed03b3d1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_steering.go @@ -0,0 +1,117 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type steeringTurn struct { + mu sync.Mutex + id string + stopped bool + cancel context.CancelFunc +} + +// TurnSteerParams mirrors the native Codex app-server turn/steer request. +type TurnSteerParams struct { + ThreadID string `json:"threadId"` + ExpectedTurnID string `json:"expectedTurnId"` + Input []UserInput `json:"input"` +} + +var _ agent.Steerer = (*Session)(nil) + +// Steer returns success only after Codex accepts input for this native turn. +func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.steer(ctx, input, nil) +} + +// SteerWithReceipt waits under the Run context after reporting the complete write. +func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + return s.steer(ctx, input, written) +} + +func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + s.steering.mu.Lock() + turnID, stopped := s.steering.id, s.steering.stopped + if !stopped { + s.steering.cancel = cancel + } + s.steering.mu.Unlock() + defer func() { + s.steering.mu.Lock() + s.steering.cancel = nil + s.steering.mu.Unlock() + }() + if stopped || s.cancelCtx.Err() != nil { + return agent.ErrSteeringInactive + } + threadID := s.currentThreadID() + if turnID == "" || threadID == "" { + return agent.ErrSteeringNotReady + } + params := TurnSteerParams{ThreadID: threadID, ExpectedTurnID: turnID, Input: FirstUserInput(input.Text)} + timeout := s.rpc.cfg.RequestTimeout + if written != nil { + timeout = 0 + } + raw, err := s.rpc.requestWithTimeout(ctx, "turn/steer", params, func(frame any) error { + if err := s.rpc.writeFrameContext(ctx, frame); err != nil { + return err + } + if written != nil { + written() + } + return nil + }, timeout, nil) + if err != nil { + var rejected *JsonRpcError + if errors.As(err, &rejected) { + return fmt.Errorf("%w: %v", agent.ErrSteeringRejected, err) + } + return err + } + var result struct { + TurnID string `json:"turnId"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("codex: decode steering receipt: %w", err) + } + if result.TurnID != turnID { + return fmt.Errorf("codex: steering receipt does not match the active turn") + } + return nil +} + +func (s *Session) startSteering(threadID, turnID string) bool { + if !s.isRootThread(threadID) || turnID == "" { + return false + } + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + if s.steering.stopped || s.steering.id != "" { + return false + } + s.steering.id = turnID + return true +} + +// stopSteering atomically retains the cancellation target and stops new input. +func (s *Session) stopSteering() (turnID string, active bool) { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + turnID, active = s.steering.id, !s.steering.stopped + s.steering.stopped = true + if s.steering.cancel != nil { + s.steering.cancel() + } + return turnID, active +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go new file mode 100644 index 000000000..dd10a238b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -0,0 +1,146 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +func TestSteeringReceiptTimeoutAndCompletionKeepProcessAlive(t *testing.T) { + for _, complete := range []bool{false, true} { + t.Run(map[bool]string{false: "receipt timeout", true: "normal completion"}[complete], func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + out := make(chan proto.Envelope, 4) + s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background(), out: out, cfg: sessionConfig{logger: obslog.Bg()}} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + timeout := 50 * time.Millisecond + if complete { + timeout = time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "input", Text: "extra"}) }() + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + // Withhold the response after reading the entire request frame. + if complete { + // Reading the pipe does not mean its writer has returned yet. + for { + client.pendingMu.Lock() + pending := client.pending[request.ID] + waiting := pending != nil && pending.timer != nil + client.pendingMu.Unlock() + if waiting { + break + } + select { + case <-ctx.Done(): + t.Fatal("steering request did not reach response wait") + case <-time.After(time.Millisecond): + } + } + s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn","status":"completed"}}`)) + } + select { + case err := <-done: + if err == nil { + t.Fatal("missing receipt reported as accepted") + } + case <-time.After(time.Second): + t.Fatal("steering response wait did not stop") + } + if !client.Alive() { + t.Fatal("response wait killed retained process") + } + if complete { + s.emitTerminal("late disconnect", true) + var frames []proto.Envelope + for env := range out { + frames = append(frames, env) + } + if len(frames) != 1 || frames[0].Type != proto.TypeDone { + t.Fatalf("terminal frames: %+v", frames) + } + } + }) + } +} + +func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out := make(chan proto.Envelope, 8) + s := &Session{ + runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, + cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), cleanup: func() {}, + bufs: NewItemBuffers(), interactions: newPendingCodexInteractions(), + } + s.registerHandlers() + ready := make(chan error, 1) + go func() { + decoder := json.NewDecoder(server.FromClient) + encoder := json.NewEncoder(server.ToClient) + for _, method := range []string{"thread/start", "turn/start"} { + var request JsonRpcRequest + if err := decoder.Decode(&request); err != nil { + ready <- err + return + } + if request.Method != method { + t.Errorf("method = %s, expected %s", request.Method, method) + } + if method == "turn/start" { + if err := SendNotification(server, "turn/started", map[string]any{"threadId": "thread", "turn": map[string]any{"id": "turn"}}); err != nil { + ready <- err + return + } + } + result := map[string]any{"thread": map[string]any{"id": "thread"}} + if method == "turn/start" { + result = map[string]any{"turn": map[string]any{"id": "turn"}} + } + if err := encoder.Encode(map[string]any{"id": request.ID, "result": result}); err != nil { + ready <- err + return + } + } + ready <- nil + }() + go s.run(SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Prompt: "first"}) + if err := <-ready; err != nil { + t.Fatal(err) + } + callCtx, callCancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer callCancel() + if err := s.Steer(callCtx, proto.PromptSteerPayload{InputID: "blocked", Text: "extra"}); err == nil { + t.Fatal("blocked write accepted") + } + if client.Alive() { + t.Fatal("blocked transport still alive") + } + // TestClient has no child waiter; emulate the process exit after pipe close. + close(client.doneCh) + select { + case <-s.waitDone: + case <-ctx.Done(): + t.Fatal("run did not end after native disconnect") + } + var frames []proto.Envelope + for env := range out { + frames = append(frames, env) + } + if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatalf("missing honest terminal outcome: %+v", frames) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go b/apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go new file mode 100644 index 000000000..1c744d273 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go @@ -0,0 +1,112 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestDurableSteeringBypassesOnlyNativeResponseDeadline(t *testing.T) { + for _, durable := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "durable"}[durable], func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + client.cfg.RequestTimeout = 20 * time.Millisecond + s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background()} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + written := make(chan struct{}) + reply := make(chan error, 1) + go func() { + input := proto.PromptSteerPayload{InputID: "extra", Text: "text"} + if durable { + reply <- s.SteerWithReceipt(ctx, input, func() { close(written) }) + } else { + reply <- s.Steer(ctx, input) + } + }() + if durable { + select { + case <-written: + t.Fatal("written before frame was read") + default: + } + } + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if durable { + select { + case <-written: + case <-ctx.Done(): + t.Fatal("missing write phase") + } + } + select { + case err := <-reply: + if durable || err == nil { + t.Fatal("unexpected response deadline", err) + } + case <-time.After(60 * time.Millisecond): + if !durable { + t.Fatal("legacy timeout disappeared") + } + } + if durable { + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]string{"turnId": "turn"}}); err != nil { + t.Fatal(err) + } + if err := <-reply; err != nil { + t.Fatal(err) + } + } + if !client.Alive() { + t.Fatal("receipt wait killed process") + } + }) + } +} + +func TestDurableSteeringKeepsConfirmedReceiptAtCompletion(t *testing.T) { + for _, confirmed := range []bool{false, true} { + failures := 0 + for range 40 { + client, server, cleanup := NewTestClient() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background()} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + inWritten, releaseWritten := make(chan struct{}), make(chan struct{}) + reply := make(chan error, 1) + go func() { + reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "extra", Text: "text"}, func() { close(inWritten); <-releaseWritten }) + }() + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + <-inWritten + if confirmed { + id, _ := json.Marshal(request.ID) + client.handleResponse(id, json.RawMessage(`{"turnId":"turn"}`), nil) + } + s.stopSteering() + close(releaseWritten) + err := <-reply + cancel() + cleanup() + if (err == nil) != confirmed { + failures++ + } + } + if failures != 0 { + t.Fatalf("confirmed=%v: incorrect native evidence in %d/40 completions", confirmed, failures) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_test.go b/apps/parsar-daemon/internal/agent/codex/session_steering_test.go new file mode 100644 index 000000000..651677311 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_steering_test.go @@ -0,0 +1,118 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSteeringUsesNativeActiveTurnAndReceipt(t *testing.T) { + for _, test := range []struct { + name string + result any + err any + wantOK bool + }{ + {name: "accepted", result: map[string]any{"turnId": "native-turn"}, wantOK: true}, + {name: "wrong turn", result: map[string]any{"turnId": "other-turn"}}, + {name: "missing turn", result: map[string]any{}}, + {name: "rejected", err: map[string]any{"code": -32600, "message": "no active turn"}}, + } { + t.Run(test.name, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{runID: "daemon-run", rpc: client.JSONRPCClient, cancelCtx: ctx} + s.setThreadID("native-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + done := make(chan error, 1) + go func() { + done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "input-1", Text: "追加输入"}) + }() + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params TurnSteerParams `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != "turn/steer" || request.Params.ThreadID != "native-thread" || request.Params.ExpectedTurnID != "native-turn" { + t.Fatalf("wrong native destination: %+v", request) + } + if len(request.Params.Input) != 1 || request.Params.Input[0].Text != "追加输入" { + t.Fatalf("input lost: %+v", request.Params.Input) + } + select { + case err := <-done: + t.Fatalf("returned before native ack: %v", err) + default: + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": test.result, "error": test.err}); err != nil { + t.Fatal(err) + } + err := <-done + if (err == nil) != test.wantOK { + t.Fatalf("want success=%v: %v", test.wantOK, err) + } + if test.err != nil && !errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("explicit rejection lost: %v", err) + } + }) + } +} + +func TestSteeringDeadlineReleasesBlockedNativeWrite(t *testing.T) { + client, _, cleanup := NewTestClient() + defer cleanup() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background()} + s.setThreadID("native-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "blocked", Text: "extra"}) }() + // No reader drains the pipe, so the request never reaches its response wait. + select { + case err := <-done: + if err == nil || errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("blocked delivery has an uncertain outcome: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("deadline did not release native stdin write") + } + if client.Alive() { + t.Fatal("blocked native connection was not closed") + } +} + +func TestSteeringDoesNotStartOrReviveTurns(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &Session{cancelCtx: ctx} + s.setThreadID("native-thread") + input := proto.PromptSteerPayload{InputID: "input-1", Text: "extra"} + // A nil RPC client proves these states do not issue a request. + for _, notification := range []json.RawMessage{nil, json.RawMessage(`{"threadId":"other-thread","turn":{"id":"other-turn"}}`)} { + s.onTurnStarted(notification) + if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringNotReady) { + t.Fatalf("starting turn: %v", err) + } + } + s.stopSteering() + s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"late-turn"}}`)) + if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatalf("terminal turn revived: %v", err) + } + s = &Session{cancelCtx: ctx} + cancel() + if err := s.Steer(context.Background(), input); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatalf("cancelled run: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_thread.go b/apps/parsar-daemon/internal/agent/codex/session_thread.go new file mode 100644 index 000000000..3d7898be3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_thread.go @@ -0,0 +1,85 @@ +package codex + +import ( + "encoding/json" + "fmt" +) + +func (s *Session) startThread(plan SessionPlan) error { + params := ThreadStartParams{ + Cwd: plan.Cwd, + Environments: plan.Environments, + Model: plan.Model, + ModelProvider: plan.ModelProvider, + ApprovalPolicy: plan.ApprovalPolicy, + Sandbox: plan.Sandbox, + Permissions: plan.Permissions, + DeveloperInstructions: plan.SystemPrompt, + } + if s.functions != nil { + params.DynamicTools = s.functions.definitions + } + s.cfg.logger.Info("codex: thread/start request", + "run_id", s.runID, + "cwd", params.Cwd, + "model", params.Model, + "model_provider", params.ModelProvider, + "sandbox", string(params.Sandbox), + "approval_silent", IsSilent(¶ms.ApprovalPolicy), + "developer_instructions_len", len(params.DeveloperInstructions)) + _, err := s.rpc.requestWithResult(s.cancelCtx, "thread/start", params, func(raw json.RawMessage) error { + return s.bindThreadResult(raw, "") + }) + return err +} + +func (s *Session) resumeThread(threadID string, plan SessionPlan) error { + params := ThreadResumeParams{ + ThreadID: threadID, ApprovalPolicy: plan.ApprovalPolicy, Sandbox: plan.Sandbox, + Permissions: plan.Permissions, + DeveloperInstructions: plan.SystemPrompt, + } + if plan.mcpHTTPServers != nil { + // Resolve the same project configuration checked before native startup. + params.Cwd = plan.Cwd + } + s.usageMu.Lock() + s.resumeUsageThreadID = threadID + s.usageMu.Unlock() + defer func() { + s.usageMu.Lock() + s.resumeUsageThreadID, s.resumeUsageTotal = "", nil + s.usageMu.Unlock() + }() + _, err := s.rpc.requestWithResult(s.cancelCtx, "thread/resume", params, func(raw json.RawMessage) error { + if err := s.bindThreadResult(raw, threadID); err != nil { + return err + } + s.usageMu.Lock() + if s.resumeUsageTotal != nil { + s.usageTotal = *s.resumeUsageTotal + } + s.resumeUsageThreadID, s.resumeUsageTotal = "", nil + s.usageMu.Unlock() + return nil + }) + return err +} + +func (s *Session) bindThreadResult(raw json.RawMessage, expectedID string) error { + var res ThreadStartResult + if err := json.Unmarshal(raw, &res); err != nil { + return fmt.Errorf("codex: decode thread response: %w", err) + } + if res.Thread.ID == "" || (expectedID != "" && res.Thread.ID != expectedID) { + return fmt.Errorf("codex: thread response has missing or mismatched identity") + } + if threadID := s.currentThreadID(); threadID != "" && threadID != res.Thread.ID { + return fmt.Errorf("codex: thread response would replace the root identity") + } + s.setThreadID(res.Thread.ID) + if res.Model != "" { + s.resolvedModel = res.Model + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools.go b/apps/parsar-daemon/internal/agent/codex/session_tools.go new file mode 100644 index 000000000..977e19a82 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_tools.go @@ -0,0 +1,42 @@ +package codex + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawMessage) { + var native struct { + Item json.RawMessage `json:"item"` + } + if s.observeTools || s.observeToolObservations { + if err := json.Unmarshal(notification, &native); err != nil { + return + } + } + for _, event := range events { + if (s.observeTools || s.observeToolObservations) && event.Type == proto.TypeToolCall { + var tool proto.ToolCallPayload + if err := event.DecodePayload(&tool); err != nil { + return + } + if s.observeToolObservations { + var err error + tool.Observation, err = normalizeToolObservation(tool.ID, tool.Stage, native.Item) + if err != nil { + s.emitTerminal("codex: invalid tool observation", true) + return + } + } else { + tool.NativeItem = native.Item + } + payload, err := json.Marshal(tool) + if err != nil { + return + } + event.Payload = payload + } + s.trySend(event) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools_test.go b/apps/parsar-daemon/internal/agent/codex/session_tools_test.go new file mode 100644 index 000000000..95cadd44b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_tools_test.go @@ -0,0 +1,114 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func toolSnapshotFixtures() []string { + return []string{ + `{"type":"commandExecution","id":"cmd","command":"printf result","cwd":"/tmp","status":"completed","aggregatedOutput":"result\n","exitCode":0,"durationMs":37}`, + `{"type":"commandExecution","id":"fail","command":"exit 2","status":"failed","aggregatedOutput":"failure details","exitCode":2,"durationMs":12}`, + `{"type":"mcpToolCall","id":"mcp","server":"reference","tool":"lookup","arguments":{"id":"record"},"status":"completed","result":{"content":[{"type":"text","text":"answer"}],"structuredContent":{"version":9007199254740993}},"error":null}`, + `{"type":"mcpToolCall","id":"mcp-error","server":"reference","tool":"lookup","arguments":{},"status":"failed","result":null,"error":{"message":"lookup failed"}}`, + `{"type":"dynamicToolCall","id":"dynamic","tool":"lookup","namespace":"reference","arguments":["a","b"],"status":"completed","contentItems":[{"type":"inputText","text":"dynamic output"}],"success":true}`, + `{"type":"fileChange","id":"edit","status":"completed","changes":[{"path":"/tmp/example","kind":{"type":"update","move_path":null},"diff":"-before\n+after"}]}`, + `{"type":"webSearch","id":"search","query":"reference","action":{"type":"search","query":"reference","queries":["reference"]}}`, + } +} + +func TestToolSnapshotsPreserveNativeResultsOnlyWhenRequested(t *testing.T) { + for _, item := range toolSnapshotFixtures() { + var identity struct{ ID string } + if err := json.Unmarshal([]byte(item), &identity); err != nil { + t.Fatal(err) + } + t.Run(identity.ID, func(t *testing.T) { + var legacy []proto.Envelope + for _, enabled := range []bool{false, true} { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", observeTools: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("private-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) + raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) + s.onItemStarted(raw) + s.onItemCompleted(raw) + if len(out) != 2 { + t.Fatalf("tool event count changed: %d", len(out)) + } + for i, stage := range []string{"before", "after"} { + event := <-out + var tool proto.ToolCallPayload + if err := event.DecodePayload(&tool); err != nil { + t.Fatal(err) + } + if event.Type != proto.TypeToolCall || event.ID != "run" || tool.ID != identity.ID || tool.Stage != stage { + t.Fatalf("tool identity/stage changed: %+v %+v", event, tool) + } + if !enabled { + if tool.NativeItem != nil || tool.Observation != nil || bytes.Contains(event.Payload, []byte("native_item")) { + t.Fatal("legacy request acquired tool snapshot") + } + legacy = append(legacy, event) + continue + } + var expected bytes.Buffer + if err := json.Compact(&expected, []byte(item)); err != nil { + t.Fatal(err) + } + if !bytes.Equal(tool.NativeItem, expected.Bytes()) { + t.Fatalf("native result lost or coerced: %s", tool.NativeItem) + } + tool.NativeItem = nil + payload, err := json.Marshal(tool) + if err != nil || !bytes.Equal(payload, legacy[i].Payload) { + t.Fatalf("legacy tool fields changed: %s, %v", payload, err) + } + } + } + }) + } +} + +func TestToolObservationsReplaceNativeSnapshotsWhenRequested(t *testing.T) { + for _, nativeSnapshots := range []bool{false, true} { + for _, item := range toolSnapshotFixtures() { + var source struct{ ID string } + if err := json.Unmarshal([]byte(item), &source); err != nil { + t.Fatal(err) + } + t.Run(source.ID, func(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", observeTools: nativeSnapshots, observeToolObservations: true, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("private-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) + raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) + s.onItemStarted(raw) + s.onItemCompleted(raw) + if len(out) != 2 { + t.Fatalf("tool event count changed: %d", len(out)) + } + for _, stage := range []string{"before", "after"} { + event := <-out + var tool proto.ToolCallPayload + if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage || tool.Observation == nil { + t.Fatal(event) + } + if tool.NativeItem != nil || bytes.Contains(event.Payload, []byte("native_item")) { + t.Fatal("duplicate native snapshot") + } + if stage == "before" && tool.Observation.Status != "in_progress" { + t.Fatal(tool.Observation) + } + if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { + t.Fatal("structured output precision lost") + } + } + }) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go b/apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go new file mode 100644 index 000000000..bdff18c15 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go @@ -0,0 +1,73 @@ +package codex + +import ( + "encoding/json" + "testing" +) + +// TestTurnErrorMessage_UnwrapsGatewayJSON pins the case that bit us +// in production: codex's TurnError.Message is the raw HTTP body the +// gateway returned, which for OpenAI-Responses-style proxies is a +// JSON-encoded {"error":{"code","message","type"}} blob. Forwarding +// it verbatim ("escaped braces and all") leaves operators staring at +// what looks like JSON in their UI error text. Unwrapping one layer +// surfaces the actual code + message. +func TestTurnErrorMessage_UnwrapsGatewayJSON(t *testing.T) { + te := &TurnError{ + Message: `{"error":{"code":"submodule_not_allowed","message":"X-Sub-Module is not allowed for this API key","type":"invalid_request_error"}}`, + CodexErrorInfo: json.RawMessage(`"other"`), + } + got := turnErrorMessage(te) + want := "submodule_not_allowed: X-Sub-Module is not allowed for this API key" + if got != want { + t.Fatalf("turnErrorMessage = %q\nwant %q", got, want) + } +} + +// TestTurnErrorMessage_KeepsPlainStringAsIs handles the path where +// codex itself constructed the message (rate-limit hints, context-window +// exceeded, etc.) — these aren't gateway-wrapped JSON; pass through. +func TestTurnErrorMessage_KeepsPlainStringAsIs(t *testing.T) { + te := &TurnError{Message: "rate limit hit, retry after 30s"} + if got := turnErrorMessage(te); got != te.Message { + t.Fatalf("turnErrorMessage = %q, want %q", got, te.Message) + } +} + +// TestTurnErrorMessage_NilSafe — sessions that never saw a turn error +// must still drive emitTerminal without panicking. +func TestTurnErrorMessage_NilSafe(t *testing.T) { + if got := turnErrorMessage(nil); got != "" { + t.Fatalf("nil → %q, want empty string", got) + } +} + +// TestTurnErrorMessage_EmptyInnerMessageFallsBackToRaw — if the JSON +// peel succeeds but the inner message is empty (rare malformed bodies), +// don't drop the original raw text on the floor. +func TestTurnErrorMessage_EmptyInnerMessageFallsBackToRaw(t *testing.T) { + te := &TurnError{Message: `{"error":{"code":"x","message":"","type":"y"}}`} + got := turnErrorMessage(te) + if got != te.Message { + t.Fatalf("empty inner.message must fall back to raw, got %q", got) + } +} + +// TestAppendOnNewline pins the body-assembly behavior so the precedence +// order in onTurnCompleted stays predictable. +func TestAppendOnNewline(t *testing.T) { + cases := []struct { + base, extra, want string + }{ + {"", "", ""}, + {"a", "", "a"}, + {"", "b", "b"}, + {"a", "b", "a\n\nb"}, + } + for _, tc := range cases { + got := appendOnNewline(tc.base, tc.extra) + if got != tc.want { + t.Errorf("appendOnNewline(%q,%q) = %q, want %q", tc.base, tc.extra, got, tc.want) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage.go b/apps/parsar-daemon/internal/agent/codex/session_usage.go new file mode 100644 index 000000000..7f03a9c54 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_usage.go @@ -0,0 +1,102 @@ +package codex + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) beginUsageTurn(turnID string) { + s.usageMu.Lock() + defer s.usageMu.Unlock() + if s.usageTurnID == turnID { + return + } + s.usageTurnID = turnID + s.usageBaseline = s.usageTotal + s.latestUsage = nil +} + +func (s *Session) onUsageUpdated(raw json.RawMessage) { + var p ThreadTokenUsageUpdatedNotification + if json.Unmarshal(raw, &p) != nil { + return + } + if !s.isRootThread(p.ThreadID) { + s.usageMu.Lock() + if s.resumeUsageThreadID != "" && p.ThreadID == s.resumeUsageThreadID && p.TurnID != "" && p.TokenUsage != nil && p.TokenUsage.Total != nil { + s.resumeUsageTotal = p.TokenUsage.Total + } + s.usageMu.Unlock() + return + } + if s.terminal.Load() { + return + } + s.usageMu.Lock() + defer s.usageMu.Unlock() + if p.TokenUsage != nil && p.TokenUsage.Total != nil && p.TurnID != "" { + // app-server replays the previous thread total after resume and + // before turn/started. It establishes a baseline, not new usage. + if s.usageTurnID == "" { + s.usageTotal = *p.TokenUsage.Total + return + } + if p.TurnID != s.usageTurnID { + return + } + s.usageTotal = *p.TokenUsage.Total + u := subtractUsage(s.usageTotal, s.usageBaseline) + s.latestUsage = &u + return + } + if p.Usage != nil && s.usageTurnID != "" && (p.TurnID == "" || p.TurnID == s.usageTurnID) { + u := *p.Usage + s.latestUsage = &u + } +} + +func subtractUsage(total, baseline TurnUsage) TurnUsage { + return TurnUsage{ + observed: true, + complete: total.complete && (!baseline.observed || baseline.complete) && + total.InputTokens >= baseline.InputTokens && total.OutputTokens >= baseline.OutputTokens && + total.CachedInputTokens >= baseline.CachedInputTokens && + total.ReasoningOutputTokens >= baseline.ReasoningOutputTokens && total.TotalTokens >= baseline.TotalTokens, + ReasoningOutputTokens: max(0, total.ReasoningOutputTokens-baseline.ReasoningOutputTokens), + InputTokens: max(0, total.InputTokens-baseline.InputTokens), + OutputTokens: max(0, total.OutputTokens-baseline.OutputTokens), + CachedInputTokens: max(0, total.CachedInputTokens-baseline.CachedInputTokens), + CacheReadInputTokens: max(0, total.CacheReadInputTokens-baseline.CacheReadInputTokens), + TotalTokens: max(0, total.TotalTokens-baseline.TotalTokens), + } +} + +func (u *TurnUsage) UnmarshalJSON(raw []byte) error { + type plain TurnUsage + var value plain + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + *u = TurnUsage(value) + u.observed, u.complete = true, true + for _, key := range []string{"inputTokens", "outputTokens", "cachedInputTokens", "reasoningOutputTokens", "totalTokens"} { + var n *int64 + if json.Unmarshal(fields[key], &n) != nil || n == nil || *n < 0 { + u.complete = false + } + } + return nil +} + +func (s *Session) usagePayload(u TurnUsage) proto.Usage { + result := proto.Usage{Provider: "openai", Model: s.resolvedModel, InputTokens: int32(u.InputTokens), OutputTokens: int32(u.OutputTokens)} + if u.complete { + result.Tokens = &proto.TokenUsage{InputTokens: int64(u.InputTokens), OutputTokens: int64(u.OutputTokens), CachedInputTokens: int64(u.CachedInputTokens), ReasoningOutputTokens: int64(u.ReasoningOutputTokens), TotalTokens: int64(u.TotalTokens)} + } + return result +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage_test.go b/apps/parsar-daemon/internal/agent/codex/session_usage_test.go new file mode 100644 index 000000000..4a01b15c3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/session_usage_test.go @@ -0,0 +1,154 @@ +package codex + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestNativeTokenUsageAcrossRuns(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), + cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}} + s.setThreadID("thread") + // A resumed thread replays its previous usage before starting this run. + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"previous","tokenUsage":{"total":{"inputTokens":1000,"outputTokens":100},"last":{"inputTokens":500,"outputTokens":50}}}`)) + if s.latestUsage != nil { + t.Fatal("restored history was treated as current usage") + } + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1200,"outputTokens":120},"last":{"inputTokens":200,"outputTokens":20}}}`)) + // The second model request follows a tool call; its last counter is not + // the whole turn. Repeating the cumulative snapshot must not add usage. + second := json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1500,"outputTokens":150},"last":{"inputTokens":300,"outputTokens":30}}}`) + s.onUsageUpdated(second) + s.onUsageUpdated(second) + if s.latestUsage == nil || s.latestUsage.InputTokens != 500 || s.latestUsage.OutputTokens != 50 { + t.Fatalf("current turn usage = %+v, want 500 input / 50 output", s.latestUsage) + } + // The next Run resumes the native thread and replays its last total. + s = &Session{runID: "next-run", out: out, cancelCtx: context.Background(), + cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}} + s.setThreadID("thread") + s.onUsageUpdated(second) + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"next"}}`)) + if s.latestUsage != nil { + t.Fatal("new turn retained previous turn usage") + } + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"next","tokenUsage":{"total":{"inputTokens":1700,"outputTokens":175},"last":{"inputTokens":200,"outputTokens":25}}}`)) + s.finalText = "done" + s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"next","status":"completed"}}`)) + var usage proto.UsagePayload + var done proto.DonePayload + for e := range out { + switch e.Type { + case proto.TypeUsage: + if err := json.Unmarshal(e.Payload, &usage); err != nil { + t.Fatal(err) + } + case proto.TypeDone: + if err := json.Unmarshal(e.Payload, &done); err != nil { + t.Fatal(err) + } + } + } + if usage.InputTokens != 200 || usage.OutputTokens != 25 || done.Usage.InputTokens != 200 || done.Content != "done" { + t.Fatalf("terminal usage=%+v done=%+v", usage, done) + } +} + +func TestNativeTokenUsageFreshThreadAndIgnoredPayloads(t *testing.T) { + s := &Session{} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":321,"outputTokens":45}}}`)) + for _, raw := range []string{ + `{`, + `{"threadId":"thread","turnId":"current"}`, + `{"threadId":"thread","turnId":"current","tokenUsage":{}}`, + `{"threadId":"thread","turnId":"previous","tokenUsage":{"total":{"inputTokens":999,"outputTokens":99}}}`, + `{"threadId":"other","turnId":"current","tokenUsage":{"total":{"inputTokens":999,"outputTokens":99}}}`, + } { + s.onUsageUpdated(json.RawMessage(raw)) + } + if s.latestUsage == nil || s.latestUsage.InputTokens != 321 || s.latestUsage.OutputTokens != 45 { + t.Fatalf("valid usage was lost or overwritten: %+v", s.latestUsage) + } +} + +func TestLegacyTurnUsagePayload(t *testing.T) { + s := &Session{} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","usage":{"inputTokens":123,"outputTokens":12}}`)) + if s.latestUsage == nil || s.latestUsage.InputTokens != 123 || s.latestUsage.OutputTokens != 12 { + t.Fatalf("legacy usage = %+v", s.latestUsage) + } + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","usage":{"inputTokens":0,"outputTokens":0}}`)) + if s.latestUsage == nil || s.latestUsage.InputTokens != 0 || s.latestUsage.OutputTokens != 0 { + t.Fatalf("explicit zero usage = %+v", s.latestUsage) + } +} + +func TestCompleteTokenBreakdownAndCancellation(t *testing.T) { + s := &Session{} + s.setThreadID("thread") + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"old","tokenUsage":{"total":{"inputTokens":100,"cachedInputTokens":20,"outputTokens":50,"reasoningOutputTokens":10,"totalTokens":150}}}`)) + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"new"}}`)) + snapshot := json.RawMessage(`{"threadId":"thread","turnId":"new","tokenUsage":{"total":{"inputTokens":130,"cachedInputTokens":24,"outputTokens":60,"reasoningOutputTokens":12,"totalTokens":190}}}`) + s.onUsageUpdated(snapshot) + s.onUsageUpdated(snapshot) + got := s.CancellationOutcome().Usage.Tokens + want := proto.TokenUsage{InputTokens: 30, CachedInputTokens: 4, OutputTokens: 10, ReasoningOutputTokens: 2, TotalTokens: 40} + if got == nil || *got != want { + t.Fatalf("cancel usage = %+v", got) + } + for _, raw := range []string{ + `{"inputTokens":0,"outputTokens":0}`, + `{"inputTokens":0,"cachedInputTokens":0,"outputTokens":0,"reasoningOutputTokens":null,"totalTokens":0}`, + } { + var usage TurnUsage + if err := json.Unmarshal([]byte(raw), &usage); err != nil { + t.Fatal(err) + } + if s.usagePayload(usage).Tokens != nil { + t.Fatal("missing usage became measured zero") + } + } + var zero TurnUsage + if err := json.Unmarshal([]byte(`{"inputTokens":0,"cachedInputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}`), &zero); err != nil { + t.Fatal(err) + } + if s.usagePayload(zero).Tokens == nil { + t.Fatal("explicit zero usage lost") + } + partial := TurnUsage{observed: true, InputTokens: 1} + if s.usagePayload(subtractUsage(zero, partial)).Tokens != nil { + t.Fatal("incomplete or regressing baseline reported complete") + } +} + +func TestAbnormalTerminationTransmitsKnownUsage(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"turn","tokenUsage":{"total":{"inputTokens":10,"cachedInputTokens":4,"outputTokens":3,"reasoningOutputTokens":2,"totalTokens":13}}}`)) + s.emitTerminal("native connection closed", true) + s.closeOut() + var done proto.DonePayload + for e := range out { + if e.Type == proto.TypeDone { + if err := e.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + if done.Usage.Tokens == nil || done.Usage.Tokens.TotalTokens != 13 || done.Usage.Tokens.ReasoningOutputTokens != 2 { + t.Fatalf("known usage missing from Done: %+v", done.Usage) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/skills.go b/apps/parsar-daemon/internal/agent/codex/skills.go new file mode 100644 index 000000000..3a79d55eb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/skills.go @@ -0,0 +1,52 @@ +package codex + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func effectiveAgentStateKey(req proto.PromptRequestPayload) string { + if strings.TrimSpace(req.AgentStateKey) != "" { + return req.AgentStateKey + } + if id := strings.TrimSpace(req.ConversationID); id != "" { + return "_legacy_conversation/" + id + "/codex" + } + if id := strings.TrimSpace(req.RunID); id != "" { + return "_legacy_run/" + id + "/codex" + } + return "" +} + +func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (string, error) { + rawSkills := req.AgentOptions["skills"] + root, err := agent.ManagedSkillsRoot("codex", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return "", fmt.Errorf("codex: resolve managed skills root: %w", err) + } + result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) + if err != nil { + return "", fmt.Errorf("codex: install skills: %w", err) + } + for _, warning := range result.Warnings { + logger.Warn("codex: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(result.SkillDirs) == 0 { + return "", nil + } + return root, nil +} + +func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { + if len(roots) == 0 { + return nil + } + _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}) + return err +} diff --git a/apps/parsar-daemon/internal/agent/codex/skills_test.go b/apps/parsar-daemon/internal/agent/codex/skills_test.go new file mode 100644 index 000000000..a03a64d11 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/skills_test.go @@ -0,0 +1,76 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + + result := make(chan error, 1) + go func() { + result <- setSkillExtraRoots(context.Background(), client.JSONRPCClient, []string{"/managed/skills"}) + }() + + decoder := json.NewDecoder(server.FromClient) + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params SkillsExtraRootsSetParams `json:"params"` + } + if err := decoder.Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.Method != "skills/extraRoots/set" { + t.Fatalf("method = %q", request.Method) + } + if len(request.Params.ExtraRoots) != 1 || request.Params.ExtraRoots[0] != "/managed/skills" { + t.Fatalf("params = %+v", request.Params) + } + response, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) + if _, err := server.ToClient.Write(append(response, '\n')); err != nil { + t.Fatalf("write response: %v", err) + } + if err := <-result; err != nil { + t.Fatalf("setSkillExtraRoots: %v", err) + } +} + +func TestPrepareManagedSkillsPrunesWhenPayloadOmitsSkills(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + stale := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills", "stale") + if err := os.MkdirAll(stale, 0o755); err != nil { + t.Fatal(err) + } + + root, err := prepareManagedSkills(context.Background(), nil, proto.PromptRequestPayload{ + AgentStateKey: "conv-1/agent-1/codex", + }) + if err != nil { + t.Fatalf("prepareManagedSkills: %v", err) + } + if root != "" { + t.Fatalf("root = %q, want empty", root) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale skill still exists: %v", err) + } +} + +func TestEffectiveAgentStateKeyFallsBackToConversation(t *testing.T) { + got := effectiveAgentStateKey(proto.PromptRequestPayload{ + ConversationID: "conv-legacy", + RunID: "run-ignored", + }) + if got != "_legacy_conversation/conv-legacy/codex" { + t.Fatalf("state key = %q", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go b/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go new file mode 100644 index 000000000..028fb678e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go @@ -0,0 +1,69 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" +) + +type subagentMetadata struct { + ID string `json:"id"` + ParentThreadID string `json:"parentThreadId"` + CreatedAt int64 `json:"createdAt"` + Source json.RawMessage `json:"source"` +} + +// Parent-filtered listing reads persisted metadata. thread/read may instead +// synthesize createdAt from a live snapshot before the first persistence flush. +func (s *Session) persistedSubagent(ctx context.Context, child, parent string, budget *int) (subagentMetadata, bool, error) { + var cursor *string + for range 4 { + if *budget == 0 { + return subagentMetadata{}, false, errors.New("metadata_query_budget") + } + *budget -= 1 + params := struct { + ParentThreadID string `json:"parentThreadId"` + UseStateDBOnly bool `json:"useStateDbOnly"` + ModelProviders []string `json:"modelProviders"` + SortKey string `json:"sortKey"` + SortDirection string `json:"sortDirection"` + Limit int `json:"limit"` + Cursor *string `json:"cursor,omitempty"` + }{parent, true, []string{}, "created_at", "desc", 100, cursor} + raw, err := s.rpc.requestWithTimeout(ctx, "thread/list", params, func(frame any) error { + return s.rpc.writeFrameContext(ctx, frame) + }, 0, nil) + if err != nil { + return subagentMetadata{}, false, errors.New("metadata_lookup_unavailable") + } + var page struct { + Data []subagentMetadata `json:"data"` + NextCursor *string `json:"nextCursor"` + } + if json.Unmarshal(raw, &page) != nil { + return subagentMetadata{}, false, errors.New("metadata_invalid") + } + for _, row := range page.Data { + if row.ID != child { + continue + } + var source struct { + SubAgent struct { + Spawn struct { + Parent string `json:"parent_thread_id"` + } `json:"thread_spawn"` + } `json:"subAgent"` + } + if json.Unmarshal(row.Source, &source) != nil || row.ParentThreadID != parent || source.SubAgent.Spawn.Parent != parent || row.CreatedAt <= 0 { + return subagentMetadata{}, false, errors.New("metadata_parent_or_creation_invalid") + } + return row, true, nil + } + if page.NextCursor == nil { + return subagentMetadata{}, false, nil + } + cursor = page.NextCursor + } + return subagentMetadata{}, false, errors.New("metadata_page_budget") +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations.go b/apps/parsar-daemon/internal/agent/codex/subagent_observations.go new file mode 100644 index 000000000..50ddbb3ec --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_observations.go @@ -0,0 +1,178 @@ +package codex + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const subagentLookupTimeout = 3 * time.Second + +type subagentCandidate struct{ child, parent, turn, item string } + +// One worker owns bounded metadata reads; the native reader only admits facts. +type subagentObservations struct { + mu sync.Mutex + seen map[string]bool + sealed bool + queue chan subagentCandidate + ctx context.Context + cancel context.CancelFunc + done chan struct{} + published chan struct{} +} + +func (s *Session) startSubagentObservations() { + ctx, cancel := context.WithCancel(s.cancelCtx) + s.subagents = &subagentObservations{seen: make(map[string]bool), queue: make(chan subagentCandidate, 64), + ctx: ctx, cancel: cancel, done: make(chan struct{}), published: make(chan struct{})} + go s.collectSubagentIdentities() +} + +func (s *Session) observeSubagentIdentity(raw json.RawMessage) { + o := s.subagents + if o == nil { + return + } + var event struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item struct { + ID string `json:"id"` + Type string `json:"type"` + Tool string `json:"tool"` + Status string `json:"status"` + SenderThreadID string `json:"senderThreadId"` + ReceiverThreadIDs []string `json:"receiverThreadIds"` + } `json:"item"` + } + if json.Unmarshal(raw, &event) != nil || event.Item.Type != "collabAgentToolCall" || + (event.Item.Tool != "spawnAgent" && event.Item.Tool != "resumeAgent") { + return + } + if !s.isRootTurn(event.ThreadID, event.TurnID) || event.Item.SenderThreadID != event.ThreadID || event.Item.ID == "" || event.Item.Status != "completed" { + s.subagentGap("discovery_unverified_or_late") + return + } + o.mu.Lock() + defer o.mu.Unlock() + for _, child := range event.Item.ReceiverThreadIDs { + if o.sealed || s.terminal.Load() || child == "" || child == event.ThreadID { + s.subagentGap("discovery_unverified_or_late") + continue + } + if o.seen[child] { + continue + } + if len(o.seen) == 64 { + s.subagentGap("discovery_capacity") + continue + } + o.seen[child] = true + o.queue <- subagentCandidate{child, event.ThreadID, event.TurnID, event.Item.ID} + } +} + +func (s *Session) collectSubagentIdentities() { + o := s.subagents + defer close(o.done) + budget := 64 + for { + select { + case <-o.ctx.Done(): + s.subagentGap("discovery_owner_ended") + return + case candidate, open := <-o.queue: + if !open { + return + } + s.resolveSubagentIdentity(candidate, &budget) + } + } +} + +func (s *Session) resolveSubagentIdentity(candidate subagentCandidate, budget *int) { + ctx, cancel := context.WithTimeout(s.subagents.ctx, subagentLookupTimeout) + defer cancel() + for { + metadata, found, err := s.persistedSubagent(ctx, candidate.child, candidate.parent, budget) + if err != nil { + s.subagentGap(err.Error()) + return + } + if found { + env, err := proto.NewEnvelope(proto.TypeSubagentIdentity, s.runID, proto.SubagentIdentityPayload{ + NativeID: metadata.ID, ParentNativeID: metadata.ParentThreadID, NativeCreatedAt: metadata.CreatedAt, + ParentTurnID: candidate.turn, SourceItemID: candidate.item, + }) + if err != nil || !s.sendWithin(ctx, env) { + s.subagentGap("identity_delivery_unavailable") + } + return + } + select { + case <-ctx.Done(): + s.subagentGap("metadata_not_persisted") + return + case <-time.After(100 * time.Millisecond): + } + } +} + +func (s *Session) subagentGap(reason string) { + s.cfg.logger.Warn("codex: subagent identity observation incomplete", "run_id", s.runID, "reason", reason) +} + +// The reader has already frozen terminal content/usage and sealed root mutation. +// It must remain free to read pending RPC replies while this worker settles. +func (s *Session) sendTerminal(events ...proto.Envelope) { + emit := func() { + for _, event := range events { + s.trySend(event) + } + } + o := s.subagents + if o == nil { + emit() + return + } + o.mu.Lock() + o.sealed = true + close(o.queue) + empty := len(o.seen) == 0 + o.mu.Unlock() + finish := func() { + timer := time.AfterFunc(subagentLookupTimeout, o.cancel) + <-o.done + timer.Stop() + o.cancel() + emit() + s.closeOut() + close(o.published) + } + if empty { + // No metadata read can depend on this reader. Startup failures still emit + // synchronously before run's deferred output cleanup. + finish() + } else { + go finish() + } +} + +func (s *Session) closeRunOutput() { + if o := s.subagents; o != nil { + o.mu.Lock() + sealed := o.sealed + o.mu.Unlock() + if sealed { + <-o.published + } else { + o.cancel() + <-o.done + } + } + s.closeOut() +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go new file mode 100644 index 000000000..bd0b20dea --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go @@ -0,0 +1,197 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const persistedChild = `{"id":"child","parentThreadId":"root","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}` +const completedSpawn = `{"threadId":"root","turnId":"turn","item":{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child"]}}` + +func identitySession(t *testing.T) (*Session, ServerSide, <-chan JsonRpcRequest, <-chan proto.Envelope) { + t.Helper() + client, server, cleanup := NewTestClient() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + out := make(chan proto.Envelope, 64) + s := &Session{runID: "run", rpc: client.JSONRPCClient, out: out, + cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} + if err := s.bindThreadResult(json.RawMessage(`{"thread":{"id":"root"}}`), ""); err != nil { + t.Fatal(err) + } + s.beginRootTurn("root", "turn") + s.startSubagentObservations() + s.registerHandlers() + requests := make(chan JsonRpcRequest, 64) + go func() { + defer close(requests) + decoder := json.NewDecoder(server.FromClient) + for { + var req JsonRpcRequest + if decoder.Decode(&req) != nil { + return + } + requests <- req + } + }() + t.Cleanup(func() { + cancel() + cleanup() + select { + case <-s.subagents.done: + case <-time.After(time.Second): + t.Error("metadata worker outlived owner") + } + }) + return s, server, requests, out +} + +func identityRequest(t *testing.T, requests <-chan JsonRpcRequest) JsonRpcRequest { + t.Helper() + select { + case request := <-requests: + return request + case <-time.After(time.Second): + t.Fatal("metadata request absent") + } + return JsonRpcRequest{} +} + +func identityReply(t *testing.T, server ServerSide, request JsonRpcRequest, result string) { + t.Helper() + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": json.RawMessage(result)}); err != nil { + t.Fatal(err) + } +} + +func TestSubagentIdentitySettlesBeforeFrozenRootTerminal(t *testing.T) { + s, server, requests, out := identitySession(t) + s.observeSubagentIdentity(json.RawMessage(completedSpawn)) + s.observeSubagentIdentity(json.RawMessage(completedSpawn)) + request := identityRequest(t, requests) + if request.Method != "thread/list" { + t.Fatal(request.Method) + } + params, _ := json.Marshal(request.Params) + var query map[string]any + _ = json.Unmarshal(params, &query) + if query["parentThreadId"] != "root" || query["useStateDbOnly"] != true || query["limit"] != float64(100) || len(query["modelProviders"].([]any)) != 0 { + t.Fatal("lookup is not explicitly persisted and parent-scoped", string(params)) + } + notify := func(method, raw string) { + t.Helper() + if err := SendNotification(server, method, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + notify("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"root-message","text":"root result"}}`) + notify("turn/completed", `{"threadId":"child","turn":{"id":"child-turn","status":"completed"}}`) + notify("turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"completed"}}`) + barrier := make(chan struct{}, 1) + s.rpc.OnNotification("test/barrier", func(json.RawMessage) { barrier <- struct{}{} }) + notify("test/barrier", `{}`) + select { + case <-barrier: + case <-time.After(time.Second): + t.Fatal("root terminal blocked the native reader") + } + notify("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"late","text":"late mutation"}}`) + identityReply(t, server, request, `{"data":[`+persistedChild+`],"nextCursor":null}`) + var kinds []string + for { + select { + case env, open := <-out: + if !open { + if len(kinds) != 2 || kinds[0] != proto.TypeSubagentIdentity || kinds[1] != proto.TypeDone { + t.Fatal("identity missing, duplicated or delivered after root terminal", kinds) + } + return + } + kinds = append(kinds, env.Type) + if env.Type == proto.TypeSubagentIdentity { + var value proto.SubagentIdentityPayload + if env.DecodePayload(&value) != nil || value.NativeID != "child" || value.ParentNativeID != "root" || value.NativeCreatedAt != 100 || value.ParentTurnID != "turn" || value.SourceItemID != "spawn" { + t.Fatal(value) + } + } + if env.Type == proto.TypeDone { + var done proto.DonePayload + if env.DecodePayload(&done) != nil || done.Content != "root result" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" { + t.Fatal("pending metadata changed frozen root outcome", done) + } + } + case <-time.After(time.Second): + t.Fatal("settled metadata did not release root terminal") + } + } +} + +func TestSubagentIdentityRejectsUnverifiedMetadata(t *testing.T) { + for _, row := range []string{ + `{"id":"child","parentThreadId":"foreign","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}`, + `{"id":"child","parentThreadId":"root","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"foreign"}}}}`, + `{"id":"child","parentThreadId":"root","createdAt":100,"source":"cli"}`, + `{"id":"child","parentThreadId":"root","source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}`, + } { + t.Run(row, func(t *testing.T) { + s, server, requests, out := identitySession(t) + s.observeSubagentIdentity(json.RawMessage(completedSpawn)) + request := identityRequest(t, requests) + s.emitDone("root result", nil) + identityReply(t, server, request, `{"data":[`+row+`],"nextCursor":null}`) + select { + case env := <-out: + if env.Type != proto.TypeDone { + t.Fatal("unverified identity escaped", env.Type) + } + case <-time.After(time.Second): + t.Fatal("failed verification blocked terminal") + } + }) + } +} + +func TestSubagentIdentityRetriesPersistenceAndUsesOpaqueCursor(t *testing.T) { + s, server, requests, out := identitySession(t) + s.observeSubagentIdentity(json.RawMessage(completedSpawn)) + identityReply(t, server, identityRequest(t, requests), `{"data":[],"nextCursor":null}`) + identityReply(t, server, identityRequest(t, requests), `{"data":[],"nextCursor":"opaque-native-cursor"}`) + request := identityRequest(t, requests) + params, _ := json.Marshal(request.Params) + var query map[string]any + _ = json.Unmarshal(params, &query) + if query["cursor"] != "opaque-native-cursor" { + t.Fatal(string(params)) + } + identityReply(t, server, request, `{"data":[`+persistedChild+`],"nextCursor":null}`) + select { + case env := <-out: + if env.Type != proto.TypeSubagentIdentity { + t.Fatal(env.Type) + } + case <-time.After(time.Second): + t.Fatal("persisted child not observed") + } +} + +func TestSubagentIdentityTerminalDeadlineBoundsQueuedDiscovery(t *testing.T) { + s, _, requests, out := identitySession(t) + for i := range 64 { + s.observeSubagentIdentity(json.RawMessage(fmt.Sprintf(`{"threadId":"root","turnId":"turn","item":{"id":"spawn-%d","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child-%d"]}}`, i, i))) + } + identityRequest(t, requests) // The native reader accepts the request but gives no reply. + started := time.Now() + s.emitDone("root result", nil) + select { + case env := <-out: + if env.Type != proto.TypeDone || time.Since(started) > 4*time.Second { + t.Fatal("queued lookups extended root lifetime", env.Type) + } + case <-time.After(5 * time.Second): + t.Fatal("metadata worker did not respect shared terminal deadline") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagents.go b/apps/parsar-daemon/internal/agent/codex/subagents.go new file mode 100644 index 000000000..710e12215 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagents.go @@ -0,0 +1,13 @@ +package codex + +import "slices" + +func disableSubagents(plan *SessionPlan) { + // Native v1 and v2 controls must override operator feature preferences. + for _, feature := range []string{"multi_agent", "multi_agent_v2"} { + plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) + if !slices.Contains(plan.DisableFeatures, feature) { + plan.DisableFeatures = append(plan.DisableFeatures, feature) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagents_test.go b/apps/parsar-daemon/internal/agent/codex/subagents_test.go new file mode 100644 index 000000000..9e725e0cf --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagents_test.go @@ -0,0 +1,18 @@ +package codex + +import ( + "reflect" + "testing" +) + +func TestDisableSubagentsOverridesNativeFeaturePreferences(t *testing.T) { + plan := SessionPlan{ + EnableFeatures: []string{"multi_agent", "unrelated", "multi_agent_v2"}, + DisableFeatures: []string{"another", "multi_agent"}, + } + disableSubagents(&plan) + if !reflect.DeepEqual(plan.EnableFeatures, []string{"unrelated"}) || + !reflect.DeepEqual(plan.DisableFeatures, []string{"another", "multi_agent", "multi_agent_v2"}) { + t.Fatal(plan.EnableFeatures, plan.DisableFeatures) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go new file mode 100644 index 000000000..b03c839eb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment.go @@ -0,0 +1,83 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +const toolEnvironmentHookSource = "/etc/codex/runtime-hooks" +const toolEnvironmentHookCommand = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" + +func prepareSystemToolAnchor() error { + const anchor = "/tmp/parsar-tool-root" + if err := os.Mkdir(anchor, 0500); err != nil && !errors.Is(err, os.ErrExist) { + return errors.New("codex: system tool temporary anchor unavailable") + } + actual, err := filepath.EvalSymlinks(anchor) + entries, readErr := os.ReadDir(anchor) + if err != nil || actual != anchor || readErr != nil || len(entries) != 0 { + return errors.New("codex: system tool temporary anchor is not an empty canonical directory") + } + return nil +} + +func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) + if err != nil { + return errors.New("codex: initialized tool hook unavailable") + } + var response struct { + Data []struct { + CWD string `json:"cwd"` + Hooks []struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` + } `json:"hooks"` + Errors []json.RawMessage `json:"errors"` + } `json:"data"` + } + if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { + return errors.New("codex: initialized tool hook configuration unavailable") + } + for _, hook := range response.Data[0].Hooks { + if hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" { + return nil + } + } + return errors.New("codex: required initialized tool hook missing") +} + +func (s *Session) onToolEnvironmentHook(raw json.RawMessage) { + if !s.toolEnvironment { + return + } + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Run struct { + SourcePath string `json:"sourcePath"` + Status string `json:"status"` + } `json:"run"` + } + if json.Unmarshal(raw, ¬ification) != nil || notification.Run.SourcePath != toolEnvironmentHookSource || !s.isRootTurn(notification.ThreadID, notification.TurnID) { + return + } + if notification.Run.Status == "failed" { + // Native hook process failures can run the original command before this + // notification. Stop subsequent work and report failure, never success. + s.emitTerminal("codex: initialized tool configuration failed; prior command effects may exist", true) + s.cancelFn() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go new file mode 100644 index 000000000..b1dbd16b1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go @@ -0,0 +1,75 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestInitializedToolHookReadiness(t *testing.T) { + for _, mode := range []string{"ready", "disabled", "unmanaged", "async", "wrong command", "errors"} { + t.Run(mode, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- verifyToolEnvironmentHook(ctx, client.JSONRPCClient, "/workspace") }() + var request struct { + ID, Method string + Params struct { + CWDs []string `json:"cwds"` + } + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != "hooks/list" || len(request.Params.CWDs) != 1 || request.Params.CWDs[0] != "/workspace" { + t.Fatal("wrong hook lookup") + } + hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} + entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} + switch mode { + case "disabled": + hook["enabled"] = false + case "unmanaged": + hook["isManaged"] = false + case "async": + hook["async"] = true + case "wrong command": + hook["command"] = "/workspace/untrusted" + case "errors": + entry["errors"] = []any{"cannot load hook"} + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { + t.Fatal(err) + } + if err := <-done; (err == nil) != (mode == "ready") { + t.Fatal("hook admission", mode, err) + } + }) + } +} + +func TestInitializedToolHookFailureStopsRunWithoutSuccess(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + out := make(chan proto.Envelope, 4) + s := &Session{toolEnvironment: true, runID: "run", cancelCtx: ctx, cancelFn: cancel, out: out, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} + s.setThreadID("thread") + s.beginRootTurn("thread", "turn") + s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"foreign","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) + if s.terminal.Load() { + t.Fatal("foreign hook ended run") + } + s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"thread","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) + if !s.terminal.Load() || ctx.Err() == nil { + t.Fatal("failed hook did not stop execution") + } + if len(out) != 2 || (<-out).Type != proto.TypeError || (<-out).Type != proto.TypeDone { + t.Fatal("failed hook reported wrong terminal events") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_observations.go b/apps/parsar-daemon/internal/agent/codex/tool_observations.go new file mode 100644 index 000000000..ecc494c01 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_observations.go @@ -0,0 +1,125 @@ +package codex + +import ( + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Raw fields bypass ThreadItem's legacy any fields to preserve structured values. +type toolObservationSource struct { + ThreadItem + Cwd *string `json:"cwd"` + Arguments json.RawMessage `json:"arguments"` + Changes json.RawMessage `json:"changes"` + Output *string `json:"aggregatedOutput"` + DurationMS *int64 `json:"durationMs"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ContentItems *[]functionContent `json:"contentItems"` + Success *bool `json:"success"` + Action *proto.ToolWebSearchAction `json:"action"` +} + +func normalizeToolObservation(id, stage string, raw json.RawMessage) (*proto.ToolObservation, error) { + var n toolObservationSource + if err := json.Unmarshal(raw, &n); err != nil { + return nil, err + } + if n.ID != id || id == "" || (stage != "before" && stage != "after") { + return nil, errors.New("invalid native tool identity or stage") + } + out := &proto.ToolObservation{Status: observationStatus(n.Status, stage)} + switch n.Type { + case "commandExecution": + if n.Command == "" { + return nil, errors.New("missing command") + } + out.Kind, out.Command, out.Cwd, out.DurationMS = "command", n.Command, n.Cwd, n.DurationMS + if n.ExitCode != nil { + value := int64(*n.ExitCode) + out.ExitCode = &value + } + if n.Output != nil { + out.Output, _ = json.Marshal(*n.Output) + } + case "mcpToolCall": + if n.Server == "" || n.Tool == "" { + return nil, errors.New("missing MCP identity") + } + out.Kind, out.Server, out.Name = "mcp", n.Server, n.Tool + out.Arguments, out.Output, out.Error = n.Arguments, n.Result, n.Error + case "dynamicToolCall": + if n.Tool == "" { + return nil, errors.New("missing function identity") + } + out.Kind, out.Name, out.Arguments = "function", n.Tool, n.Arguments + if n.Namespace != "" { + out.Name = n.Namespace + "::" + n.Tool + } + if stage == "after" { + if n.Success != nil && !*n.Success { + out.Status = "failed" + } + if n.ContentItems != nil { + content := make([]proto.FunctionResultContent, 0, len(*n.ContentItems)) + for _, part := range *n.ContentItems { + var value proto.FunctionResultContent + switch part.Type { + case "inputText": + value = proto.FunctionResultContent{Type: "input_text", Text: part.Text} + case "inputImage": + value = proto.FunctionResultContent{Type: "input_image", ImageURL: part.ImageURL} + default: + return nil, errors.New("unsupported function result content") + } + content = append(content, value) + } + if err := (proto.FunctionResultPayload{Content: content}).ValidateContent(); err != nil { + return nil, err + } + out.Content = &content + } + } + case "fileChange": + out.Kind, out.Name = "function", "apply_patch" + changes := n.Changes + if len(changes) == 0 { + changes = json.RawMessage("null") + } + out.Arguments, _ = json.Marshal(struct { + Changes json.RawMessage `json:"changes"` + }{changes}) + case "webSearch": + out.Kind, out.Action = "web_search", n.Action + if out.Action != nil { + switch out.Action.Type { + case "openPage": + out.Action.Type = "open_page" + case "findInPage": + out.Action.Type = "find_in_page" + case "search", "open_page", "find_in_page", "other": + default: + return nil, errors.New("unsupported web action") + } + } + default: + return nil, errors.New("unsupported native tool observation") + } + return out, nil +} + +func observationStatus(native, stage string) string { + if stage == "before" { + return "in_progress" + } + switch native { + case "", "completed": + return "completed" + case "failed", "declined": + return "failed" + default: + return "incomplete" + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_observations_test.go b/apps/parsar-daemon/internal/agent/codex/tool_observations_test.go new file mode 100644 index 000000000..8875959d0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_observations_test.go @@ -0,0 +1,39 @@ +package codex + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestToolObservationDetails(t *testing.T) { + cases := []struct{ native, expected string }{ + {`{"id":"x","type":"commandExecution","command":"exit 2","cwd":"/work","status":"failed","aggregatedOutput":"","exitCode":2,"durationMs":37}`, `{"kind":"command","status":"failed","command":"exit 2","cwd":"/work","exit_code":2,"duration_ms":37,"output":""}`}, + {`{"id":"x","type":"mcpToolCall","server":"reference","tool":"lookup","status":"failed","arguments":{"number":9007199254740993},"result":null,"error":{"message":"failed"}}`, `{"kind":"mcp","status":"failed","name":"lookup","server":"reference","arguments":{"number":9007199254740993},"output":null,"error":{"message":"failed"}}`}, + {`{"id":"x","type":"dynamicToolCall","tool":"lookup","namespace":"reference","status":"completed","success":false,"arguments":[1],"contentItems":[{"type":"inputText","text":""},{"type":"inputImage","imageUrl":"data:image/png;base64,abc"}]}`, `{"kind":"function","status":"failed","name":"reference::lookup","arguments":[1],"content":[{"type":"input_text","text":""},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}`}, + {`{"id":"x","type":"dynamicToolCall","tool":"lookup","contentItems":[]}`, `{"kind":"function","status":"completed","name":"lookup","content":[]}`}, + {`{"id":"x","type":"fileChange","changes":[{"diff":"after","number":9007199254740993}]}`, `{"kind":"function","status":"completed","name":"apply_patch","arguments":{"changes":[{"diff":"after","number":9007199254740993}]}}`}, + {`{"id":"x","type":"webSearch","action":{"type":"openPage","url":"https://example.com"}}`, `{"kind":"web_search","status":"completed","action":{"type":"open_page","url":"https://example.com"}}`}, + } + for _, c := range cases { + value, err := normalizeToolObservation("x", "after", []byte(c.native)) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(value) + if err != nil || !bytes.Equal(raw, []byte(c.expected)) { + t.Fatalf("got %s; want %s; error %v", raw, c.expected, err) + } + } +} + +func TestToolObservationRejectsUnrepresentableDetails(t *testing.T) { + for _, raw := range []string{ + `{"id":"other","type":"commandExecution","command":"pwd"}`, + `{"id":"x","type":"dynamicToolCall","tool":"lookup","contentItems":[{"type":"inputAudio"}]}`, + } { + if _, err := normalizeToolObservation("x", "after", []byte(raw)); err == nil { + t.Fatal("invalid observation accepted") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/verbosity_test.go b/apps/parsar-daemon/internal/agent/codex/verbosity_test.go new file mode 100644 index 000000000..ea4b511ba --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/verbosity_test.go @@ -0,0 +1,33 @@ +package codex + +import ( + "reflect" + "testing" +) + +func TestVerbosityConfiguration(t *testing.T) { + for _, mode := range []string{"", "low", "medium", "high"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + opts := map[string]any{} + var want [][2]string + if mode != "" { + opts["model_verbosity"] = mode + want = [][2]string{{"model_verbosity", `"` + mode + `"`}} + } + plan, err := BuildSessionPlan("run", "state", "", opts) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if !reflect.DeepEqual(plan.ExtraConfig, want) { + t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) + } + }) + } + for _, value := range []any{nil, "", "enabled", true, 1, map[string]any{}, []any{}} { + if _, err := BuildSessionPlan("run", "state", "", map[string]any{"model_verbosity": value}); err == nil { + t.Fatalf("accepted invalid model_verbosity %T", value) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/version.go b/apps/parsar-daemon/internal/agent/codex/version.go new file mode 100644 index 000000000..f36fc527d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/version.go @@ -0,0 +1,60 @@ +package codex + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" +) + +// InstallURL points operators at the Codex install instructions when +// the daemon can see the adapter but not the CLI binary. +const InstallURL = "https://github.com/openai/codex" + +// defaultBinary is the executable to probe and spawn: binpath.Codex() +// honours the PARSAR_CODEX_BIN override so a bare-name PATH lookup can +// be bypassed in images where PATH is not under our control. A function +// rather than a const so the env is read at call time. +func defaultBinary() string { return binpath.Codex() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish an +// install problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("codex CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). Matches +// the CheckCLIAvailable signature of the claudecode and opencode adapters +// so connect.go's preflight loop treats every engine uniformly. +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + if strings.TrimSpace(binary) == "" { + binary = defaultBinary() + } + if _, lookErr := exec.LookPath(binary); lookErr != nil { + return "", fmt.Errorf("%w: %s", ErrCLINotFound, binary) + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, binary, "--version") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("codex --version failed: %s", msg) + } + out := strings.TrimSpace(stdout.String()) + if i := strings.IndexByte(out, '\n'); i >= 0 { + out = out[:i] + } + if out == "" { + return "", fmt.Errorf("codex --version returned empty output") + } + return out, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/web_search_test.go b/apps/parsar-daemon/internal/agent/codex/web_search_test.go new file mode 100644 index 000000000..6de2ae5ac --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/web_search_test.go @@ -0,0 +1,33 @@ +package codex + +import ( + "reflect" + "testing" +) + +func TestWebSearchConfiguration(t *testing.T) { + for _, mode := range []string{"", "disabled", "cached", "live"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + opts := map[string]any{} + var want [][2]string + if mode != "" { + opts["web_search"] = mode + want = [][2]string{{"web_search", `"` + mode + `"`}} + } + plan, err := BuildSessionPlan("run", "state", "", opts) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if !reflect.DeepEqual(plan.ExtraConfig, want) { + t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) + } + }) + } + for _, value := range []any{nil, "", "enabled", true, 1, map[string]any{}, []any{}} { + if _, err := BuildSessionPlan("run", "state", "", map[string]any{"web_search": value}); err == nil { + t.Fatalf("accepted invalid web_search %T", value) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_directory.go b/apps/parsar-daemon/internal/agent/codex/workspace_directory.go new file mode 100644 index 000000000..ac7107bc5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_directory.go @@ -0,0 +1,115 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +const workspaceDirectoryMaxEntries = 4096 + +var _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) +var _ agent.WorkspaceDirectoryLister = (*Session)(nil) + +func (p *Prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + p.mu.Lock() + if p.claimed || p.closed || p.started { + p.mu.Unlock() + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable + } + frame, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) + p.mu.Unlock() + if err != nil { + return agent.WorkspaceDirectoryResult{}, err + } + return p.session.harness.readWorkspaceDirectory(ctx, frame, maxEntries) +} + +func (s *Session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + frame, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) + if err != nil { + return agent.WorkspaceDirectoryResult{}, err + } + return s.harness.readWorkspaceDirectory(ctx, frame, maxEntries) +} + +func (s *Session) admitWorkspaceDirectory(ctx context.Context, path string, maxEntries int) ([]byte, error) { + if err := s.workspaceReadAvailable(ctx); err != nil { + return nil, err + } + if !workspaceRelativePath(path, true) || maxEntries < 1 || maxEntries > workspaceDirectoryMaxEntries { + return nil, agent.ErrWorkspaceReadInvalid + } + frame, err := json.Marshal(struct { + Environment string `json:"environment_id"` + Operation string `json:"operation"` + Path string `json:"path"` + MaxEntries int `json:"max_entries"` + }{s.harness.environment, "list_directory", path, maxEntries}) + if err != nil { + return nil, agent.ErrWorkspaceReadInvalid + } + return s.claimWorkspaceRead(frame) +} + +func (h *privateHarness) readWorkspaceDirectory(ctx context.Context, frame []byte, maxEntries int) (result agent.WorkspaceDirectoryResult, err error) { + err = h.exchangeWorkspaceRead(ctx, frame, maxEntries*2048+1024, func(response []byte) error { + var decodeErr error + result, decodeErr = decodeWorkspaceDirectory(response, maxEntries) + return decodeErr + }) + return result, err +} + +func decodeWorkspaceDirectory(frame []byte, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + var response struct { + Error *string `json:"error"` + Directory *struct { + Entries *[]struct { + Name string `json:"name"` + Kind string `json:"kind"` + SizeBytes *int64 `json:"size_bytes"` + } `json:"entries"` + Truncated *bool `json:"truncated"` + } `json:"directory"` + } + invalid := agent.ErrWorkspaceReadUncertain + decoder := json.NewDecoder(bytes.NewReader(frame)) + decoder.DisallowUnknownFields() + if decoder.Decode(&response) != nil || decoder.Decode(new(any)) != io.EOF || (response.Error == nil) == (response.Directory == nil) { + return agent.WorkspaceDirectoryResult{}, invalid + } + if response.Error != nil { + return agent.WorkspaceDirectoryResult{}, workspaceReadError(*response.Error) + } + directory := response.Directory + if directory.Entries == nil || directory.Truncated == nil || len(*directory.Entries) > maxEntries { + return agent.WorkspaceDirectoryResult{}, invalid + } + result := agent.WorkspaceDirectoryResult{Entries: make([]agent.WorkspaceDirectoryEntry, 0, len(*directory.Entries)), Truncated: *directory.Truncated} + seen := make(map[string]bool, len(*directory.Entries)) + for _, entry := range *directory.Entries { + if !workspaceRelativePath(entry.Name, false) || strings.Contains(entry.Name, "/") || seen[entry.Name] { + return agent.WorkspaceDirectoryResult{}, invalid + } + seen[entry.Name] = true + switch entry.Kind { + case "file": + if entry.SizeBytes == nil || *entry.SizeBytes < 0 { + return agent.WorkspaceDirectoryResult{}, invalid + } + case "directory", "symlink", "other": + if entry.SizeBytes != nil { + return agent.WorkspaceDirectoryResult{}, invalid + } + default: + return agent.WorkspaceDirectoryResult{}, invalid + } + result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) + } + return result, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go new file mode 100644 index 000000000..b243b1843 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go @@ -0,0 +1,98 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +const workspaceDirectorySuccess = `{"directory":{"entries":[{"name":"result.bin","kind":"file","size_bytes":4},{"name":"subdir","kind":"directory","size_bytes":null}],"truncated":false}}` + "\n" + +func TestWorkspaceDirectoryValidatesCompleteResponse(t *testing.T) { + result, err := decodeWorkspaceDirectory([]byte(workspaceDirectorySuccess), 2) + if err != nil || result.Truncated || len(result.Entries) != 2 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 4 || result.Entries[1].SizeBytes != nil { + t.Fatal(result, err) + } + for _, frame := range []string{ + `{"directory":{"entries":[],"truncated":null}}`, + `{"directory":{"entries":null,"truncated":false}}`, + `{"directory":{"entries":[{"name":"../other","kind":"file","size_bytes":4}],"truncated":false}}`, + `{"directory":{"entries":[{"name":"file","kind":"file"}],"truncated":false}}`, + `{"directory":{"entries":[{"name":"link","kind":"symlink","size_bytes":1}],"truncated":false}}`, + `{"directory":{"entries":[{"name":"file","kind":"file","size_bytes":-1}],"truncated":false}}`, + `{"directory":{"entries":[{"name":"same","kind":"directory"},{"name":"same","kind":"directory"}],"truncated":false}}`, + workspaceDirectorySuccess + `{}`, + } { + if got, err := decodeWorkspaceDirectory([]byte(frame), 2); !errors.Is(err, agent.ErrWorkspaceReadUncertain) || len(got.Entries) != 0 { + t.Fatalf("malformed directory succeeded: %+v %v", got, err) + } + } + if _, err := decodeWorkspaceDirectory([]byte(workspaceDirectorySuccess), 1); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("oversized response accepted", err) + } + result, err = decodeWorkspaceDirectory([]byte(`{"directory":{"entries":[],"truncated":true}}`), 1) + if err != nil || !result.Truncated { + t.Fatal("truncated observation lost", result, err) + } +} + +func TestWorkspaceDirectorySharesReadOwnership(t *testing.T) { + session, listener := workspaceReadFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := session.ListWorkspaceDirectory(ctx, "", 2) + done <- err + }() + conn, frame := workspaceReadConnection(t, listener) + if conn == nil { + return + } + defer conn.Close() + var request map[string]any + if json.Unmarshal(frame, &request) != nil || request["environment_id"] != "frozen-environment" || request["path"] != "" || request["operation"] != "list_directory" || request["max_entries"] != float64(2) { + t.Fatalf("directory binding changed: %s", frame) + } + cancel() + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { + t.Fatal("directory detach freed shared slot", err) + } + select { + case err := <-done: + t.Fatal("directory wait discarded", err) + default: + } + _, _ = conn.Write([]byte(workspaceDirectorySuccess)) + if err := <-done; err != nil { + t.Fatal(err) + } + for _, path := range []string{"/root", "a/../b", "a//b", ".", "../x", "a\\b", "a\n"} { + if _, err := session.ListWorkspaceDirectory(t.Context(), path, 2); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatal("invalid directory admitted", path, err) + } + } +} + +func TestWorkspaceDirectoryUncertaintyFencesFileReads(t *testing.T) { + session, listener := workspaceReadFixture(t) + done := make(chan struct{}) + go func() { + defer close(done) + conn, _ := workspaceReadConnection(t, listener) + if conn != nil { + _, _ = conn.Write([]byte("{broken}\n")) + _ = conn.Close() + } + }() + if _, err := session.ListWorkspaceDirectory(t.Context(), "", 2); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal(err) + } + <-done + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("uncertainty lost between operations", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go new file mode 100644 index 000000000..38c8f4908 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go @@ -0,0 +1,87 @@ +package codex + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Preserve only process/transport essentials, never ambient model credentials, +// native configuration selectors or runtime injection variables. +func workspaceReadEnvironment(environment []string) []string { + var result []string + for _, value := range environment { + key, _, _ := strings.Cut(value, "=") + switch key { + case "HOME", "PATH", "TMPDIR", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy": + result = append(result, value) + } + } + return result +} + +func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { + if err := p.Close(); err != nil && p.workspaceReadOnly { + // A failed constructor still returns its cleanup owner to the dispatcher. + return p, errors.Join(cause, err) + } + return nil, cause +} + +// SupportsWorkspaceReadPreparation checks local prerequisites, not public admission. +// Native connection and the installed executor helper are verified per operation. +func SupportsWorkspaceReadPreparation() bool { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + return false + } + binary, helper := os.Getenv("PARSAR_CODEX_HARNESS_BIN"), os.Getenv("PARSAR_CODEX_DIRECTORY_HELPER") + if !filepath.IsAbs(binary) || filepath.Clean(binary) != binary || !filepath.IsAbs(helper) || filepath.Clean(helper) != helper { + return false + } + info, err := os.Stat(binary) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 +} + +func workspaceReadPlan(req proto.PromptRequestPayload) (SessionPlan, error) { + root, err := paths.Root() + if err != nil { + return SessionPlan{}, err + } + base := filepath.Join(root, "parsar-daemon", "workspace-read") + if err := os.MkdirAll(base, 0o700); err != nil { + return SessionPlan{}, err + } + state, err := os.MkdirTemp(base, "read-") + if err != nil { + return SessionPlan{}, err + } + plan := SessionPlan{Cwd: state, Env: []string{"CODEX_HOME=" + state, "DISABLE_TELEMETRY=1"}, Cleanup: func() { _ = os.RemoveAll(state) }} + configureRemoteEnvironment(&plan, *req.RemoteEnvironment) + return plan, nil +} + +// A failed Close retains state until the same child exits. A successful Close +// performs cleanup synchronously, including another caller's ongoing cleanup. +func readPreparationCleanup(rpc *JSONRPCClient, cleanup func()) func() { + return func() { + rpc.mu.Lock() + cmd := rpc.cmd + rpc.mu.Unlock() + if cmd == nil || cmd.Process == nil { + cleanup() + return + } + select { + case <-rpc.Done(): + cleanup() + default: + go func() { <-rpc.Done(); cleanup() }() + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go new file mode 100644 index 000000000..2e91abe49 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go @@ -0,0 +1,40 @@ +package codex + +import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "testing" +) + +func TestFailedReadPreparationRetainsUnreapedOwner(t *testing.T) { + state := t.TempDir() + cmd := exec.Command("sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + rpc := NewJSONRPCClient(JSONRPCConfig{}) + rpc.cmd, rpc.alive = cmd, true + var reap sync.Once + t.Cleanup(func() { _ = cmd.Process.Kill(); reap.Do(rpc.waitChild) }) + _, cancel := context.WithCancel(t.Context()) + cleanup := readPreparationCleanup(rpc, sync.OnceFunc(func() { _ = os.RemoveAll(state) })) + p := &Prepared{workspaceReadOnly: true, session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cwd: state, Cleanup: cleanup}} + cause := errors.New("controlled initialization failure") + owner, err := p.preparationFailed(cause) + if owner != p || !errors.Is(err, cause) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("construction failure discarded an unconfirmed resource", err) + } + if _, err := os.Stat(state); err != nil { + t.Fatal("unreaped owner lost temporary state", err) + } + reap.Do(rpc.waitChild) + if err := owner.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatal("confirmed cleanup retained temporary state", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go new file mode 100644 index 000000000..61b594e0e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go @@ -0,0 +1,87 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceReadPreparationLeavesExecutionStateUntouched(t *testing.T) { + privateHarnessTestHome(t) + request, cfg, root := preparationFixture(t) + cfg.harnessBinary = cfg.codexBinary + // Put fixture controls in the executable, not in the sanitized child environment. + body, err := os.ReadFile(cfg.codexBinary) + if err != nil { + t.Fatal(err) + } + controls := "export PARSAR_PREPARATION_FAKE=1 PARSAR_PRIVATE_HARNESS_FAKE=1\n" + for _, key := range []string{"PARSAR_PREPARATION_FRAMES", "PARSAR_PREPARATION_STATUS"} { + controls += "export " + key + "='" + strings.ReplaceAll(os.Getenv(key), "'", "'\\''") + "'\n" + } + if err := os.WriteFile(cfg.codexBinary, []byte(strings.Replace(string(body), "exec ", controls+"exec ", 1)), 0700); err != nil { + t.Fatal(err) + } + request.WorkspaceReadOnly = true + request.WorkDir, request.AgentOptions, request.FunctionTools = "", nil, nil + stable, err := allocCodexHome(request.AgentStateKey) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"config.toml", "history.jsonl"} { + if err := os.WriteFile(filepath.Join(stable, name), []byte("preserve original state"), 0600); err != nil { + t.Fatal(err) + } + } + p, err := newPreparation(t.Context(), request, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + if p.plan.Cwd == stable || privateHarnessEnv(p.plan.Env, "CODEX_HOME") != p.plan.Cwd || p.plan.Model != "" || p.plan.ModelProvider != "" { + t.Fatal("read preparation reused execution configuration") + } + if _, err := p.Start(t.Context(), "run", "do work", make(chan proto.Envelope, 1)); err == nil { + t.Fatal("read-only owner started execution") + } + assertPreparationOnly(t, root) + for _, name := range []string{"config.toml", "history.jsonl"} { + data, err := os.ReadFile(filepath.Join(stable, name)) + if err != nil || string(data) != "preserve original state" { + t.Fatal("original execution state changed", name, err) + } + } + if err := p.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p.plan.Cwd); !os.IsNotExist(err) { + t.Fatal("successful close left read state", err) + } +} + +func TestWorkspaceReadEnvironmentExcludesAmbientCredentials(t *testing.T) { + input := []string{"PATH=/usr/bin", "HOME=/operator", "HTTPS_PROXY=http://proxy", "OPENAI_API_KEY=sentinel", "ANTHROPIC_API_KEY=sentinel", "CUSTOM_PROVIDER_SECRET=sentinel", "CODEX_HOME=/execution", "LD_PRELOAD=/inject", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=old"} + got := workspaceReadEnvironment(input) + if strings.Join(got, "\n") != strings.Join(input[:3], "\n") { + t.Fatal("read child inherited execution configuration or credentials") + } +} + +func TestWorkspaceReadPreparationRejectsExecutionConfiguration(t *testing.T) { + request, cfg, _ := preparationFixture(t) + request.WorkspaceReadOnly = true + if _, err := newPreparation(context.Background(), request, cfg); err == nil { + t.Fatal("execution settings accepted as read-only") + } + request.WorkDir, request.AgentOptions, request.FunctionTools = "", nil, nil + if !proto.ValidWorkspaceReadPreparation(request) { + t.Fatal("minimal read request rejected") + } + if _, err := newPreparation(context.Background(), request, cfg); err == nil { + t.Fatal("stock harness admitted a read-only preparation") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_read.go b/apps/parsar-daemon/internal/agent/codex/workspace_read.go new file mode 100644 index 000000000..6d86fe954 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_read.go @@ -0,0 +1,193 @@ +package codex + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "io/fs" + "net" + "path/filepath" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +const workspaceReadMaxBytes = 8 << 20 +const workspaceReadTimeout = 12 * time.Second + +var _ agent.WorkspaceReader = (*Prepared)(nil) +var _ agent.WorkspaceReader = (*Session)(nil) + +func (p *Prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + p.mu.Lock() + if p.claimed || p.closed || p.started { + p.mu.Unlock() + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable + } + frame, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) + p.mu.Unlock() + if err != nil { + return agent.WorkspaceReadResult{}, err + } + return p.session.harness.readWorkspaceFile(ctx, frame, maxBytes) +} + +func (s *Session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + frame, err := s.admitWorkspaceRead(ctx, path, maxBytes) + if err != nil { + return agent.WorkspaceReadResult{}, err + } + return s.harness.readWorkspaceFile(ctx, frame, maxBytes) +} + +func (s *Session) admitWorkspaceRead(ctx context.Context, path string, maxBytes int) ([]byte, error) { + if err := s.workspaceReadAvailable(ctx); err != nil { + return nil, err + } + if maxBytes < 1 || maxBytes > workspaceReadMaxBytes || !workspaceRelativePath(path, false) { + return nil, agent.ErrWorkspaceReadInvalid + } + frame, err := json.Marshal(struct { + Environment string `json:"environment_id"` + Operation string `json:"operation"` + Path string `json:"path"` + MaxBytes int `json:"max_bytes"` + }{s.harness.environment, "read", path, maxBytes}) + if err != nil { + return nil, agent.ErrWorkspaceReadInvalid + } + return s.claimWorkspaceRead(frame) +} + +func (s *Session) workspaceReadAvailable(ctx context.Context) error { + if s.harness == nil { + return agent.ErrWorkspaceReadUnsupported + } + if ctx == nil || ctx.Err() != nil || s.cancelCtx.Err() != nil || s.cancelled.Load() || s.terminal.Load() || !s.rpc.Alive() { + return agent.ErrWorkspaceReadUnavailable + } + return nil +} + +func workspaceRelativePath(path string, allowRoot bool) bool { + if path == "" { + return allowRoot + } + if len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { + return false + } + for _, part := range strings.Split(path, "/") { + if part == "" || part == "." || part == ".." { + return false + } + } + return true +} + +func (s *Session) claimWorkspaceRead(frame []byte) ([]byte, error) { + if len(frame)+1 > 8192 { + return nil, agent.ErrWorkspaceReadInvalid + } + h := s.harness + h.readMu.Lock() + defer h.readMu.Unlock() + if h.uncertain { + return nil, agent.ErrWorkspaceReadUncertain + } + if h.reading { + return nil, agent.ErrWorkspaceReadBusy + } + h.reading = true + return append(frame, '\n'), nil +} + +func (h *privateHarness) readWorkspaceFile(ctx context.Context, frame []byte, maxBytes int) (result agent.WorkspaceReadResult, err error) { + err = h.exchangeWorkspaceRead(ctx, frame, base64.StdEncoding.EncodedLen(maxBytes)+1024, func(response []byte) error { + var decodeErr error + result, decodeErr = decodeWorkspaceRead(response, maxBytes) + return decodeErr + }) + return result, err +} + +func (h *privateHarness) exchangeWorkspaceRead(ctx context.Context, frame []byte, limit int, decode func([]byte) error) (err error) { + defer func() { + h.readMu.Lock() + h.reading = false + h.uncertain = h.uncertain || errors.Is(err, agent.ErrWorkspaceReadUncertain) + h.readMu.Unlock() + }() + deadline := time.Now().Add(workspaceReadTimeout) + if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { + deadline = requested + } + // Cancellation cannot discard an admitted native wait; only its fixed deadline can. + operation, cancel := context.WithDeadline(context.WithoutCancel(ctx), deadline) + defer cancel() + conn, dialErr := (&net.Dialer{}).DialContext(operation, "unix", filepath.Join(h.root, "files.sock")) + if dialErr != nil { + return agent.ErrWorkspaceReadUnavailable + } + defer conn.Close() + if conn.SetDeadline(deadline) != nil { + return agent.ErrWorkspaceReadUnavailable + } + if n, writeErr := conn.Write(frame); writeErr != nil || n != len(frame) { + return agent.ErrWorkspaceReadUncertain + } + response, readErr := bufio.NewReader(io.LimitReader(conn, int64(limit+1))).ReadBytes('\n') + if readErr != nil || len(response) > limit { + return agent.ErrWorkspaceReadUncertain + } + return decode(response) +} + +func decodeWorkspaceRead(frame []byte, maxBytes int) (agent.WorkspaceReadResult, error) { + var response struct { + Error *string `json:"error"` + Read *struct { + Data *string `json:"data_base64"` + Truncated *bool `json:"truncated"` + Closed *bool `json:"close_acknowledged"` + } `json:"read"` + } + decoder := json.NewDecoder(bytes.NewReader(frame)) + decoder.DisallowUnknownFields() + if decoder.Decode(&response) != nil || decoder.Decode(new(any)) != io.EOF || (response.Error == nil) == (response.Read == nil) { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain + } + if response.Error != nil { + return agent.WorkspaceReadResult{}, workspaceReadError(*response.Error) + } + read := response.Read + if read.Data == nil || read.Truncated == nil || read.Closed == nil || !*read.Closed { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain + } + data, err := base64.StdEncoding.Strict().DecodeString(*read.Data) + if err != nil || base64.StdEncoding.EncodeToString(data) != *read.Data || len(data) > maxBytes || (*read.Truncated && len(data) != maxBytes) { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain + } + return agent.WorkspaceReadResult{Data: data, Truncated: *read.Truncated}, nil +} + +func workspaceReadError(code string) error { + switch code { + case "unsupported": + return agent.ErrWorkspaceReadUnsupported + case "not_found": + return fs.ErrNotExist + case "permission_denied": + return fs.ErrPermission + case "invalid_request", "invalid_path": + return agent.ErrWorkspaceReadInvalid + case "environment_unavailable", "wrong_environment", "native_error", "too_large": + return agent.ErrWorkspaceReadUnavailable + default: + return agent.ErrWorkspaceReadUncertain + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go new file mode 100644 index 000000000..3a0e05143 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go @@ -0,0 +1,291 @@ +package codex + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io/fs" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const workspaceReadSuccess = `{"read":{"data_base64":"AAEC/w==","truncated":false,"close_acknowledged":true}}` + "\n" + +func workspaceReadFixture(t *testing.T) (*Session, net.Listener) { + t.Helper() + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + base := filepath.Join(home, ".parsar") + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(base, "wr-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + listener, err := net.Listen("unix", filepath.Join(root, "files.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + owner, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + session := &Session{ + harness: &privateHarness{root: root, environment: "frozen-environment"}, + rpc: &JSONRPCClient{alive: true}, cancelCtx: owner, cancelFn: cancel, + } + return session, listener +} + +func workspaceReadConnection(t *testing.T, listener net.Listener) (net.Conn, []byte) { + t.Helper() + conn, err := listener.Accept() + if err != nil { + t.Error(err) + return nil, nil + } + if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Error(err) + } + frame, err := bufio.NewReader(conn).ReadBytes('\n') + if err != nil { + t.Error(err) + } + return conn, frame +} + +func TestWorkspaceReadValidatesAcknowledgedResult(t *testing.T) { + for _, test := range []struct { + name, response string + limit int + want error + truncated bool + }{ + {name: "binary", response: workspaceReadSuccess, limit: 4}, + {name: "truncated", response: strings.Replace(workspaceReadSuccess, "false", "true", 1), limit: 4, truncated: true}, + {name: "empty", response: `{"read":{"data_base64":"","truncated":false,"close_acknowledged":true}}`, limit: 4}, + {name: "not found", response: `{"error":"not_found"}`, limit: 4, want: fs.ErrNotExist}, + {name: "permission", response: `{"error":"permission_denied"}`, limit: 4, want: fs.ErrPermission}, + {name: "native error", response: `{"error":"native_error"}`, limit: 4, want: agent.ErrWorkspaceReadUnavailable}, + {name: "unknown error", response: `{"error":"secret native detail"}`, limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "no close", response: strings.Replace(workspaceReadSuccess, `,"close_acknowledged":true`, "", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "false close", response: strings.Replace(workspaceReadSuccess, `"close_acknowledged":true`, `"close_acknowledged":false`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "no truncation", response: strings.Replace(workspaceReadSuccess, `,"truncated":false`, "", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "oversize", response: workspaceReadSuccess, limit: 3, want: agent.ErrWorkspaceReadUncertain}, + {name: "short truncation", response: strings.Replace(workspaceReadSuccess, "false", "true", 1), limit: 5, want: agent.ErrWorkspaceReadUncertain}, + {name: "bad base64", response: strings.Replace(workspaceReadSuccess, "AAEC/w==", "%%%", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "trailing frame", response: workspaceReadSuccess + `{}`, limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "unknown field", response: strings.Replace(workspaceReadSuccess, `"read":`, `"extra":true,"read":`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + {name: "ambiguous", response: strings.Replace(workspaceReadSuccess, `"read":`, `"error":"not_found","read":`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := decodeWorkspaceRead([]byte(test.response), test.limit) + if !errors.Is(err, test.want) || result.Truncated != test.truncated { + t.Fatalf("unexpected result: %+v, %v", result, err) + } + if err != nil && len(result.Data) != 0 { + t.Fatal("failed read returned partial bytes") + } + if err == nil && test.name != "empty" && string(result.Data) != string([]byte{0, 1, 2, 255}) { + t.Fatal("binary bytes changed") + } + }) + } +} + +func TestWorkspaceReadFrozenBindingAndAdmission(t *testing.T) { + session, listener := workspaceReadFixture(t) + for _, path := range []string{"", "/absolute", "../escape", "a/../b", "a\\b", "a\n", strings.Repeat("x", 8192)} { + if _, err := session.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatalf("path %q admitted: %v", path, err) + } + } + for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { + if _, err := session.ReadWorkspaceFile(t.Context(), "file", limit); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatalf("limit %d admitted: %v", limit, err) + } + } + done := make(chan struct{}) + go func() { + defer close(done) + conn, frame := workspaceReadConnection(t, listener) + if conn == nil { + return + } + defer conn.Close() + var request map[string]any + if err := json.Unmarshal(frame, &request); err != nil || len(request) != 4 || request["environment_id"] != "frozen-environment" || request["operation"] != "read" || request["path"] != "dir/file" || request["max_bytes"] != float64(4) { + t.Errorf("binding changed: %s", frame) + } + _, _ = conn.Write([]byte(workspaceReadSuccess)) + }() + if result, err := session.ReadWorkspaceFile(t.Context(), "dir/file", 4); err != nil || len(result.Data) != 4 { + t.Fatalf("read: %+v %v", result, err) + } + <-done + if _, err := (&Session{}).ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnsupported) { + t.Fatal("stock resource admitted read", err) + } + session.cancelFn() + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { + t.Fatal("cancelled owner admitted read", err) + } +} + +func TestWorkspaceReadRetainsCancelledObservationAndBusySlot(t *testing.T) { + session, listener := workspaceReadFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := session.ReadWorkspaceFile(ctx, "file", 4) + result <- err + }() + conn, _ := workspaceReadConnection(t, listener) + if conn == nil { + return + } + defer conn.Close() + cancel() + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { + t.Fatal("cancelled observer freed read slot", err) + } + select { + case err := <-result: + t.Fatal("cancelled observation discarded native wait", err) + default: + } + _, _ = conn.Write([]byte(workspaceReadSuccess)) + if err := <-result; err != nil { + t.Fatal("acknowledged result lost after observation cancellation", err) + } +} + +func TestWorkspaceReadUncertaintyStopsLaterReads(t *testing.T) { + for _, response := range []string{"", "{broken}\n", strings.Repeat("x", 2048) + "\n"} { + t.Run(response[:min(len(response), 8)], func(t *testing.T) { + session, listener := workspaceReadFixture(t) + done := make(chan struct{}) + go func() { + defer close(done) + conn, _ := workspaceReadConnection(t, listener) + if conn != nil { + _, _ = conn.Write([]byte(response)) + _ = conn.Close() + } + }() + if result, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) || len(result.Data) != 0 { + t.Fatal("ambiguous read succeeded", result, err) + } + <-done + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("uncertain owner admitted another read", err) + } + }) + } +} + +func TestWorkspaceReadDeadlineRetainsUncertainty(t *testing.T) { + session, listener := workspaceReadFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := session.ReadWorkspaceFile(ctx, "file", 4) + result <- err + }() + conn, _ := workspaceReadConnection(t, listener) + if conn == nil { + return + } + defer conn.Close() + if err := <-result; !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("deadline did not preserve uncertainty", err) + } + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("deadline admitted a replacement read", err) + } +} + +func TestWorkspaceReadOwnerExitDoesNotEstablishSettlement(t *testing.T) { + session, listener := workspaceReadFixture(t) + result := make(chan error, 1) + go func() { + _, err := session.ReadWorkspaceFile(t.Context(), "file", 4) + result <- err + }() + conn, _ := workspaceReadConnection(t, listener) + if conn == nil { + return + } + session.cancelFn() + _ = conn.Close() + if err := <-result; !errors.Is(err, agent.ErrWorkspaceReadUncertain) { + t.Fatal("owner exit reported read settlement", err) + } + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { + t.Fatal("exited owner admitted read", err) + } +} + +func TestWorkspaceReadFollowsPreparedTransfer(t *testing.T) { + req, cfg, root := preparationFixture(t) + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + fixture, listener := workspaceReadFixture(t) + p.session.harness = fixture.harness + result := make(chan error, 1) + go func() { + _, err := p.ReadWorkspaceFile(t.Context(), "file", 4) + result <- err + }() + conn, _ := workspaceReadConnection(t, listener) + if conn == nil { + return + } + defer conn.Close() + started, err := p.Start(t.Context(), "actual-run", "actual prompt", make(chan proto.Envelope, 16)) + if err != nil { + t.Fatal(err) + } + session := started.(*Session) + defer session.Cancel(context.Background()) + if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { + t.Fatal("transferred preparation admitted read", err) + } + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { + t.Fatal("transfer lost admitted read", err) + } + _, _ = conn.Write([]byte(workspaceReadSuccess)) + if err := <-result; err != nil { + t.Fatal("read failed across Start", err) + } + waitPreparationMethod(t, root, "turn/start") + done := make(chan struct{}) + go func() { + defer close(done) + conn, _ := workspaceReadConnection(t, listener) + if conn != nil { + _, _ = conn.Write([]byte(workspaceReadSuccess)) + _ = conn.Close() + } + }() + if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + t.Fatal("transferred Session cannot read", err) + } + <-done +} diff --git a/apps/parsar-daemon/internal/agent/functions.go b/apps/parsar-daemon/internal/agent/functions.go new file mode 100644 index 000000000..9dee7b992 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/functions.go @@ -0,0 +1,14 @@ +package agent + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var ErrUnknownFunctionCall = errors.New("agent: function call is no longer pending") + +type FunctionResultSubmitter interface { + SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error +} diff --git a/apps/parsar-daemon/internal/agent/installroot/lock.go b/apps/parsar-daemon/internal/agent/installroot/lock.go new file mode 100644 index 000000000..19d9295e4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installroot/lock.go @@ -0,0 +1,69 @@ +// Package installroot coordinates adapter installations within one daemon process. +package installroot + +import ( + "context" + "os" + "sync" +) + +type installRootLock struct { + info os.FileInfo + token chan struct{} + users int +} + +var installRoots = struct { + sync.Mutex + entries map[*installRootLock]struct{} +}{entries: make(map[*installRootLock]struct{})} + +// Lock creates the install root if needed and holds its filesystem identity +// until the returned function is called once. +// Waiting callers may cancel without interrupting the current installation. +func Lock(ctx context.Context, root string) (func(), error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, err + } + info, err := os.Stat(root) + if err != nil { + return nil, err + } + installRoots.Lock() + var entry *installRootLock + for candidate := range installRoots.entries { + if os.SameFile(candidate.info, info) { + entry = candidate + break + } + } + if entry == nil { + entry = &installRootLock{info: info, token: make(chan struct{}, 1)} + entry.token <- struct{}{} + installRoots.entries[entry] = struct{}{} + } + entry.users++ + installRoots.Unlock() + + release := func() { + installRoots.Lock() + entry.users-- + if entry.users == 0 { + delete(installRoots.entries, entry) + } + installRoots.Unlock() + } + select { + case <-ctx.Done(): + release() + return nil, ctx.Err() + case <-entry.token: + return func() { + entry.token <- struct{}{} + release() + }, nil + } +} diff --git a/apps/parsar-daemon/internal/agent/installroot/lock_test.go b/apps/parsar-daemon/internal/agent/installroot/lock_test.go new file mode 100644 index 000000000..4359af299 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installroot/lock_test.go @@ -0,0 +1,39 @@ +package installroot + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLockCaseAliases(t *testing.T) { + parent := t.TempDir() + alias := strings.ToUpper(parent) + realInfo, err := os.Stat(parent) + if err != nil { + t.Fatal(err) + } + aliasInfo, err := os.Stat(alias) + if err != nil || !os.SameFile(realInfo, aliasInfo) { + t.Skip("filesystem does not expose this case alias") + } + root := filepath.Join(parent, "new-root") + unlock, err := Lock(context.Background(), root) + if err != nil { + t.Fatal(err) + } + defer unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + release, err := Lock(ctx, filepath.Join(alias, "NEW-ROOT")) + if err == nil { + release() + t.Fatal("case alias acquired an independent lock") + } + if err != context.DeadlineExceeded { + t.Fatalf("lock error = %v", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/interactions.go b/apps/parsar-daemon/internal/agent/interactions.go new file mode 100644 index 000000000..2613c96e8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/interactions.go @@ -0,0 +1,18 @@ +package agent + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// PermissionResponder optionally accepts decisions for emitted permission requests. +// Unknown or expired requests return ErrUnknownPermission. +type PermissionResponder interface { + SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error +} + +// UserChoiceResponder optionally accepts answers for emitted user-choice requests. +// Unknown or expired requests return ErrUnknownAsk. +type UserChoiceResponder interface { + SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error +} diff --git a/apps/parsar-daemon/internal/agent/mcode/events.go b/apps/parsar-daemon/internal/agent/mcode/events.go new file mode 100644 index 000000000..5a6b83e2a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/events.go @@ -0,0 +1,114 @@ +package mcode + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func decodeComponent(value string) (string, error) { return url.PathUnescape(value) } + +func (s *Session) handle(frame rpcFrame) error { + if len(frame.ID) > 0 { + if frame.Method == "session/request_permission" && s.active { + return s.askPermission(frame) + } + if frame.Method == "elicitation/create" && s.active { + return s.askQuestion(frame) + } + return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32601, Message: "ACP method not supported by Parsar"}}) + } + if frame.Method != "session/update" || !s.active { + return nil + } + var event sessionUpdate + if err := json.Unmarshal(frame.Params, &event); err != nil { + return fmt.Errorf("mcode: invalid session update") + } + if event.SessionID != s.sessionID { + return nil + } + s.mu.Lock() + s.steeringReady = true + s.mu.Unlock() + switch event.Update.Kind { + case "agent_message_chunk": + if event.Update.Content.Type != "text" { + return fmt.Errorf("mcode: unsupported response content") + } + text := event.Update.Content.Text + s.content.WriteString(text) + s.sequence++ + s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: s.sequence}) + case "agent_thought_chunk": + s.sequence++ + s.emit(proto.TypeThinking, proto.ThinkingPayload{Text: event.Update.Content.Text, Sequence: s.sequence}) + case "tool_call", "tool_call_update": + s.emitTool(event.Update.toolUpdate) + } + return nil +} + +func (s *Session) emitTool(update toolUpdate) { + if update.ID == "" || s.completedTools[update.ID] { + return + } + previous, started := s.tools[update.ID] + if update.Name == "" { + update.Name = previous.Name + } + if update.Name == "" { + update.Name = update.Title + } + if update.RawInput == nil { + update.RawInput = previous.RawInput + } + if s.req.ObserveToolObservations { + started = workspaceToolObservation(previous, "before") != nil + } + if !started { + s.emitToolStage(update, "before") + } + if update.Status == "completed" || update.Status == "failed" { + s.emitToolStage(update, "after") + delete(s.tools, update.ID) + s.completedTools[update.ID] = true + } else { + s.tools[update.ID] = update + } +} + +func (s *Session) askPermission(frame rpcFrame) error { + var request permissionRequest + if err := json.Unmarshal(frame.Params, &request); err != nil { + return fmt.Errorf("mcode: invalid permission request") + } + if request.SessionID != s.sessionID { + return fmt.Errorf("mcode: permission request belongs to another session") + } + pending := pendingPermission{RPCID: frame.ID} + for _, option := range request.Options { + switch option.Kind { + case "allow_once": + pending.Allow = option.ID + case "reject_once": + pending.Deny = option.ID + } + } + if pending.Allow == "" || pending.Deny == "" { + return fmt.Errorf("mcode: permission request has no one-time allow/deny options") + } + id := "perm_" + uuid.NewString() + s.mu.Lock() + s.permissions[id] = pending + s.mu.Unlock() + tool := request.ToolCall.Name + if tool == "" { + tool = request.ToolCall.Title + } + s.emit(proto.TypePermissionRequest, proto.PermissionRequestPayload{RequestID: id, Tool: tool, Title: request.ToolCall.Title, Payload: request.ToolCall.RawInput}) + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go new file mode 100644 index 000000000..5b0c829a5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -0,0 +1,62 @@ +package mcode + +import ( + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// SupportsExecution is an operator opt-in, separate from ordinary product availability. +func SupportsExecution(version string) bool { + return os.Getenv("PARSAR_MCODE_AGENTS_API") == "1" && version == SupportedVersion +} + +func validateExecutionRequest(req proto.PromptRequestPayload) error { + if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || req.WorkDir != "" || req.AgentStateKey == "" || req.RemoteEnvironment != nil || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + return fmt.Errorf("mcode: unsupported execution configuration") + } + if req.ExecutionControls == nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { + return fmt.Errorf("mcode: unsupported execution controls") + } + if mode := optionString(req.AgentOptions, "mode"); mode != "" { + return fmt.Errorf("mcode: text execution uses default native permissions") + } + if req.AgentOptions["plugins"] != nil { + return fmt.Errorf("mcode: execution cannot import plugins") + } + if req.AgentOptions["skills"] != nil || req.AgentOptions["mcp_servers"] != nil || req.AgentOptions["env"] != nil { + return fmt.Errorf("mcode: execution cannot import product capabilities or environment") + } + return nil +} + +func configureTextExecution(config map[string]any) { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{}, "builtinTools": []string{}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": false, "webSearch": false}, + }} + config["askUser"] = map[string]bool{"enabled": false} + config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} +} + +// Only process and model-network essentials cross into the native child. +func executionEnvironment() []string { + var env []string + for _, key := range []string{"PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"} { + if value, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+value) + } + } + return env +} + +// ACP commands are only recognized for a single text block. Public input must +// remain user text; ordinary product Sessions retain their native command behavior. +func promptContent(text string, public bool) []map[string]string { + blocks := []map[string]string{{"type": "text", "text": text}} + if public { + blocks = append(blocks, map[string]string{"type": "text", "text": ""}) + } + return blocks +} diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/parsar-daemon/internal/agent/mcode/execution_test.go new file mode 100644 index 000000000..f9b4980f4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/execution_test.go @@ -0,0 +1,77 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executionRequest(t *testing.T) proto.PromptRequestPayload { + r := testRequest(t) + r.StrictResume, r.ReleaseOnCompletion, r.DisableExecutionEnvironment, r.DisableSubagents = true, true, true, true + r.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + return r +} + +func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { + r := executionRequest(t) + t.Setenv("AGENTS_API_SECRET_CANARY", "secret") + t.Setenv("NODE_OPTIONS", "--import=untrusted") + opts, err := prepareOptions(t.Context(), r) + if err != nil { + t.Fatal(err) + } + for _, e := range opts.Env { + if strings.HasPrefix(e, "AGENTS_API_SECRET_CANARY=") || strings.HasPrefix(e, "NODE_OPTIONS=") { + t.Fatal("ambient authority inherited") + } + } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if json.Unmarshal(data, &config) != nil { + t.Fatal("bad config") + } + features := config["agents"].(map[string]any)["default"].(map[string]any)["features"].(map[string]any) + for _, k := range []string{"mavis", "delegation", "webSearch"} { + if features[k] != false { + t.Fatalf("%s remains enabled", k) + } + } +} + +func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { + for _, change := range []func(*proto.PromptRequestPayload){ + func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, + func(r *proto.PromptRequestPayload) { r.DisableSubagents = false }, + func(r *proto.PromptRequestPayload) { r.RequireExistingNativeSession = true }, + func(r *proto.PromptRequestPayload) { r.WorkDir = "/tmp" }, + func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, + func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, + func(r *proto.PromptRequestPayload) { r.AgentOptions["env"] = map[string]any{"X": "Y"} }, + } { + r := executionRequest(t) + change(&r) + if _, err := prepareOptions(t.Context(), r); err == nil { + t.Fatal("unsupported execution accepted") + } + } +} + +func TestPublicTextDoesNotInvokeACPCommands(t *testing.T) { + for _, text := range []string{"/model", "/compact", "hello"} { + blocks := promptContent(text, true) + if len(blocks) != 2 || blocks[0]["text"] != text || blocks[1]["text"] != "" { + t.Fatal(blocks) + } + if blocks = promptContent(text, false); len(blocks) != 1 || blocks[0]["text"] != text { + t.Fatal("product prompt changed") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/native_history_test.go b/apps/parsar-daemon/internal/agent/mcode/native_history_test.go new file mode 100644 index 000000000..280bd5882 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/native_history_test.go @@ -0,0 +1,66 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// A successful public real-model run supplies an actual foreign native ID. +// Neither that history nor a missing ID may silently become a new session. +func TestNativeMCodeHistoryIsolation(t *testing.T) { + binary, options, foreign := os.Getenv("PARSAR_MCODE_BIN"), os.Getenv("PARSAR_MCODE_REAL_OPTIONS"), os.Getenv("PARSAR_MCODE_FOREIGN_NATIVE_ID") + if binary == "" || options == "" || foreign == "" { + t.Skip("native executable, private provider options and foreign history ID required") + } + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal(err) + } + for name, id := range map[string]string{"foreign": foreign, "missing": "00000000-0000-4000-8000-000000000000"} { + t.Run(name, func(t *testing.T) { + req := executionRequest(t) + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private options") + } + req.AgentSessionID, req.Prompt = id, "This input must never execute." + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + out := make(chan proto.Envelope, 64) + session, err := newSession(ctx, req, out, binary) + if err != nil { + t.Fatal(err) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + if err := session.Cancel(cleanup); err != nil { + t.Error(err) + } + }() + rejected := false + for event := range out { + if event.Type == proto.TypeError { + var failure proto.ErrorPayload + _ = json.Unmarshal(event.Payload, &failure) + rejected = strings.Contains(failure.Error, "session/load:") && !strings.Contains(failure.Error, "deadline exceeded") + } + if event.Type == proto.TypeDone { + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + if done.Content != "" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("unowned history executed or silently replaced") + } + } + } + if !rejected { + t.Fatal("native history was not explicitly rejected") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/native_test.go b/apps/parsar-daemon/internal/agent/mcode/native_test.go new file mode 100644 index 000000000..896dcae5c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/native_test.go @@ -0,0 +1,192 @@ +package mcode + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Opt in with the installed native CLI; the default test gate uses protocol fixtures. +func TestNativeMCodeACP(t *testing.T) { + binary := os.Getenv("PARSAR_MCODE_INTEGRATION_BIN") + if binary == "" { + t.Skip("set PARSAR_MCODE_INTEGRATION_BIN to run native ACP smoke test") + } + req := testRequest(t) + var mu sync.Mutex + var requests []string + mcpCalls := 0 + mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var msg struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if json.NewDecoder(r.Body).Decode(&msg) != nil { + w.WriteHeader(400) + return + } + if len(msg.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + var result any + switch msg.Method { + case "initialize": + result = map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]string{"name": "fixture", "version": "1"}} + case "tools/list": + result = map[string]any{"tools": []map[string]any{{"name": "get_fixture", "description": "Returns the fixture marker", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{}}}}} + case "tools/call": + mu.Lock() + mcpCalls++ + mu.Unlock() + result = map[string]any{"content": []map[string]string{{"type": "text", "text": "MCP-READY"}}} + default: + result = map[string]any{} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": msg.ID, "result": result}) + })) + defer mcp.Close() + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if json.NewDecoder(r.Body).Decode(&body) != nil { + w.WriteHeader(400) + return + } + raw, _ := json.Marshal(body) + mu.Lock() + requests = append(requests, string(raw)) + mu.Unlock() + tool := "" + if !strings.Contains(string(raw), "MCP-READY") && strings.Contains(string(raw), "QA-CALL-MCP") { + tools, _ := body["tools"].([]any) + for _, entry := range tools { + value, _ := entry.(map[string]any) + name, _ := value["name"].(string) + if strings.Contains(name, "get_fixture") { + tool = name + break + } + } + } + writeNativeResponse(w, tool) + })) + defer model.Close() + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + f, _ := zw.Create("SKILL.md") + _, _ = f.Write([]byte("---\nname: qa-mcode-skill\ndescription: Test skill marker SKILL-MCODE-451\n---\nReturn SKILL-MCODE-451.\n")) + _ = zw.Close() + digest := sha256.Sum256(archive.Bytes()) + skill := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(archive.Bytes()) })) + defer skill.Close() + req.AgentOptions["mcode_provider"] = map[string]any{"name": "Parsar", "kind": "custom", "enabled": true, "npm": "@ai-sdk/anthropic", "options": map[string]any{"apiKey": "fixture-only", "baseURL": model.URL}, "models": map[string]any{"fixture": map[string]any{"name": "Fixture", "tool_call": true, "limit": map[string]int{"context": 64000, "output": 4096}}}} + req.AgentOptions["skills"] = []any{map[string]any{"name": "qa-mcode-skill", "version": "1", "download_url": skill.URL, "sha256": hex.EncodeToString(digest[:])}} + req.AgentOptions["mcp_servers"] = map[string]any{"qa": map[string]any{"type": "http", "url": mcp.URL}} + req.AgentOptions["system_prompt"] = "SP-MCODE-672: use the available tools when requested." + req.Prompt = "QA-CALL-MCP: call get_fixture, then reply PARSAR-MCODE-OK." + run := func() proto.DonePayload { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + out := make(chan proto.Envelope, 64) + session, err := newSession(ctx, req, out, binary) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = session.Cancel(context.Background()) + select { + case <-session.exited: + case <-time.After(5 * time.Second): + t.Error("native CLI did not stop") + } + }) + var done proto.DonePayload + for event := range out { + if event.Type == proto.TypeError { + t.Fatalf("native ACP failure: %s", event.Payload) + } + if event.Type == proto.TypeDone { + _ = json.Unmarshal(event.Payload, &done) + } + } + if done.Content != "PARSAR-MCODE-OK" { + t.Fatalf("native output=%q", done.Content) + } + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID].(string); !ok { + t.Fatal("native session ID missing") + } + return done + } + done := run() + mu.Lock() + firstRequests := strings.Join(requests, "\n") + calls := mcpCalls + requests = nil + mu.Unlock() + if !strings.Contains(firstRequests, "SP-MCODE-672") || !strings.Contains(firstRequests, "qa-mcode-skill") { + t.Fatalf("native context missing: prompt=%t skill=%t", strings.Contains(firstRequests, "SP-MCODE-672"), strings.Contains(firstRequests, "qa-mcode-skill")) + } + if calls == 0 { + t.Fatal("native MCP was not called") + } + req.RunID = "run-2" + req.AgentSessionID = done.Metadata[proto.DoneMetaAgentSessionID].(string) + req.AgentOptions["system_prompt"] = "SP-MCODE-NEW: reply concisely." + req.AgentOptions["skills"] = []any{} + provider := req.AgentOptions["mcode_provider"].(map[string]any) + models := provider["models"].(map[string]any) + models["fixture-new"] = models["fixture"] + delete(models, "fixture") + req.AgentOptions["model"] = "fixture-new" + req.Prompt = "Now reply PARSAR-MCODE-OK." + run() + mu.Lock() + resumed := strings.Join(requests, "\n") + mu.Unlock() + if !strings.Contains(resumed, "SP-MCODE-NEW") { + t.Fatal("resume retained stale instructions") + } + if !strings.Contains(resumed, `"model":"fixture-new"`) { + t.Fatal("resume did not use the updated model") + } + t.Logf("native new/resume, model, instructions, Skill discovery and MCP verified (%d tool calls)", calls) +} + +func writeNativeResponse(w http.ResponseWriter, tool string) { + w.Header().Set("Content-Type", "text/event-stream") + send := func(kind string, payload any) { + data, _ := json.Marshal(payload) + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, data) + } + send("message_start", map[string]any{"type": "message_start", "message": map[string]any{"id": "msg_qa", "type": "message", "role": "assistant", "model": "fixture", "content": []any{}, "stop_reason": nil, "stop_sequence": nil, "usage": map[string]int{"input_tokens": 20, "output_tokens": 0}}}) + stop := "end_turn" + if tool != "" { + send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "tool_use", "id": "call_fixture", "name": tool, "input": map[string]any{}}}) + send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "input_json_delta", "partial_json": "{}"}}) + stop = "tool_use" + } else { + send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]string{"type": "text", "text": ""}}) + send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "text_delta", "text": "PARSAR-MCODE-OK"}}) + } + send("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) + send("message_delta", map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 8}}) + send("message_stop", map[string]string{"type": "message_stop"}) +} diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go new file mode 100644 index 000000000..9ccd9efb1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/options.go @@ -0,0 +1,230 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +type launchOptions struct { + Dir, DataDir, Model string + Env []string + MCP []map[string]any +} + +func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { + return prepareOptionsWithSkills(ctx, req, true) +} + +func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { + var result launchOptions + if req.StrictResume { + if err := validateExecutionRequest(req); err != nil { + return result, err + } + } + if len(req.Attachments) > 0 { + return result, fmt.Errorf("mcode: ACP does not support attachments") + } + root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return result, err + } + result.DataDir = filepath.Dir(root) + result.Dir, err = workDir(req.WorkDir, filepath.Join(result.DataDir, "workspace")) + if err != nil { + return result, err + } + if err := os.MkdirAll(result.DataDir, 0o700); err != nil { + return result, err + } + if req.WorkDir == "" { + if err := os.MkdirAll(result.Dir, 0o700); err != nil { + return result, err + } + } + if managedSkills { + installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) + if err != nil { + return result, err + } + if len(installed.Warnings) > 0 { + return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") + } + } + opts := req.AgentOptions + prompt := optionString(opts, "system_prompt") + if override := optionString(opts, "override_system_prompt"); override != "" { + prompt = override + } + if len(prompt) > 32*1024 { + return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") + } + if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { + return result, err + } + config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} + if provider, ok := opts["mcode_provider"].(map[string]any); ok { + config["custom_provider"] = map[string]any{"parsar": provider} + } else { + return result, fmt.Errorf("mcode: a Parsar-managed model is required") + } + result.Model = optionString(opts, "model") + if result.Model == "" { + return result, fmt.Errorf("mcode: model is required") + } + if req.StrictResume { + configureTextExecution(config) + } + mode := optionString(opts, "mode") + if mode == "" { + mode = "auto" + } + if mode != "auto" && mode != "default" && mode != "bypassPermissions" { + return result, fmt.Errorf("mcode: unsupported permission mode") + } + config["permissionMode"] = mode + data, err := json.Marshal(config) + if err != nil { + return result, err + } + if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { + return result, err + } + result.Env = append([]string{}, os.Environ()...) + if req.StrictResume { + result.Env = executionEnvironment() + } + if raw := opts["env"]; raw != nil && !req.StrictResume { + env, ok := raw.(map[string]any) + if !ok { + return result, fmt.Errorf("mcode: env must be an object") + } + for key, rawValue := range env { + value, ok := rawValue.(string) + if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { + return result, fmt.Errorf("mcode: invalid environment entry") + } + result.Env = append(result.Env, key+"="+value) + } + } + // The adapter owns the native state location, including after cold resume. + result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) + if req.StrictResume { + result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) + } + result.MCP, err = mcpServers(opts["mcp_servers"]) + return result, err +} + +func workDir(raw, fallback string) (string, error) { + if raw == "" { + return fallback, nil + } + if strings.HasPrefix(raw, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + raw = filepath.Join(home, raw[2:]) + } + if !filepath.IsAbs(raw) { + return "", fmt.Errorf("mcode: working directory must be absolute or start with ~/") + } + return filepath.Clean(raw), nil +} + +func optionString(options map[string]any, key string) string { + value, _ := options[key].(string) + return value +} + +func mcpServers(raw any) ([]map[string]any, error) { + result := []map[string]any{} + if raw == nil { + return result, nil + } + servers, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: mcp_servers must be an object") + } + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + entry, ok := servers[name].(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: invalid MCP server %q", name) + } + server := map[string]any{"name": name} + if url := optionString(entry, "url"); url != "" { + kind := optionString(entry, "type") + if kind == "" { + kind = "http" + } + if kind != "http" && kind != "sse" { + return nil, fmt.Errorf("mcode: unsupported MCP transport %q", kind) + } + server["type"] = kind + server["url"] = url + headers, err := namedValues(entry["headers"]) + if err != nil { + return nil, err + } + server["headers"] = headers + } else { + command := optionString(entry, "command") + if command == "" { + return nil, fmt.Errorf("mcode: MCP server %q needs command or URL", name) + } + server["command"] = command + args := entry["args"] + if args == nil { + args = []string{} + } + server["args"] = args + env, err := namedValues(entry["env"]) + if err != nil { + return nil, err + } + server["env"] = env + } + result = append(result, server) + } + return result, nil +} + +func namedValues(raw any) ([]map[string]string, error) { + result := []map[string]string{} + if raw == nil { + return result, nil + } + values, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: MCP headers/env must be an object") + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value, ok := values[key].(string) + if !ok { + return nil, fmt.Errorf("mcode: MCP headers/env values must be strings") + } + result = append(result, map[string]string{"name": key, "value": value}) + } + return result, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/options_test.go b/apps/parsar-daemon/internal/agent/mcode/options_test.go new file mode 100644 index 000000000..a2508a052 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/options_test.go @@ -0,0 +1,117 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestOptionsRefreshManagedState(t *testing.T) { + req := testRequest(t) + req.AgentOptions["env"] = map[string]any{"MINIMAX_DATA_DIR": "/wrong", "FIXTURE": "yes"} + opts, err := prepareOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(opts.Dir, os.Getenv("PARSAR_HOME")+string(os.PathSeparator)) { + t.Fatalf("workdir escaped managed state: %s", opts.Dir) + } + if opts.Env[len(opts.Env)-1] != "MINIMAX_DATA_DIR="+opts.DataDir { + t.Fatal("state override did not win") + } + req.AgentOptions["system_prompt"] = "" + req.AgentOptions["mode"] = "default" + req.AgentSessionID = "native-1" + refreshed, err := prepareOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if refreshed.DataDir != opts.DataDir { + t.Fatal("resume moved native state") + } + content, err := os.ReadFile(filepath.Join(opts.DataDir, "AGENTS.md")) + if err != nil || len(content) != 0 { + t.Fatal("removed instructions retained on resume") + } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if json.Unmarshal(data, &cfg) != nil { + t.Fatal("invalid config") + } + if cfg["permissionMode"] != "default" { + t.Fatal("requested native permission mode was not refreshed") + } + if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { + t.Fatal("external discovery enabled") + } + info, _ := os.Stat(filepath.Join(opts.DataDir, "config.yaml")) + if info.Mode().Perm() != 0600 { + t.Fatal("native credentials file is not private") + } +} + +func TestOptionsRejectDroppedContext(t *testing.T) { + tests := []struct { + name string + edit func(*proto.PromptRequestPayload) + }{ + {"relative workdir", func(r *proto.PromptRequestPayload) { r.WorkDir = "relative" }}, + {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, + {"attachment", func(r *proto.PromptRequestPayload) { r.Attachments = []proto.PromptAttachment{{Kind: "image"}} }}, + {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, + {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "mcode_provider") }}, + {"invalid permission mode", func(r *proto.PromptRequestPayload) { r.AgentOptions["mode"] = "plan" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := testRequest(t) + tt.edit(&req) + if _, err := prepareOptions(context.Background(), req); err == nil { + t.Fatal("expected validation failure") + } + }) + } +} + +func TestMCPTransportConversion(t *testing.T) { + servers, err := mcpServers(map[string]any{ + "local": map[string]any{"command": "fixture", "args": []any{"--stdio"}, "env": map[string]any{"TOKEN": "test"}}, + "remote": map[string]any{"type": "http", "url": "https://mcp.example.test", "headers": map[string]any{"Authorization": "Bearer fixture"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(servers) != 2 || servers[0]["command"] != "fixture" || servers[1]["type"] != "http" { + t.Fatalf("servers=%v", servers) + } + if servers[0]["env"].([]map[string]string)[0]["name"] != "TOKEN" || servers[1]["headers"].([]map[string]string)[0]["value"] != "Bearer fixture" { + t.Fatal("MCP credentials lost") + } +} + +func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { + pending := pendingQuestion{Properties: map[string]formProperty{"text": {Type: "string"}, "choice": {Type: "string", Options: []formOption{{Value: "eu", Title: "Europe"}}}}, Required: []string{"choice"}} + if _, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{}); err == nil { + t.Fatal("required answer accepted empty") + } + decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "text", Answers: []string{"custom"}}, {QuestionID: "choice", Answers: []string{"Europe"}}}} + content, err := questionContent(pending, decision) + if err != nil { + t.Fatal(err) + } + if content["choice"] != "eu" || content["text"] != "custom" { + t.Fatalf("answers=%v", content) + } + decision.QuestionAnswers[1].Answers = []string{"unoffered"} + if _, err := questionContent(pending, decision); err == nil { + t.Fatal("unoffered choice accepted") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation.go b/apps/parsar-daemon/internal/agent/mcode/preparation.go new file mode 100644 index 000000000..74058a5de --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/preparation.go @@ -0,0 +1,131 @@ +package mcode + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type prepared struct { + mu sync.Mutex + session *Session + ready chan struct{} + started chan struct{} + failure error + closed bool + binding *preparedStart +} + +type preparedStart struct { + runID, prompt string + out chan<- proto.Envelope +} + +func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.ObserveSubagentIdentities { + return nil, fmt.Errorf("mcode: preparation cannot contain input or product context") + } + opts, err := prepareWorkspaceOptions(ctx, config, req) + if err != nil { + return nil, err + } + s, err := launch(ctx, req, opts, config.Binary, nil) + if err != nil { + return nil, err + } + p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} + go s.run(p) + <-p.ready + if p.failure != nil { + <-s.finished + return nil, p.failure + } + if ctx.Err() != nil { + _ = p.Close() + return nil, ctx.Err() + } + return p, nil + } +} + +func (p *prepared) Start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (agent.Session, error) { + if ctx == nil { + ctx = context.Background() + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || p.binding != nil { + return nil, fmt.Errorf("mcode: preparation is no longer available") + } + if strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" || out == nil || ctx.Err() != nil { + return nil, fmt.Errorf("mcode: start requires a live context, identity, prompt and output") + } + select { + case <-p.session.process.Context().Done(): + return nil, fmt.Errorf("mcode: prepared process ended") + case <-p.session.exited: + return nil, fmt.Errorf("mcode: prepared process exited") + default: + } + p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} + close(p.started) + return p.session, nil +} + +func (p *prepared) Close() error { + p.mu.Lock() + if p.binding != nil { + p.mu.Unlock() + return nil + } + p.closed = true + p.mu.Unlock() + return p.session.Cancel(context.Background()) +} + +func (p *prepared) Cancel(ctx context.Context) error { + p.mu.Lock() + p.closed = true + p.mu.Unlock() + return p.session.Cancel(ctx) +} + +func (p *prepared) CancellationOutcome() proto.DonePayload { return p.session.CancellationOutcome() } + +// The Session goroutine owns preparation, input submission and terminal output. +func (p *prepared) awaitStart(err error) error { + p.failure = err + close(p.ready) + if err != nil { + return err + } + for { + var ended error + select { + case <-p.started: + case <-p.session.process.Context().Done(): + ended = p.session.process.Context().Err() + case _, ok := <-p.session.frames: + if ok { + continue + } + ended = fmt.Errorf("mcode: prepared process exited") + } + p.mu.Lock() + if b := p.binding; b != nil { + p.session.req.RunID, p.session.req.Prompt, p.session.out = b.runID, b.prompt, b.out + } else { + p.closed = true + } + p.mu.Unlock() + return ended + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go new file mode 100644 index 000000000..3eb1220f5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -0,0 +1,123 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { + t.Helper() + r := executionRequest(t) + r.RunID, r.Prompt, r.ConversationID = "", "", "" + r.DisableExecutionEnvironment = false + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "disabled"} + r.WorkDir = t.TempDir() + record := filepath.Join(t.TempDir(), "calls") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "native") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + script := "#!/bin/sh\nexport PARSAR_MCODE_TEST_HELPER=prepared\nexport PARSAR_MCODE_TEST_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "disabled", Scratch: t.TempDir(), ProtectedDirs: []string{os.Getenv("PARSAR_HOME")}}, r, record +} + +func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { + c, r, record := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + t.Cleanup(func() { _ = p.Cancel(context.Background()) }) + raw, err := os.ReadFile(record) + if err != nil || strings.Contains(string(raw), "session/prompt") { + t.Fatalf("preparation consumed input: %q %v", raw, err) + } + if p.session.opts.Dir == r.WorkDir || !strings.HasPrefix(p.session.opts.Dir, p.session.opts.DataDir+string(filepath.Separator)) { + t.Fatal("native cwd is not private") + } + out := make(chan proto.Envelope) + var wg sync.WaitGroup + winners := make(chan bool, 8) + for range 8 { + wg.Add(1) + go func() { defer wg.Done(); _, err := p.Start(ctx, "run", "input", out); winners <- err == nil }() + } + wg.Wait() + close(winners) + n := 0 + for winner := range winners { + if winner { + n++ + } + } + if n != 1 { + t.Fatalf("owners=%d", n) + } + if err := p.Close(); err != nil { + t.Fatal(err) + } + deltas, done := 0, 0 + for e := range out { + if e.Type == proto.TypeError { + t.Fatalf("execution: %s", e.Payload) + } + if e.Type == proto.TypeDelta { + deltas++ + } + if e.Type == proto.TypeDone { + done++ + select { + case <-p.session.exited: + default: + t.Fatal("Done before native settlement") + } + var d proto.DonePayload + _ = json.Unmarshal(e.Payload, &d) + if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("native identity lost") + } + } + } + if deltas != 100 || done != 1 { + t.Fatalf("deltas=%d done=%d", deltas, done) + } + raw, _ = os.ReadFile(record) + if strings.Count(string(raw), "session/prompt") != 1 { + t.Fatalf("inputs=%s", raw) + } +} + +func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { + c, r, _ := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + if err = resource.Close(); err != nil { + t.Fatal(err) + } + if _, err = resource.Start(ctx, "run", "input", make(chan proto.Envelope)); err == nil { + t.Fatal("released preparation started") + } + if err = resource.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/protocol.go b/apps/parsar-daemon/internal/agent/mcode/protocol.go new file mode 100644 index 000000000..cba538344 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/protocol.go @@ -0,0 +1,66 @@ +// Package mcode drives MiniMax Code through its native ACP stdio interface. +package mcode + +import "encoding/json" + +type rpcFrame struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type sessionResult struct { + SessionID string `json:"sessionId"` + ConfigOptions []configOption `json:"configOptions"` +} + +type configOption struct { + ID string `json:"id"` + Options []struct { + Value string `json:"value"` + } `json:"options"` +} + +type toolUpdate struct { + ID string `json:"toolCallId"` + Name string `json:"name"` + Title string `json:"title"` + Status string `json:"status"` + RawInput map[string]any `json:"rawInput"` + RawOutput any `json:"rawOutput"` +} + +type sessionUpdate struct { + SessionID string `json:"sessionId"` + Update struct { + Kind string `json:"sessionUpdate"` + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + toolUpdate + } `json:"update"` +} + +type permissionRequest struct { + SessionID string `json:"sessionId"` + ToolCall toolUpdate `json:"toolCall"` + Options []struct { + ID string `json:"optionId"` + Kind string `json:"kind"` + } `json:"options"` +} + +type pendingPermission struct { + RPCID json.RawMessage + Allow string + Deny string +} diff --git a/apps/parsar-daemon/internal/agent/mcode/questions.go b/apps/parsar-daemon/internal/agent/mcode/questions.go new file mode 100644 index 000000000..1955cde3a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/questions.go @@ -0,0 +1,216 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +type formOption struct { + Value string `json:"const"` + Title string `json:"title"` + Description string `json:"description"` +} + +type formProperty struct { + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + Options []formOption `json:"oneOf"` + Items struct { + Options []formOption `json:"anyOf"` + } `json:"items"` +} + +type pendingQuestion struct { + RPCID json.RawMessage + Properties map[string]formProperty + Required []string + OtherFields map[string]string +} + +func (s *Session) askQuestion(frame rpcFrame) error { + var request struct { + SessionID string `json:"sessionId"` + Mode string `json:"mode"` + Schema struct { + Type string `json:"type"` + Properties map[string]formProperty `json:"properties"` + Required []string `json:"required"` + } `json:"requestedSchema"` + } + if err := json.Unmarshal(frame.Params, &request); err != nil { + return fmt.Errorf("mcode: invalid input request") + } + if request.SessionID != s.sessionID { + return fmt.Errorf("mcode: input request belongs to another session") + } + if request.Mode != "form" || request.Schema.Type != "object" || len(request.Schema.Properties) == 0 { + return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "Parsar requires a nonempty input form"}}) + } + otherFields := questionnaireOtherFields(request.Schema.Properties) + for _, key := range otherFields { + delete(request.Schema.Properties, key) + } + keys := make([]string, 0, len(request.Schema.Properties)) + for key := range request.Schema.Properties { + keys = append(keys, key) + } + sort.Strings(keys) + questions := make([]proto.PromptForUserChoiceQuestion, 0, len(keys)) + for _, key := range keys { + property := request.Schema.Properties[key] + if property.Type != "string" && property.Type != "array" { + return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "Parsar supports text and choice input fields"}}) + } + question := proto.PromptForUserChoiceQuestion{ID: key, Question: property.Title, MultiSelect: property.Type == "array", Options: []proto.PromptForUserChoiceOption{}} + if question.Question == "" { + question.Question = key + } + if property.Description != "" { + question.Question += "\n" + property.Description + } + options := property.Options + if question.MultiSelect { + options = property.Items.Options + } + labels := map[string]bool{} + for _, option := range options { + label := option.Title + if label == "" { + label = option.Value + } + if labels[label] { + return fmt.Errorf("mcode: input choices have ambiguous labels") + } + labels[label] = true + question.Options = append(question.Options, proto.PromptForUserChoiceOption{Label: label, Description: option.Description}) + } + question.IsOther = len(options) == 0 || otherFields[key] != "" + questions = append(questions, question) + } + id := "ask_" + uuid.NewString() + s.mu.Lock() + s.questions[id] = pendingQuestion{RPCID: frame.ID, Properties: request.Schema.Properties, Required: request.Schema.Required, OtherFields: otherFields} + s.mu.Unlock() + s.emit(proto.TypePromptForUserChoice, proto.PromptForUserChoicePayload{AskID: id, Questions: questions}) + return nil +} + +// Native questionnaires encode Other as a companion field, not a second question. +func questionnaireOtherFields(properties map[string]formProperty) map[string]string { + fields := map[string]string{} + for key, property := range properties { + if len(property.Options) == 0 && len(property.Items.Options) == 0 { + continue + } + for candidate, other := range properties { + if strings.HasPrefix(candidate, key+"__other") && strings.Trim(strings.TrimPrefix(candidate, key+"__other"), "_") == "" && other.Type == "string" && len(other.Options) == 0 && other.Title == property.Title+" — Other" { + fields[key] = candidate + break + } + } + } + return fields +} + +func (s *Session) SubmitPromptForUserChoice(_ context.Context, id string, decision proto.PromptForUserChoiceDecisionPayload) error { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.questions[id] + if !ok { + return agent.ErrUnknownAsk + } + response := map[string]any{"action": "cancel"} + if !decision.Cancelled { + content, err := questionContent(pending, decision) + if err != nil { + return err + } + response = map[string]any{"action": "accept", "content": content} + } + raw, _ := json.Marshal(response) + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { + return err + } + delete(s.questions, id) + return nil +} + +func questionContent(pending pendingQuestion, decision proto.PromptForUserChoiceDecisionPayload) (map[string]any, error) { + answers := decision.QuestionAnswers + if len(answers) == 0 && len(pending.Properties) == 1 { + for key := range pending.Properties { + answers = []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: key, Answers: decision.Answers}} + } + } + content := map[string]any{} + for _, answer := range answers { + property, ok := pending.Properties[answer.QuestionID] + if !ok { + return nil, fmt.Errorf("mcode: unknown input field") + } + values := append([]string{}, answer.Answers...) + if len(values) == 0 && answer.Answer != "" { + values = []string{answer.Answer} + } + if len(values) == 0 { + continue + } + options := property.Options + if property.Type == "array" { + options = property.Items.Options + } else if len(values) != 1 { + return nil, fmt.Errorf("mcode: input field requires a single answer") + } + selected := make([]string, 0, len(values)) + for _, value := range values { + if len(options) == 0 { + selected = append(selected, value) + continue + } + found := false + for _, option := range options { + label := option.Title + if label == "" { + label = option.Value + } + if value == label { + selected = append(selected, option.Value) + found = true + break + } + } + if !found { + other := pending.OtherFields[answer.QuestionID] + if other == "" { + return nil, fmt.Errorf("mcode: answer is not an offered choice") + } + if _, exists := content[other]; exists { + return nil, fmt.Errorf("mcode: input field requires a single custom answer") + } + content[other] = value + } + } + if len(selected) == 0 { + continue + } + if property.Type == "array" { + content[answer.QuestionID] = selected + } else { + content[answer.QuestionID] = selected[0] + } + } + for _, key := range pending.Required { + if _, ok := content[key]; !ok { + return nil, fmt.Errorf("mcode: required input is missing") + } + } + return content, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/questions_test.go b/apps/parsar-daemon/internal/agent/mcode/questions_test.go new file mode 100644 index 000000000..4fff09ade --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/questions_test.go @@ -0,0 +1,55 @@ +package mcode + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestQuestionnaireOtherUsesOneQuestion(t *testing.T) { + for _, multiple := range []bool{false, true} { + property := formProperty{Type: "string", Title: "Region?", Options: []formOption{{Value: "eu", Title: "Europe"}}} + if multiple { + property.Type = "array" + property.Items.Options = property.Options + property.Options = nil + } + properties := map[string]formProperty{"region": property, "region__other": {Type: "string", Title: "Region? — Other"}} + params, _ := json.Marshal(map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "properties": properties}}) + out := make(chan proto.Envelope, 1) + session := &Session{ctx: t.Context(), sessionID: "native-1", out: out, questions: map[string]pendingQuestion{}} + if err := session.askQuestion(rpcFrame{ID: json.RawMessage(`1`), Params: params}); err != nil { + t.Fatal(err) + } + var request proto.PromptForUserChoicePayload + _ = json.Unmarshal((<-out).Payload, &request) + if len(request.Questions) != 1 || !request.Questions[0].IsOther || request.Questions[0].MultiSelect != multiple { + t.Fatalf("unexpected questions: %#v", request.Questions) + } + pending := session.questions[request.AskID] + for _, custom := range []bool{false, true} { + answer := "Europe" + want := map[string]any{"region": "eu"} + if multiple { + want["region"] = []string{"eu"} + } + if custom { + answer = "Asia" + want = map[string]any{"region__other": "Asia"} + } + got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{Answers: []string{answer}}) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("multiple=%t custom=%t: got=%v err=%v", multiple, custom, got, err) + } + } + if multiple { + got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Europe", "Asia"}}) + want := map[string]any{"region": []string{"eu"}, "region__other": "Asia"} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("combined choice and custom: got=%v err=%v", got, err) + } + } + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go new file mode 100644 index 000000000..ffe148782 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -0,0 +1,378 @@ +package mcode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type Session struct { + ctx context.Context + req proto.PromptRequestPayload + opts launchOptions + process *clirunner.Process + out chan<- proto.Envelope + frames chan rpcFrame + exited chan struct{} + finished chan struct{} + writeMu sync.Mutex + mu sync.Mutex + sessionID string + permissions map[string]pendingPermission + questions map[string]pendingQuestion + exitErr error + nextID int + responses map[string]chan rpcFrame + steeringReady bool + steeringTurn string + sequence uint64 + active bool + content strings.Builder + tools map[string]toolUpdate + completedTools map[string]bool +} + +var _ agent.Session = (*Session)(nil) + +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultBinary()) +} + +func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, binary string) (*Session, error) { + if ctx == nil { + ctx = context.Background() + } + if out == nil { + return nil, fmt.Errorf("mcode: output channel is required") + } + opts, err := prepareOptions(ctx, req) + if err != nil { + return nil, err + } + s, err := launch(ctx, req, opts, binary, out) + if err != nil { + return nil, err + } + go s.run(nil) + return s, nil +} + +func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string, out chan<- proto.Envelope) (*Session, error) { + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) + if err != nil { + return nil, err + } + s := &Session{ctx: ctx, req: req, opts: opts, process: process, out: out, frames: make(chan rpcFrame, 32), exited: make(chan struct{}), finished: make(chan struct{}), responses: map[string]chan rpcFrame{}, permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + go func() { _, _ = io.Copy(io.Discard, process.Stderr) }() + go s.read() + return s, nil +} + +func (s *Session) read() { + defer close(s.exited) + defer close(s.frames) + scanner := bufio.NewScanner(s.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) + var readErr error + for scanner.Scan() { + var frame rpcFrame + if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { + readErr = fmt.Errorf("mcode: malformed ACP response") + s.process.Cancel() + break + } + if frame.Method == "" { + s.mu.Lock() + response := s.responses[string(frame.ID)] + s.mu.Unlock() + if response != nil { + select { + case response <- frame: + default: + } + continue + } + } + select { + case s.frames <- frame: + case <-s.finished: + case <-s.process.Context().Done(): + } + } + if scanner.Err() != nil { + readErr = fmt.Errorf("mcode: ACP stream read failed") + s.process.Cancel() + } + waitErr := s.process.Wait() + if readErr != nil { + s.exitErr = readErr + } else { + s.exitErr = waitErr + } +} + +func (s *Session) run(p *prepared) { + defer func() { + if s.out != nil { + close(s.out) + } + }() + defer close(s.finished) + err := s.prepareNative() + if p != nil { + err = p.awaitStart(err) + } + if err == nil { + err = s.executePrompt() + } + if p != nil || err != nil { + s.process.Cancel() + <-s.exited + } + if s.out == nil { + return + } + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() + if err != nil { + s.process.Cancel() + s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) + } + s.mu.Lock() + sessionID := s.sessionID + s.permissions = map[string]pendingPermission{} + s.questions = map[string]pendingQuestion{} + s.mu.Unlock() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} + if sessionID != "" { + metadata[proto.DoneMetaAgentSessionID] = sessionID + } + // ACP context usage is not per-turn token usage; do not record it as spend. + s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata}) +} + +func (s *Session) prepareNative() error { + var initialized struct { + ProtocolVersion int `json:"protocolVersion"` + } + if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "parsar", "version": "1"}, "clientCapabilities": map[string]any{"elicitation": map[string]any{"form": map[string]any{}}}}, &initialized, false); err != nil { + return err + } + if initialized.ProtocolVersion != 1 { + return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) + } + params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} + method := "session/new" + if s.req.AgentSessionID != "" { + method = "session/load" + params["sessionId"] = s.req.AgentSessionID + } + var session sessionResult + if err := s.call(method, params, &session, false); err != nil { + return err + } + if s.req.AgentSessionID != "" { + session.SessionID = s.req.AgentSessionID + } + if session.SessionID == "" { + return fmt.Errorf("mcode: ACP returned an empty session id") + } + s.mu.Lock() + s.sessionID = session.SessionID + s.mu.Unlock() + model, err := advertisedModel(session.ConfigOptions, s.opts.Model) + if err != nil && s.req.AgentSessionID != "" && !slices.ContainsFunc(session.ConfigOptions, func(option configOption) bool { return option.ID == "model" }) { + // Native load omits the selector when its persisted model was removed; selection still validates against the current catalog. + model = "m:custom_provider%3Aparsar:" + strings.ReplaceAll(url.QueryEscape(s.opts.Model), "+", "%20") + ":v:" + err = nil + } + if err != nil { + return err + } + if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { + return err + } + return nil +} + +func (s *Session) executePrompt() error { + s.active = true + var result struct { + StopReason string `json:"stopReason"` + } + err := s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(s.req.Prompt, s.req.StrictResume)}, &result, true) + s.active = false + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() + if err != nil { + return err + } + if result.StopReason != "end_turn" { + return fmt.Errorf("mcode: prompt stopped (%s)", result.StopReason) + } + return nil +} + +// Select the advertised custom model, rather than using mcode's native default. +func advertisedModel(options []configOption, model string) (string, error) { + for _, option := range options { + if option.ID != "model" { + continue + } + for _, candidate := range option.Options { + parts := strings.Split(candidate.Value, ":") + if len(parts) < 4 || parts[0] != "m" { + continue + } + provider, err := decodeComponent(parts[1]) + if err != nil { + continue + } + id, err := decodeComponent(parts[2]) + if err != nil { + continue + } + if provider == "custom_provider:parsar" && id == model && (parts[3] == "u" || (len(parts) == 5 && parts[3] == "v" && parts[4] == "")) { + return candidate.Value, nil + } + } + } + return "", fmt.Errorf("mcode: configured model is not advertised by the CLI") +} + +func (s *Session) call(method string, params any, result any, prompt bool) error { + id, _ := s.reserveResponse() + s.removeResponse(id) + raw, err := json.Marshal(params) + if err != nil { + return err + } + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}); err != nil { + return err + } + ctx := s.process.Context() + if !prompt { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + } + for { + select { + case <-ctx.Done(): + return fmt.Errorf("mcode: %s: %w", method, ctx.Err()) + case frame, ok := <-s.frames: + if !ok { + <-s.exited + return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) + } + if frame.Method != "" { + if err := s.handle(frame); err != nil { + return err + } + continue + } + if string(frame.ID) != id { + continue + } + if frame.Error != nil { + return fmt.Errorf("mcode: %s: %s", method, frame.Error.Message) + } + if result != nil { + if err := json.Unmarshal(frame.Result, result); err != nil { + return fmt.Errorf("mcode: invalid %s result", method) + } + } + return nil + } + } +} + +func (s *Session) write(frame rpcFrame) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return json.NewEncoder(s.process.Stdin).Encode(frame) +} + +func (s *Session) emit(kind string, payload any) { + env, err := proto.NewEnvelope(kind, s.req.RunID, payload) + if err != nil { + return + } + select { + case s.out <- env: + case <-s.ctx.Done(): + } +} + +func (s *Session) Cancel(ctx context.Context) error { + s.process.Cancel() + if !s.req.StrictResume { + return nil + } + select { + case <-s.exited: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-s.finished: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) SubmitPermission(_ context.Context, id string, decision proto.PermissionDecisionPayload) error { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.permissions[id] + if !ok { + return agent.ErrUnknownPermission + } + choice := pending.Deny + if decision.Approved { + choice = pending.Allow + } + if choice == "" { + return errors.New("mcode: ACP permission option is unavailable") + } + if len(decision.UpdatedInput) > 0 { + return errors.New("mcode: edited permission input is not supported") + } + raw, _ := json.Marshal(map[string]any{"outcome": map[string]string{"outcome": "selected", "optionId": choice}}) + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { + return err + } + delete(s.permissions, id) + return nil +} + +func (s *Session) reserveResponse() (string, chan rpcFrame) { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + id := strconv.Itoa(s.nextID) + reply := make(chan rpcFrame, 1) + s.responses[id] = reply + return id, reply +} +func (s *Session) removeResponse(id string) { + s.mu.Lock() + delete(s.responses, id) + s.mu.Unlock() +} diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go new file mode 100644 index 000000000..06a0d46f9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -0,0 +1,327 @@ +package mcode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func testRequest(t *testing.T) proto.PromptRequestPayload { + t.Helper() + t.Setenv("PARSAR_HOME", t.TempDir()) + return proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conversation-1", AgentStateKey: "conversation-1/agent-1/mcode", Prompt: "Hello", AgentOptions: map[string]any{ + "model": "fixture", "mcode_provider": map[string]any{"kind": "custom", "enabled": true}, "system_prompt": "Current instructions", + }} +} + +func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { + t.Helper() + req := testRequest(t) + if scenario == "strict-cancel" { + req = executionRequest(t) + } + if resume { + req.AgentSessionID = "native-1" + } + t.Setenv("PARSAR_MCODE_TEST_HELPER", scenario) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "mcode") + script := "#!/bin/sh\nexport PARSAR_MCODE_TEST_HELPER=" + scenario + "\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + out := make(chan proto.Envelope, 32) + session, err := newSession(ctx, req, out, binary) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = session.Cancel(context.Background()) + select { + case <-session.exited: + case <-time.After(3 * time.Second): + t.Error("CLI was not reaped") + } + }) + return session, out +} + +func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "resume"}[resume], func(t *testing.T) { + _, out := helperSession(t, "happy", resume) + var content string + tools := map[string]int{} + doneCount := 0 + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("error: %s", event.Payload) + case proto.TypeDelta: + var p proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &p) + content += p.Delta + case proto.TypeToolCall: + var p proto.ToolCallPayload + _ = json.Unmarshal(event.Payload, &p) + tools[p.Stage]++ + case proto.TypeDone: + doneCount++ + var p proto.DonePayload + _ = json.Unmarshal(event.Payload, &p) + if p.Content != "Hello world" || p.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatalf("done = %#v", p) + } + if p.Usage.InputTokens != 0 || p.Usage.OutputTokens != 0 || p.Usage.CostUSD != 0 { + t.Fatalf("context occupancy reported as usage: %#v", p.Usage) + } + } + } + if content != "Hello world" || doneCount != 1 || tools["before"] != 1 || tools["after"] != 1 { + t.Fatalf("content=%q done=%d tools=%v", content, doneCount, tools) + } + }) + } +} + +func TestSessionInteractionRoundTrip(t *testing.T) { + for _, approved := range []bool{true, false} { + t.Run(map[bool]string{true: "allow", false: "deny"}[approved], func(t *testing.T) { + session, out := helperSession(t, "interaction", false) + permissions, questions := 0, 0 + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("error: %s", event.Payload) + case proto.TypePermissionRequest: + permissions++ + var p proto.PermissionRequestPayload + _ = json.Unmarshal(event.Payload, &p) + if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: approved}); err != nil { + t.Fatal(err) + } + if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("duplicate approval: %v", err) + } + case proto.TypePromptForUserChoice: + questions++ + var p proto.PromptForUserChoicePayload + _ = json.Unmarshal(event.Payload, &p) + decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe"}}}} + if err := session.SubmitPromptForUserChoice(context.Background(), p.AskID, decision); err != nil { + t.Fatal(err) + } + } + } + if permissions != 1 || questions != 1 { + t.Fatalf("permissions=%d questions=%d", permissions, questions) + } + }) + } +} + +func TestResumeSelectsModelWhenNativeSelectorIsMissing(t *testing.T) { + _, out := helperSession(t, "resume-model", true) + completed := false + for event := range out { + if event.Type == proto.TypeError { + t.Fatalf("resume failed: %s", event.Payload) + } + if event.Type == proto.TypeDone { + completed = true + } + } + if !completed { + t.Fatal("resume did not complete") + } +} + +func TestSessionFailuresAreReported(t *testing.T) { + for _, scenario := range []string{"malformed", "exit", "rpc-error", "unknown-model"} { + t.Run(scenario, func(t *testing.T) { + _, out := helperSession(t, scenario, false) + reported := false + for event := range out { + if event.Type == proto.TypeError { + reported = true + } + } + if !reported { + t.Fatal("failure was not emitted") + } + }) + } +} + +func TestCancelStopsWaitingCLI(t *testing.T) { + session, out := helperSession(t, "hang", false) + if err := session.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-session.exited: + case <-time.After(3 * time.Second): + t.Fatal("cancel hung") + } + for range out { + } +} + +func TestMCodeProcess(t *testing.T) { + scenario := os.Getenv("PARSAR_MCODE_TEST_HELPER") + if scenario == "" { + return + } + encoder := json.NewEncoder(os.Stdout) + send := func(value any) { + if err := encoder.Encode(value); err != nil { + os.Exit(2) + } + } + update := func(kind string, fields map[string]any) { + fields["sessionUpdate"] = kind + send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "native-1", "update": fields}}) + } + scanner := bufio.NewScanner(os.Stdin) + var promptID json.RawMessage + for scanner.Scan() { + var frame rpcFrame + if json.Unmarshal(scanner.Bytes(), &frame) != nil { + os.Exit(3) + } + if scenario == "malformed" { + os.Stdout.WriteString("invalid JSON\n") + os.Exit(0) + } + if scenario == "exit" { + os.Exit(1) + } + if scenario == "hang" { + continue + } + if record := os.Getenv("PARSAR_MCODE_TEST_RECORD"); record != "" { + f, err := os.OpenFile(record, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + os.Exit(10) + } + _, _ = f.WriteString(frame.Method + "\n") + _ = f.Close() + } + result := any(map[string]any{}) + switch frame.Method { + case "initialize": + result = map[string]int{"protocolVersion": 1} + case "session/new", "session/load": + if frame.Method == "session/load" { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) + } + model := "m:custom_provider%3Aparsar:fixture:v:" + if scenario == "unknown-model" { + model = "m:minimax:native:u" + } + result = map[string]any{"sessionId": "native-1", "configOptions": []map[string]any{{"id": "model", "options": []map[string]string{{"value": model}}}}} + if scenario == "resume-model" { + result = map[string]any{"configOptions": []map[string]any{{"id": "permissionMode"}}} + } + case "session/set_config_option": + var params map[string]string + _ = json.Unmarshal(frame.Params, ¶ms) + if params["configId"] == "model" && params["value"] != "m:custom_provider%3Aparsar:fixture:v:" { + os.Exit(4) + } + case "session/prompt": + var input struct { + Prompt []map[string]string `json:"prompt"` + } + _ = json.Unmarshal(frame.Params, &input) + if strict := scenario == "strict-cancel" || scenario == "prepared"; (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + os.Exit(9) + } + if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { + promptID = frame.ID + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + continue + } + if scenario == "many-frames" || scenario == "prepared" { + for range 100 { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "x"}}) + } + result = map[string]string{"stopReason": "end_turn"} + break + } + if scenario == "rpc-error" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32603, Message: "Fixture provider unavailable"}}) + continue + } + if scenario == "interaction" { + promptID = frame.ID + send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "always", "kind": "allow_always"}, {"optionId": "deny", "kind": "reject_once"}}}}) + continue + } + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) + update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "Read", "status": "in_progress", "rawInput": map[string]any{"path": "fixture.txt"}}) + for range 2 { + update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) + } + update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) + result = map[string]string{"stopReason": "end_turn"} + case "mcode/session/steer": + if scenario == "steer-lost" { + os.Exit(0) + } + if scenario == "steer-rejected" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "inactive"}}) + continue + } + raw, _ := json.Marshal(map[string]string{"turnId": "native-turn", "mode": "steered"}) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "-steered"}}) + raw, _ = json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + continue + case "": + if string(frame.ID) == `"permission-1"` { + var reply struct { + Outcome struct { + Option string `json:"optionId"` + } `json:"outcome"` + } + _ = json.Unmarshal(frame.Result, &reply) + if reply.Outcome.Option != "once" && reply.Outcome.Option != "deny" { + os.Exit(5) + } + send(map[string]any{"jsonrpc": "2.0", "id": "question-1", "method": "elicitation/create", "params": map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "required": []string{"region"}, "properties": map[string]any{"region": map[string]any{"type": "string", "title": "Region?", "oneOf": []map[string]string{{"const": "eu", "title": "Europe"}, {"const": "us", "title": "America"}}}}}}}) + } else { + var reply struct { + Action string `json:"action"` + Content map[string]string `json:"content"` + } + _ = json.Unmarshal(frame.Result, &reply) + if reply.Action != "accept" || reply.Content["region"] != "eu" { + os.Exit(6) + } + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + } + continue + } + raw, _ := json.Marshal(result) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + } + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/mcode/steering.go b/apps/parsar-daemon/internal/agent/mcode/steering.go new file mode 100644 index 000000000..994ae4a08 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/steering.go @@ -0,0 +1,100 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var _ agent.DurableSteerer = (*Session)(nil) + +func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, input, nil) +} + +// Native acceptance belongs to the active ACP Turn; it does not promise model consumption. +func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if strings.TrimSpace(input.InputID) == "" || strings.TrimSpace(input.Text) == "" { + return agent.ErrSteeringRejected + } + s.mu.Lock() + ready, native := s.steeringReady, s.sessionID + s.mu.Unlock() + if s.process.Context().Err() != nil { + return agent.ErrSteeringInactive + } + if !ready || native == "" { + return agent.ErrSteeringNotReady + } + id, response := s.reserveResponse() + defer s.removeResponse(id) + raw, err := json.Marshal(map[string]string{"sessionId": native, "text": input.Text, "clientRequestId": input.InputID}) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + stop := context.AfterFunc(ctx, s.process.Cancel) + err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) + stop() + if err != nil { + return fmt.Errorf("mcode: input transport failed") + } + if written != nil { + written() + } + var frame rpcFrame + var stopped error + select { + case frame = <-response: + case <-s.finished: + stopped = fmt.Errorf("mcode: run ended with unknown input outcome") + case <-s.exited: + stopped = fmt.Errorf("mcode: input transport closed") + case <-ctx.Done(): + stopped = ctx.Err() + } + if stopped != nil { + // A native receipt already read before terminal closure remains authoritative. + select { + case frame = <-response: + default: + return stopped + } + } + if frame.Error != nil { + if frame.Error.Code == -32602 || frame.Error.Code == -32601 { + return agent.ErrSteeringRejected + } + return fmt.Errorf("mcode: native steering failed with unknown input outcome") + } + var result struct { + TurnID string `json:"turnId"` + Mode string `json:"mode"` + } + if json.Unmarshal(frame.Result, &result) != nil || result.TurnID == "" || (result.Mode != "steered" && result.Mode != "duplicate") { + return fmt.Errorf("mcode: invalid steering receipt") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.steeringTurn != "" && s.steeringTurn != result.TurnID { + return fmt.Errorf("mcode: steering turn changed") + } + s.steeringTurn = result.TurnID + return nil +} + +func (s *Session) CancellationOutcome() proto.DonePayload { + s.mu.Lock() + defer s.mu.Unlock() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} + if s.sessionID != "" { + metadata[proto.DoneMetaAgentSessionID] = s.sessionID + } + return proto.DonePayload{Metadata: metadata} +} diff --git a/apps/parsar-daemon/internal/agent/mcode/steering_test.go b/apps/parsar-daemon/internal/agent/mcode/steering_test.go new file mode 100644 index 000000000..b5bce7af7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/steering_test.go @@ -0,0 +1,126 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestNativeSteeringReceipt(t *testing.T) { + for _, scenario := range []string{"steering", "steer-rejected", "steer-lost"} { + t.Run(scenario, func(t *testing.T) { + s, out := helperSession(t, scenario, false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatalf("first event %s", event.Type) + } + case <-ctx.Done(): + t.Fatal("not ready") + } + written := false + reply := make(chan error, 1) + go func() { + reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-1", Text: "next"}, func() { written = true }) + }() + // Drain native output concurrently, as the router does. + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + err := <-reply + if !written { + t.Fatal("complete write was not reported") + } + switch scenario { + case "steering": + if err != nil { + t.Fatal(err) + } + case "steer-rejected": + if !errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("got %v", err) + } + case "steer-lost": + if err == nil || errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("unknown outcome became rejection/success: %v", err) + } + } + s.Cancel(ctx) + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + }) + } +} + +func TestTerminalFollowsAllNativeFrames(t *testing.T) { + _, out := helperSession(t, "many-frames", false) + count := 0 + for e := range out { + switch e.Type { + case proto.TypeDelta: + count++ + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(e.Payload, &done) + if count != 100 || len(done.Content) != 100 { + t.Fatalf("terminal overtook frames: %d/%d", count, len(done.Content)) + } + case proto.TypeError: + t.Fatalf("unexpected error %s", e.Payload) + } + } +} + +func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { + s, out := helperSession(t, "strict-cancel", false) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + select { + case <-out: + case <-ctx.Done(): + t.Fatal("not ready") + } + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + if err := s.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case <-s.exited: + default: + t.Fatal("cancel returned before process exit") + } + select { + case <-s.finished: + default: + t.Fatal("cancel returned before output settlement") + } + if err := s.Cancel(ctx); err != nil { + t.Fatal("duplicate cancellation", err) + } + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + if s.CancellationOutcome().Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("lost native binding") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_observations.go b/apps/parsar-daemon/internal/agent/mcode/tool_observations.go new file mode 100644 index 000000000..d48b386bb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/tool_observations.go @@ -0,0 +1,55 @@ +package mcode + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) emitToolStage(update toolUpdate, stage string) { + payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} + if stage == "after" { + payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} + } + if s.req.ObserveToolObservations { + payload.Observation = workspaceToolObservation(update, stage) + // Native task/skill bookkeeping has no qualified public item mapping. + if payload.Observation == nil { + return + } + } + s.emit(proto.TypeToolCall, payload) +} + +func workspaceToolObservation(update toolUpdate, stage string) *proto.ToolObservation { + if update.Name != "mcp__parsar_workspace__workspace_bash" { + return nil + } + command, _ := update.RawInput["command"].(string) + if command == "" { + return nil + } + cwd := "/workspace" + result := &proto.ToolObservation{Kind: "command", Command: command, Cwd: &cwd, Status: "in_progress"} + if stage == "after" { + result.Status = update.Status + // ACP reports MCP content but no structured exit code or duration. + raw, _ := json.Marshal(update.RawOutput) + var output struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &output) == nil && output.Content != nil { + text := "" + for _, part := range output.Content { + if part.Type == "text" { + text += part.Text + } + } + result.Output, _ = json.Marshal(text) + } + } + return result +} diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go b/apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go new file mode 100644 index 000000000..2f6fb4969 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go @@ -0,0 +1,49 @@ +package mcode + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing.T) { + for _, status := range []string{"completed", "failed"} { + t.Run(status, func(t *testing.T) { + out := make(chan proto.Envelope, 8) + s := &Session{ctx: context.Background(), req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + s.emitTool(toolUpdate{ID: "call", Name: "mcp__parsar_workspace__workspace_bash"}) + if len(out) != 0 { + t.Fatal("command item emitted before native arguments") + } + s.emitTool(toolUpdate{ID: "call", RawInput: map[string]any{"command": "pwd"}}) + s.emitTool(toolUpdate{ID: "call", Status: status, RawOutput: map[string]any{"content": []any{map[string]string{"type": "text", "text": "/workspace"}}}}) + s.emitTool(toolUpdate{ID: "call", Status: status}) + if len(out) != 2 { + t.Fatalf("events=%d", len(out)) + } + var before, after proto.ToolCallPayload + _ = json.Unmarshal((<-out).Payload, &before) + _ = json.Unmarshal((<-out).Payload, &after) + if before.Observation == nil || before.Observation.Kind != "command" || before.Observation.Status != "in_progress" { + t.Fatal(before) + } + n := after.Observation + if n == nil || n.Command != "pwd" || n.Cwd == nil || *n.Cwd != "/workspace" || n.Status != status { + t.Fatal(after) + } + if string(n.Output) != `"/workspace"` || n.ExitCode != nil || n.DurationMS != nil { + t.Fatal(n) + } + }) + } +} + +func TestPrivateUtilitiesAreNotInventedPublicFunctionCalls(t *testing.T) { + for _, name := range []string{"mcp__parsar_workspace__workspace_read", "skill", "task_query", "task_output", "task_stop", "mcp__unregistered__workspace_bash"} { + if workspaceToolObservation(toolUpdate{Name: name}, "before") != nil { + t.Fatal(name) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/version.go b/apps/parsar-daemon/internal/agent/mcode/version.go new file mode 100644 index 000000000..a23600bf7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/version.go @@ -0,0 +1,24 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +var ErrCLINotFound = errors.New("mcode CLI not found") + +const SupportedVersion = "0.4.12" + +func defaultBinary() string { return binpath.MCode() } + +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + version, err := versionprobe.Check(ctx, binary, versionprobe.Config{Name: "mcode", DefaultBinary: defaultBinary(), MissingError: ErrCLINotFound, TrimBinary: true}) + if err == nil && version != SupportedVersion { + err = fmt.Errorf("mcode: unsupported version %s; install %s", version, SupportedVersion) + } + return version, err +} diff --git a/apps/parsar-daemon/internal/agent/mcode/version_test.go b/apps/parsar-daemon/internal/agent/mcode/version_test.go new file mode 100644 index 000000000..f85957b81 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/version_test.go @@ -0,0 +1,26 @@ +package mcode + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestOnlyQualifiedLatestCLIIsAvailable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX CLI fixture") + } + for _, version := range []string{SupportedVersion, "0.3.11", "0.4.13"} { + t.Run(version, func(t *testing.T) { + binary := filepath.Join(t.TempDir(), "mcode") + if err := os.WriteFile(binary, []byte("#!/bin/sh\necho "+version+"\n"), 0700); err != nil { + t.Fatal(err) + } + got, err := CheckCLIAvailable(t.Context(), binary) + if got != version || (err == nil) != (version == SupportedVersion) { + t.Fatalf("version %q, error %v", got, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go new file mode 100644 index 000000000..b5b99d07a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -0,0 +1,117 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// WorkspaceConfig is frozen deployment input, separate from public Agent options. +type WorkspaceConfig struct { + Binary, Node, Bridge, Directory, Network, Scratch string + ProtectedDirs []string +} + +func ConfigureLocal(binary, node, bridge, root, workspace, network, staging string) (WorkspaceConfig, error) { + c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network, + Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch"), + ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), filepath.Join(root, "runtime", "mcode"), filepath.Dir(workspace), staging}} + if network != "enabled" && network != "disabled" { + return c, fmt.Errorf("mcode: explicit workspace network policy is required") + } + for _, path := range []string{binary, node, bridge, root, workspace, staging} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { + return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") + } + } + for _, path := range []string{binary, node, bridge} { + resolved, err := filepath.EvalSymlinks(path) + if err != nil || resolved != path || strings.HasPrefix(path, workspace+"/") || strings.HasPrefix(path, "/workspace/") { + return c, fmt.Errorf("mcode: runtime programs must be canonical paths outside the workspace") + } + } + bound, err := os.Stat(workspace) + public, publicErr := os.Stat("/workspace") + if err != nil || publicErr != nil || !bound.IsDir() || !os.SameFile(bound, public) { + return c, fmt.Errorf("mcode: public workspace alias does not match the Runtime binding") + } + if err := os.MkdirAll(c.Scratch, 0700); err != nil { + return c, err + } + return c, nil +} + +func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { + if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || req.LocalEnvironment.NetworkAccess != c.Network || req.RemoteEnvironment != nil || req.WorkspaceReadOnly { + return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") + } + // Reuse public option validation and private Session state provisioning. Native + // cwd remains private; only the internal MCP worker receives the public workspace. + private := req + private.LocalEnvironment, private.WorkDir, private.DisableExecutionEnvironment = nil, "", true + opts, err := prepareOptionsWithSkills(ctx, private, false) + if err != nil { + return opts, err + } + if err := localworkspace.VerifySkills(req.LocalEnvironment.Skills); err != nil { + return opts, err + } + if len(req.LocalEnvironment.Skills) > 0 { + link := filepath.Join(opts.DataDir, "skills") + if target, err := os.Readlink(link); err == nil { + if target != localworkspace.SkillDirectory { + return opts, fmt.Errorf("mcode: unexpected native Skill root") + } + } else if !os.IsNotExist(err) { + return opts, err + } else if err := os.Symlink(localworkspace.SkillDirectory, link); err != nil { + return opts, err + } + } + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + return opts, err + } + var config map[string]any + if err = json.Unmarshal(raw, &config); err != nil { + return opts, err + } + config["permissionMode"] = "bypassPermissions" + config["sandbox"] = map[string]bool{"enabled": false} + raw, err = json.Marshal(config) + if err != nil { + return opts, err + } + if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { + return opts, err + } + profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} + if req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + return opts, err + } + profile["toolEnvironment"] = true + profile["systemPackages"] = req.LocalEnvironment.SystemPackages + // Initialization exposes only user env/packages; private staging and + // daemon/native history remain explicitly denied. + protected := slices.Clone(c.ProtectedDirs) + profile["protectedDirs"] = slices.DeleteFunc(protected, func(path string) bool { return path == "/environment" }) + } + raw, err = json.Marshal(profile) + if err != nil { + return opts, err + } + path := filepath.Join(opts.DataDir, "workspace-profile.json") + if err = os.WriteFile(path, raw, 0600); err != nil { + return opts, err + } + opts.MCP = []map[string]any{{"name": "parsar_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} + return opts, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go new file mode 100644 index 000000000..84b71f218 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go @@ -0,0 +1,38 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" +) + +// CheckWorkspace verifies the installed companion. Public qualification remains +// an operator deployment requirement, not an implication of this probe. +func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment(), OwnProcessGroup: true}) + if err != nil { + return err + } + defer p.Cancel() + go func() { _, _ = io.Copy(io.Discard, p.Stderr) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 4097)) + if err != nil || len(raw) > 4096 { + p.Cancel() + } + waitErr := p.Wait() + var info struct { + Protocol int `json:"protocol"` + Native, Source string + } + if err != nil || waitErr != nil || len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 1 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { + return fmt.Errorf("mcode: workspace companion check failed") + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcp.go b/apps/parsar-daemon/internal/agent/mcp.go new file mode 100644 index 000000000..dec48b31f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcp.go @@ -0,0 +1,19 @@ +package agent + +import "strings" + +// ValidMCPHTTPBearerToken accepts RFC 6750 b64token bytes without normalization. +// Credential resource storage has a separate, opaque-string contract. +func ValidMCPHTTPBearerToken(token string) bool { + value := strings.TrimRight(token, "=") + if value == "" { + return false + } + for i := range len(value) { + c := value[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("-._~+/", rune(c))) { + return false + } + } + return true +} diff --git a/apps/parsar-daemon/internal/agent/opencode/export_test.go b/apps/parsar-daemon/internal/agent/opencode/export_test.go new file mode 100644 index 000000000..38ec5218a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/export_test.go @@ -0,0 +1,36 @@ +package opencode + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + OpenCodeBinary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + opencodeBinary: cfg.OpenCodeBinary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +type Translation = translation + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go new file mode 100644 index 000000000..b2543636b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -0,0 +1,312 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// BuildResult is the opencode CLI launch plan for one prompt. +type BuildResult struct { + Args []string + Env []string + WorkDir string + ModelSelector string + Cleanup func() +} + +// BuildArgs translates the daemon prompt_request into an `opencode +// run` invocation. +func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, error) { + cleanup := func() {} + result := BuildResult{Cleanup: cleanup} + + resolvedWorkDir, err := resolveWorkDir(workDir) + if err != nil { + return result, err + } + + promptText, err := buildPrompt(prompt, opts) + if err != nil { + return result, err + } + + args := []string{"run", "--format", "json"} + if resolvedWorkDir != "" { + args = append(args, "--dir", resolvedWorkDir) + } + if model := firstString(opts, "model_selector", "model"); model != "" { + args = append(args, "--model", model) + result.ModelSelector = model + } + if agent := stringOpt(opts, "agent"); agent != "" { + args = append(args, "--agent", agent) + } + if boolOpt(opts, "dangerously_skip_permissions") { + args = append(args, "--dangerously-skip-permissions") + } + args = append(args, promptText) + + env, err := buildEnv(opts) + if err != nil { + return result, err + } + + rawConfig := stringOpt(opts, "opencode_json") + if servers, ok := opts["mcp_servers"]; ok && servers != nil { + rawConfig, err = mergeMCPConfig(rawConfig, servers) + if err != nil { + return result, err + } + } + if rawConfig != "" { + configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) + if err != nil { + return result, err + } + cleanup = scratchCleanup + env = append(env, "XDG_CONFIG_HOME="+configHome) + } + + result.Args = args + result.Env = env + result.WorkDir = resolvedWorkDir + result.Cleanup = cleanup + return result, nil +} + +func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + } + servers, ok := rawServers.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) + } + mcp, _ := config["mcp"].(map[string]any) + if mcp == nil { + mcp = map[string]any{} + } + for name, raw := range servers { + entry, ok := raw.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) + } + enabled := true + if value, ok := entry["enabled"].(bool); ok { + enabled = value + } + if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { + remote := map[string]any{ + "type": "remote", + "url": strings.TrimSpace(remoteURL), + "enabled": enabled, + } + if headers := stringMap(entry["headers"]); len(headers) > 0 { + remote["headers"] = headers + } + mcp[name] = remote + continue + } + command, ok := entry["command"].(string) + if !ok || strings.TrimSpace(command) == "" { + return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) + } + commandParts := []string{command} + if args, ok := entry["args"].([]any); ok { + for _, arg := range args { + if value, ok := arg.(string); ok { + commandParts = append(commandParts, value) + } + } + } else if args, ok := entry["args"].([]string); ok { + commandParts = append(commandParts, args...) + } + local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} + if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { + local["environment"] = env + } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { + local["environment"] = env + } + mcp[name] = local + } + if len(mcp) > 0 { + config["mcp"] = mcp + } + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) + } + return string(encoded), nil +} + +func stringMap(value any) map[string]string { + switch typed := value.(type) { + case map[string]string: + return typed + case map[string]any: + result := make(map[string]string, len(typed)) + for key, raw := range typed { + if text, ok := raw.(string); ok { + result[key] = text + } + } + return result + default: + return nil + } +} + +func resolveWorkDir(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("opencode: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("opencode: work_dir must be absolute or start with ~/, got %q", trimmed) + } + // Match claudecode + codex: mkdir -p so the wizard's "Created if + // it does not exist" hint actually holds across engines. + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("opencode: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func buildPrompt(prompt string, opts map[string]any) (string, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "", fmt.Errorf("opencode: empty prompt") + } + systemPrompt := stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + systemPrompt = override + } + if systemPrompt == "" { + return prompt, nil + } + return systemPrompt + "\n\n" + prompt, nil +} + +func buildEnv(opts map[string]any) ([]string, error) { + env := []string{ + "DISABLE_TELEMETRY=1", + } + raw, ok := opts["env"] + if !ok || raw == nil { + return env, nil + } + envMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) + } + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func writeConfigHome(runID, raw string) (string, func(), error) { + if strings.TrimSpace(runID) == "" { + return "", func() {}, fmt.Errorf("opencode: runID required for scratch config") + } + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return "", func() {}, fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + root, err := paths.Root() + if err != nil { + return "", func() {}, err + } + scratchRoot := filepath.Join(root, "parsar-daemon", "scratch", safeRunID(runID)) + configHome := filepath.Join(scratchRoot, "config-home") + opencodeDir := filepath.Join(configHome, "opencode") + if err := os.MkdirAll(opencodeDir, 0o700); err != nil { + return "", func() {}, fmt.Errorf("opencode: create config dir %s: %w", opencodeDir, err) + } + configPath := filepath.Join(opencodeDir, "opencode.json") + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + return "", func() {}, fmt.Errorf("opencode: write %s: %w", configPath, err) + } + cleanup := func() { _ = os.RemoveAll(scratchRoot) } + return configHome, cleanup, nil +} + +func safeRunID(runID string) string { + var b strings.Builder + for _, r := range runID { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "" { + return "run" + } + return out +} + +func firstString(opts map[string]any, keys ...string) string { + for _, key := range keys { + if v := stringOpt(opts, key); v != "" { + return v + } + } + return "" +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +func boolOpt(opts map[string]any, key string) bool { + if opts == nil { + return false + } + v, ok := opts[key] + if !ok || v == nil { + return false + } + b, ok := v.(bool) + return ok && b +} diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go new file mode 100644 index 000000000..879d82874 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -0,0 +1,161 @@ +package opencode_test + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" +) + +func TestBuildArgsUsesOpenCodeRunJSONAndWorkdir(t *testing.T) { + res, err := opencode.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ + "model_selector": "anthropic/claude-opus-4-7", + "agent": "build", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--format", "json") || !slices.Contains(res.Args, "run") { + t.Fatalf("args missing run/json: %v", res.Args) + } + if !containsPair(res.Args, "--dir", os.TempDir()) { + t.Fatalf("args missing --dir temp: %v", res.Args) + } + if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { + t.Fatalf("args missing model selector: %v", res.Args) + } + if !containsPair(res.Args, "--agent", "build") { + t.Fatalf("args missing agent: %v", res.Args) + } + if got := res.Args[len(res.Args)-1]; got != "hello" { + t.Fatalf("last arg prompt = %q, want hello; args=%v", got, res.Args) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + _, err := opencode.BuildArgs("run-1", "hello", "./relative", nil) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +// TestBuildArgsCreatesMissingWorkdir: align with claudecode + codex — +// a non-existent absolute path is mkdir -p'd. Pinning this keeps the +// wizard's "Created if it does not exist" hint honest across engines. +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := opencode.BuildArgs("run-1", "hello", target, nil) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--dir", target) { + t.Fatalf("args missing --dir %s: %v", target, res.Args) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildArgsWritesManagedConfigUnderParsarHome(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + res, err := opencode.BuildArgs("run/id", "hello", "", map[string]any{ + "opencode_json": `{"provider":{},"permission":{"*":"allow"}}`, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + configHome := envValue(res.Env, "XDG_CONFIG_HOME") + if configHome == "" { + t.Fatalf("XDG_CONFIG_HOME missing in env: %v", res.Env) + } + wantPrefix := filepath.Join(home, "parsar-daemon", "scratch", "run_id", "config-home") + if configHome != wantPrefix { + t.Fatalf("configHome = %q, want %q", configHome, wantPrefix) + } + configPath := filepath.Join(configHome, "opencode", "opencode.json") + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected opencode.json at %s: %v", configPath, err) + } + res.Cleanup() + if _, err := os.Stat(filepath.Join(home, "parsar-daemon", "scratch", "run_id")); !os.IsNotExist(err) { + t.Fatalf("scratch dir still exists after cleanup: %v", err) + } +} + +func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ + "opencode_json": `{"provider":{}}`, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, + "docs": map[string]any{ + "url": "https://docs.example.com/mcp", + "headers": map[string]any{"Authorization": "Bearer token"}, + }, + }, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + mcp := config["mcp"].(map[string]any) + remote := mcp["docs"].(map[string]any) + if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { + t.Fatalf("remote = %+v", remote) + } + headers := remote["headers"].(map[string]any) + if headers["Authorization"] != "Bearer token" { + t.Fatalf("headers = %+v", headers) + } + local := mcp["local"].(map[string]any) + if local["type"] != "local" { + t.Fatalf("local = %+v", local) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if strings.HasPrefix(item, prefix) { + return strings.TrimPrefix(item, prefix) + } + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser.go b/apps/parsar-daemon/internal/agent/opencode/parser.go new file mode 100644 index 000000000..9231dff01 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/parser.go @@ -0,0 +1,296 @@ +package opencode + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type translator struct { + runID string + seq atomic.Uint64 + + deltaBuf strings.Builder + plainBuf strings.Builder + rawLines []string + usage proto.Usage + stepUsage usageInfo + toolsSeen map[string]bool +} + +type translation struct { + Envelopes []proto.Envelope +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + t.rawLines = append(t.rawLines, string(line)) + + var head struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` + Part json.RawMessage `json:"part"` + } + if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { + if t.plainBuf.Len() > 0 { + t.plainBuf.WriteByte('\n') + } + t.plainBuf.Write(line) + return translation{}, nil + } + + switch head.Type { + case "message.part.delta": + return t.translatePartDelta(head.Properties) + case "text": + return t.translateTextPart(head.Part) + case "tool_use": + return t.translateToolPart(head.Part) + case "message.updated", "message.updated.1": + t.captureUsage(head.Properties) + return translation{}, nil + case "step_finish": + t.capturePartUsage(head.Part) + return translation{}, nil + default: + t.captureGenericUsage(line) + return translation{}, nil + } +} + +func (t *translator) translateToolPart(raw json.RawMessage) (translation, error) { + var p struct { + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State struct { + Status string `json:"status"` + Input map[string]any `json:"input"` + Output string `json:"output"` + Error string `json:"error"` + } `json:"state"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse tool part: %w", err) + } + if p.Type != "tool" || p.CallID == "" || p.Tool == "" || + (p.State.Status != "completed" && p.State.Status != "error") || t.toolsSeen[p.CallID] { + return translation{}, nil + } + result := map[string]any{"output": p.State.Output, "is_error": p.State.Status == "error"} + if p.State.Status == "error" { + result["output"] = p.State.Error + } + // JSON CLI tool parts arrive only after execution. Pair the call and + // result for existing trace consumers; neither frame requests approval. + call := proto.ToolCallPayload{ID: p.CallID, Name: p.Tool, Stage: "before", Args: p.State.Input} + before, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) + if err != nil { + return translation{}, err + } + call.Stage, call.Result = "after", result + after, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) + if err != nil { + return translation{}, err + } + if t.toolsSeen == nil { + t.toolsSeen = make(map[string]bool) + } + t.toolsSeen[p.CallID] = true + return translation{Envelopes: []proto.Envelope{before, after}}, nil +} + +func (t *translator) translatePartDelta(raw json.RawMessage) (translation, error) { + var p struct { + Field string `json:"field"` + Delta string `json:"delta"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse message.part.delta: %w", err) + } + if p.Delta == "" || (p.Field != "" && p.Field != "text") { + return translation{}, nil + } + t.deltaBuf.WriteString(p.Delta) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Delta, Sequence: t.seq.Add(1)}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateTextPart(raw json.RawMessage) (translation, error) { + var p struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse text part: %w", err) + } + if p.Text == "" || (p.Type != "" && p.Type != "text") { + return translation{}, nil + } + t.deltaBuf.WriteString(p.Text) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Text, Sequence: t.seq.Add(1)}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) captureUsage(raw json.RawMessage) { + var p struct { + Info usageInfo `json:"info"` + } + if err := json.Unmarshal(raw, &p); err == nil { + t.mergeUsage(p.Info) + } +} + +func (t *translator) capturePartUsage(raw json.RawMessage) { + var p usageInfo + if err := json.Unmarshal(raw, &p); err == nil { + if p.Tokens.CacheRead == 0 { + p.Tokens.CacheRead = p.Tokens.Cache.Read + } + if p.Tokens.CacheWrite == 0 { + p.Tokens.CacheWrite = p.Tokens.Cache.Write + } + t.stepUsage.Tokens.Input += p.Tokens.Input + t.stepUsage.Tokens.Output += p.Tokens.Output + t.stepUsage.Tokens.Reasoning += p.Tokens.Reasoning + t.stepUsage.Tokens.CacheRead += p.Tokens.CacheRead + t.stepUsage.Tokens.CacheWrite += p.Tokens.CacheWrite + t.stepUsage.Tokens.Total += p.Tokens.Total + t.stepUsage.Cost += p.Cost + t.mergeUsage(t.stepUsage) + } +} + +func (t *translator) captureGenericUsage(raw json.RawMessage) { + var p struct { + Info usageInfo `json:"info"` + Tokens usageTokens `json:"tokens"` + Cost float64 `json:"cost"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return + } + t.mergeUsage(p.Info) + if p.Tokens.Input != 0 || p.Tokens.Output != 0 || p.Cost != 0 { + t.mergeUsage(usageInfo{Tokens: p.Tokens, Cost: p.Cost}) + } +} + +type usageInfo struct { + Tokens usageTokens `json:"tokens"` + Cost float64 `json:"cost"` +} + +type usageTokens struct { + Input int32 `json:"input"` + Output int32 `json:"output"` + Reasoning int32 `json:"reasoning"` + CacheRead int32 `json:"cacheRead"` + CacheWrite int32 `json:"cacheWrite"` + Total int32 `json:"total"` + Cache struct { + Read int32 `json:"read"` + Write int32 `json:"write"` + } `json:"cache"` +} + +func (t *translator) mergeUsage(info usageInfo) { + if info.Tokens.CacheRead == 0 { + info.Tokens.CacheRead = info.Tokens.Cache.Read + } + if info.Tokens.CacheWrite == 0 { + info.Tokens.CacheWrite = info.Tokens.Cache.Write + } + if info.Tokens.Input != 0 { + t.usage.InputTokens = info.Tokens.Input + } + if info.Tokens.Output != 0 { + t.usage.OutputTokens = info.Tokens.Output + } + if info.Cost != 0 { + t.usage.CostUSD = info.Cost + } + if info.Tokens.Reasoning != 0 || info.Tokens.CacheRead != 0 || info.Tokens.CacheWrite != 0 || info.Tokens.Total != 0 { + if t.usage.Raw == nil { + t.usage.Raw = map[string]any{} + } + if info.Tokens.Reasoning != 0 { + t.usage.Raw["reasoning_tokens"] = info.Tokens.Reasoning + } + if info.Tokens.CacheRead != 0 { + t.usage.Raw["cache_read_tokens"] = info.Tokens.CacheRead + } + if info.Tokens.CacheWrite != 0 { + t.usage.Raw["cache_write_tokens"] = info.Tokens.CacheWrite + } + if info.Tokens.Total != 0 { + t.usage.Raw["total_tokens"] = info.Tokens.Total + } + } +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + if t.plainBuf.Len() > 0 && t.deltaBuf.Len() == 0 { + delta := strings.TrimSpace(t.plainBuf.String()) + if delta != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: delta, Sequence: t.seq.Add(1)}); err == nil { + envs = append(envs, env) + } + t.deltaBuf.WriteString(delta) + } + } + usage := t.usage + usage.Provider = "opencode" + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { + if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { + envs = append(envs, env) + } + } + if waitErr != nil || cancelled { + msg := "opencode: subprocess exited without success" + if waitErr != nil { + msg = fmt.Sprintf("opencode: subprocess exited: %v", waitErr) + } + if strings.TrimSpace(stderr) != "" { + msg += ": " + truncate(strings.TrimSpace(stderr), 400) + } + if cancelled { + msg = "opencode: cancelled" + } + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { + envs = append(envs, env) + } + } + content := strings.TrimSpace(t.deltaBuf.String()) + metadata := map[string]any{"connector_path": "opencode_run"} + if len(t.rawLines) > 0 { + metadata["opencode_raw_lines"] = t.rawLines + } + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{Content: content, Transcript: strings.Join(t.rawLines, "\n"), Usage: usage, Metadata: metadata}); err == nil { + envs = append(envs, env) + } + return envs +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser_test.go b/apps/parsar-daemon/internal/agent/opencode/parser_test.go new file mode 100644 index 000000000..b09f38a3e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/parser_test.go @@ -0,0 +1,169 @@ +package opencode_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTranslatePartDeltaEmitsDeltaAndDone(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message.part.delta","properties":{"field":"text","delta":"hello"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "hello" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } + envs := tr.TerminalEnvelopes(nil, "", false) + last := envs[len(envs)-1] + if last.Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done", last.Type) + } + done := decodePayload[proto.DonePayload](t, last) + if done.Content != "hello" || done.Metadata["connector_path"] != "opencode_run" { + t.Fatalf("done payload = %#v", done) + } +} + +func TestTranslateTextEventsEmitDeltasAndDone(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-text") + tx, err := tr.Translate([]byte(`{"type":"text","timestamp":1785838824775,"sessionID":"ses_1","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_1","type":"text","text":"OK"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "OK" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } + if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_2","type":"step-finish"}}`)); err != nil { + t.Fatalf("Translate step finish: %v", err) + } + tx, err = tr.Translate([]byte(`{"type":"text","timestamp":1785838824776,"sessionID":"ses_1","part":{"id":"prt_3","messageID":"msg_1","sessionID":"ses_1","type":"text","text":" again"}}`)) + if err != nil { + t.Fatalf("Translate second text: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("second delta envelopes = %#v", tx.Envelopes) + } + second := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if second.Delta != " again" || second.Sequence <= delta.Sequence { + t.Fatalf("second delta payload = %#v", second) + } + if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_4","type":"step-finish"}}`)); err != nil { + t.Fatalf("Translate second step finish: %v", err) + } + + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "OK again" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestTranslateCapturesUsage(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-u") + _, err := tr.Translate([]byte(`{"type":"message.updated","properties":{"info":{"cost":0.25,"tokens":{"input":10,"output":7,"reasoning":3,"cacheRead":2,"cacheWrite":1,"total":23}}}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.Provider != "opencode" || got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.25 { + t.Fatalf("usage = %#v", got) + } + if got.Raw["total_tokens"] != float64(23) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +func TestTranslateStepFinishCapturesUsage(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-step-finish") + _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":12576,"input":11803,"output":645,"reasoning":0,"cache":{"write":4,"read":128}},"cost":0.00432258}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":25,"input":20,"output":3,"reasoning":2,"cache":{"write":1,"read":4}},"cost":0.001}}`)) + if err != nil { + t.Fatalf("Translate second step: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.InputTokens != 11823 || got.OutputTokens != 648 || got.CostUSD != 0.00532258 { + t.Fatalf("usage = %#v", got) + } + if got.Raw["total_tokens"] != float64(12601) || got.Raw["reasoning_tokens"] != float64(2) || got.Raw["cache_read_tokens"] != float64(132) || got.Raw["cache_write_tokens"] != float64(5) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +func TestPlainOutputFallsBackToDelta(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-p") + _, _ = tr.Translate([]byte("plain output")) + envs := tr.TerminalEnvelopes(nil, "", false) + if len(envs) < 2 || envs[0].Type != proto.TypeDelta || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("plain terminal envs = %#v", envs) + } + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "plain output" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestTerminalErrorIncludesStderr(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-e") + envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) + if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("error terminal envs = %#v", envs) + } + errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) + if errPayload.Error == "" || !contains(errPayload.Error, "bad auth") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return sub == "" +} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go b/apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go new file mode 100644 index 000000000..6a5dbacd6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go @@ -0,0 +1,96 @@ +package opencode_test + +import ( + "fmt" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTranslateCompletedToolsPreservesTraceAndReply(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-tools") + for i, tool := range []string{"skill", "bash", "qa-service-desk_get_test_ticket", "read", "read"} { + id := fmt.Sprintf("call_%d", i) + line := []byte(fmt.Sprintf(`{"type":"tool_use","part":{"type":"tool","callID":%q,"tool":%q,"state":{"status":"completed","input":{"name":"qa-onboarding-zip","limit":3,"enabled":true},"output":"synthetic result"}}}`, id, tool)) + tx, err := tr.Translate(line) + if err != nil || len(tx.Envelopes) != 2 { + t.Fatalf("tool %s: envelopes=%#v err=%v", id, tx.Envelopes, err) + } + for j, stage := range []string{"before", "after"} { + env := tx.Envelopes[j] + call := decodePayload[proto.ToolCallPayload](t, env) + if env.Type != proto.TypeToolCall || env.ID != "run-tools" || call.ID != id || call.Name != tool || call.Stage != stage { + t.Fatalf("tool envelope=%#v payload=%#v", env, call) + } + if call.Args["name"] != "qa-onboarding-zip" || call.Args["limit"] != float64(3) || call.Args["enabled"] != true { + t.Fatalf("typed args = %#v", call.Args) + } + if stage == "before" && call.Result != nil { + t.Fatalf("call already has result: %#v", call.Result) + } + if stage == "after" && (call.Result["output"] != "synthetic result" || call.Result["is_error"] != false) { + t.Fatalf("tool result = %#v", call.Result) + } + } + duplicate, err := tr.Translate(line) + if err != nil || len(duplicate.Envelopes) != 0 { + t.Fatalf("duplicate call %s: %#v, %v", id, duplicate, err) + } + } + if _, err := tr.Translate([]byte(`{"type":"text","part":{"type":"text","text":"third working day"}}`)); err != nil { + t.Fatal(err) + } + if _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"tokens":{"input":12,"output":4},"cost":0.01}}`)); err != nil { + t.Fatal(err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "third working day" || done.Usage.InputTokens != 12 || done.Usage.OutputTokens != 4 || done.Usage.CostUSD != 0.01 { + t.Fatalf("done = %#v", done) + } +} + +func TestTranslateFailedToolRemainsToolFailure(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-error") + tx, err := tr.Translate([]byte(`{"type":"tool_use","part":{"type":"tool","callID":"call_error","tool":"read","state":{"status":"error","input":{"filePath":"/missing"},"error":"File not found"}}}`)) + if err != nil || len(tx.Envelopes) != 2 { + t.Fatalf("envelopes=%#v err=%v", tx.Envelopes, err) + } + result := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[1]) + if result.Result["is_error"] != true || result.Result["output"] != "File not found" || result.Args["filePath"] != "/missing" { + t.Fatalf("failed tool = %#v", result) + } + // A recoverable tool error must not become a run error or contaminate text. + _, _ = tr.Translate([]byte(`{"type":"text","part":{"type":"text","text":"Please supply a valid path."}}`)) + for _, env := range tr.TerminalEnvelopes(nil, "", false) { + if env.Type == proto.TypeError { + t.Fatal("tool failure became run failure") + } + if env.Type == proto.TypeDone && decodePayload[proto.DonePayload](t, env).Content != "Please supply a valid path." { + t.Fatal("tool failure leaked into final text") + } + } +} + +func TestTranslateToolRequiresTerminalIdentity(t *testing.T) { + for _, part := range []string{ + `null`, + `{"type":"text","callID":"c","tool":"read","state":{"status":"completed"}}`, + `{"type":"tool","tool":"read","state":{"status":"completed"}}`, + `{"type":"tool","callID":"c","state":{"status":"completed"}}`, + `{"type":"tool","callID":"c","tool":"read","state":{"status":"pending"}}`, + `{"type":"tool","callID":"c","tool":"read","state":{"status":"running"}}`, + } { + tr := opencode.NewTranslatorForTest("run-ignored") + tx, err := tr.Translate([]byte(`{"type":"tool_use","part":` + part + `}`)) + if err != nil || len(tx.Envelopes) != 0 { + t.Fatalf("unexpected events for %s: %#v, %v", part, tx, err) + } + // An incomplete frame must not consume the eventual terminal call ID. + tx, err = tr.Translate([]byte(`{"type":"tool_use","part":{"type":"tool","callID":"c","tool":"read","state":{"status":"completed","output":""}}}`)) + if err != nil || len(tx.Envelopes) != 2 { + t.Fatalf("terminal events missing: %#v, %v", tx, err) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/opencode/session.go b/apps/parsar-daemon/internal/agent/opencode/session.go new file mode 100644 index 000000000..f6f83c7d8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/session.go @@ -0,0 +1,217 @@ +// Package opencode is the agent_kind="opencode" adapter. It drives the +// OpenCode CLI via `opencode run --format json`. +package opencode + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +type sessionConfig struct { + opencodeBinary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{opencodeBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="opencode". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `opencode run` subprocess. +type Session struct { + runID string + model string + cfg sessionConfig + + cmd *exec.Cmd + out chan<- proto.Envelope + + cancelCtx context.Context + cancelFn context.CancelFunc + + cancelOnce sync.Once + closeOutOnce sync.Once + waitDone chan struct{} + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("opencode: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.opencodeBinary == "" { + cfg.opencodeBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + opts, err := prepareManagedSkills(parent, cfg.logger, req) + if err != nil { + return nil, err + } + + buildRes, err := BuildArgs(req.RunID, req.Prompt, req.WorkDir, opts) + if err != nil { + return nil, fmt.Errorf("opencode: build args: %w", err) + } + cancelCtx, cancelFn := context.WithCancel(parent) + + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + cmd := exec.CommandContext(cancelCtx, cfg.opencodeBinary, args...) + if buildRes.WorkDir != "" { + cmd.Dir = buildRes.WorkDir + } + cmd.Env = append(os.Environ(), buildRes.Env...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: start %q: %w", cfg.opencodeBinary, err) + } + model := buildRes.ModelSelector + if _, key, qualified := strings.Cut(model, "/"); qualified { + model = key + } + + s := &Session{ + runID: req.RunID, + model: model, + cfg: cfg, + cmd: cmd, + out: out, + cancelCtx: cancelCtx, + cancelFn: cancelFn, + waitDone: make(chan struct{}), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(stderr) + go s.run(stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + if s.cmd.Process == nil { + return + } + _ = s.cmd.Process.Signal(syscall.SIGTERM) + go func() { + select { + case <-s.waitDone: + return + case <-time.After(s.cfg.killTimeout): + _ = s.cmd.Process.Signal(syscall.SIGKILL) + } + }() + s.cancelFn() + }) + return nil +} + +func (s *Session) run(stdout io.Reader) { + defer close(s.waitDone) + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + tr.usage.Model = s.model + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tx, err := tr.Translate(sc.Bytes()) + if err != nil { + s.cfg.logger.Warn("opencode: translate line", "run_id", s.runID, "err", err) + continue + } + for _, env := range tx.Envelopes { + select { + case s.out <- env: + case <-s.cancelCtx.Done(): + _ = s.cmd.Wait() + return + } + } + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("opencode: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.cmd.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("opencode stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("opencode: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/opencode/session_model_test.go b/apps/parsar-daemon/internal/agent/opencode/session_model_test.go new file mode 100644 index 000000000..534ec0c98 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/session_model_test.go @@ -0,0 +1,57 @@ +package opencode_test + +import ( + "context" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSessionUsageCarriesSelectedModel(t *testing.T) { + for _, tc := range []struct { + name, selector, fallback, want string + }{ + {"managed selector wins", "anthropic/MiniMax-M3", "old-model", "MiniMax-M3"}, + {"model path", "openrouter/anthropic/claude", "", "anthropic/claude"}, + {"legacy model", "", "openai/gpt-4o", "gpt-4o"}, + {"bare model", "", "MiniMax-M3", "MiniMax-M3"}, + {"native default remains unknown", "", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + req := opencodeHelperReq("run_model", "hello", "json-success") + req.AgentOptions["model_selector"] = tc.selector + req.AgentOptions["model"] = tc.fallback + out := make(chan proto.Envelope, 32) + session, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) + if err != nil { + t.Fatal(err) + } + defer session.Cancel(context.Background()) + events, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatal("session did not finish") + } + count := 0 + for _, event := range events { + var usage proto.Usage + switch event.Type { + case proto.TypeUsage: + usage = decodePayload[proto.UsagePayload](t, event).Usage + case proto.TypeDone: + usage = decodePayload[proto.DonePayload](t, event).Usage + default: + continue + } + count++ + if usage.Model != tc.want || usage.Provider != "opencode" || usage.InputTokens != 4 || usage.OutputTokens != 2 { + t.Fatalf("%s usage = %#v; want model %q with existing provider/tokens", event.Type, usage, tc.want) + } + } + if count != 2 { + t.Fatalf("usage and done frames = %d, want 2", count) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/opencode/session_test.go b/apps/parsar-daemon/internal/agent/opencode/session_test.go new file mode 100644 index 000000000..72bfbbf6a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/session_test.go @@ -0,0 +1,406 @@ +package opencode_test + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `opencode` when +// OPENCODE_TESTHELPER_ROLE is set, bypassing m.Run so the test +// framework's PASS line doesn't pollute fake stdout. +const opencodeHelperEnvKey = "OPENCODE_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(opencodeHelperEnvKey); role != "" { + runFakeOpenCode(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakeOpenCode(role string) { + if dumpPath := os.Getenv("OPENCODE_TESTHELPER_CONFIG_DUMP"); dumpPath != "" { + body, err := json.Marshal(map[string]string{ + "config_dir": os.Getenv("OPENCODE_CONFIG_DIR"), + "xdg_config_home": os.Getenv("XDG_CONFIG_HOME"), + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "encode managed config env: %v\n", err) + os.Exit(65) + } + if err := os.WriteFile(dumpPath, body, 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "dump managed config: %v\n", err) + os.Exit(65) + } + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + + sawRun := false + sawJSONFormat := false + for i, arg := range os.Args[1:] { + if arg == "run" { + sawRun = true + } + if arg == "--format" && i+2 <= len(os.Args[1:]) && os.Args[i+2] == "json" { + sawJSONFormat = true + } + } + + switch role { + case "json-success": + if !sawRun || !sawJSONFormat { + _, _ = os.Stderr.WriteString("missing opencode run --format json\n") + os.Exit(64) + } + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "hi ", + }, + }) + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "there", + }, + }) + _ = enc.Encode(map[string]any{ + "type": "message.updated", + "properties": map[string]any{ + "info": map[string]any{ + "cost": 0.12, + "tokens": map[string]any{ + "input": 4, + "output": 2, + "total": 6, + }, + }, + }, + }) + + case "plain-success": + _, _ = os.Stdout.WriteString("plain line one\nplain line two\n") + + case "nonzero": + _, _ = os.Stderr.WriteString("bad auth from fake opencode\n") + os.Exit(17) + + case "hang": + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "started", + }, + }) + time.Sleep(10 * time.Minute) + } +} + +func opencodeHelperConfig() opencode.SessionConfigForTest { + return opencode.SessionConfigForTest{ + OpenCodeBinary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func opencodeHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Prompt: prompt, + AgentOptions: map[string]any{ + "env": map[string]any{ + opencodeHelperEnvKey: role, + }, + }, + } +} + +func drainOpenCode(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_json", "hello", "json-success"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeDelta) + mustContainOpenCode(t, types, proto.TypeUsage) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + for _, env := range got { + if env.ID != "run_json" { + t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) + } + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "hi there" { + t.Fatalf("done content = %q, want hi there", done.Content) + } + if done.Usage.Provider != "opencode" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { + t.Fatalf("done usage = %#v", done.Usage) + } +} + +func TestSessionInstallsAndRegistersManagedSkills(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + t.Setenv("OPENCODE_CONFIG_DIR", "") + userConfigHome := filepath.Join(t.TempDir(), "user-config") + t.Setenv("XDG_CONFIG_HOME", userConfigHome) + body := openCodeSkillZip(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + dumpPath := filepath.Join(t.TempDir(), "opencode.json") + out := make(chan proto.Envelope, 32) + req := opencodeHelperReq("run_skills", "hello", "json-success") + req.ConversationID = "conv-skills" + req.AgentStateKey = "conv-skills/agent-1/opencode" + req.AgentOptions["skills"] = []any{map[string]any{ + "name": "find-skills", "version": "1.0.0", "download_url": srv.URL, + "sha256": fmt.Sprintf("%x", sha256.Sum256(body)), + }} + req.AgentOptions["env"].(map[string]any)["OPENCODE_TESTHELPER_CONFIG_DUMP"] = dumpPath + + sess, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainOpenCode(t, out, 5*time.Second); !closed { + t.Fatal("out did not close") + } + + skillRoot := filepath.Join(home, "runtime", "opencode", "state", "conv-skills", "agent-1", "opencode", "skills") + if _, err := os.Stat(filepath.Join(skillRoot, "find-skills", "SKILL.md")); err != nil { + t.Fatalf("managed skill missing: %v", err) + } + dumped, err := os.ReadFile(dumpPath) + if err != nil { + t.Fatalf("read dumped config: %v", err) + } + var configEnv map[string]string + if err := json.Unmarshal(dumped, &configEnv); err != nil { + t.Fatalf("decode dumped config env: %v", err) + } + if got, want := configEnv["config_dir"], filepath.Dir(skillRoot); got != want { + t.Fatalf("OPENCODE_CONFIG_DIR = %q, want %q", got, want) + } + if got := configEnv["xdg_config_home"]; got != userConfigHome { + t.Fatalf("XDG_CONFIG_HOME = %q, want inherited %q", got, userConfigHome) + } + + cleanupReq := opencodeHelperReq("run_skills_cleanup", "hello", "json-success") + cleanupReq.ConversationID = req.ConversationID + cleanupReq.AgentStateKey = req.AgentStateKey + cleanupOut := make(chan proto.Envelope, 32) + cleanupSession, err := opencode.NewSessionForTest(context.Background(), cleanupReq, cleanupOut, opencodeHelperConfig()) + if err != nil { + t.Fatalf("cleanup session: %v", err) + } + defer cleanupSession.Cancel(context.Background()) + if _, closed := drainOpenCode(t, cleanupOut, 5*time.Second); !closed { + t.Fatal("cleanup out did not close") + } + if _, err := os.Stat(filepath.Join(skillRoot, "find-skills")); !os.IsNotExist(err) { + t.Fatalf("unbound skill still exists: %v", err) + } +} + +func openCodeSkillZip(t *testing.T) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + entry, err := writer.Create("SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("---\nname: find-skills\ndescription: Find skills\n---\nUse the catalog.")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestSessionPlainStdoutFallsBackToDeltaAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_plain", "hello", "plain-success"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + if len(got) < 2 || got[0].Type != proto.TypeDelta || got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("plain envs = %v", types) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "plain line one\nplain line two" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_err", "hello", "nonzero"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeError) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "bad auth from fake opencode") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_cancel", "hello", "hang"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + // Let the helper emit its first delta before cancellation. + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeError) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } +} + +func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { + var session any = (*opencode.Session)(nil) + if _, ok := session.(agent.PermissionResponder); ok { + t.Fatal("unexpected permission responder") + } + if _, ok := session.(agent.UserChoiceResponder); ok { + t.Fatal("unexpected user-choice responder") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_nil", "hello", "json-success"), nil, opencodeHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + out := make(chan proto.Envelope, 4) + _, err := opencode.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "run_empty", Prompt: ""}, out, opencodeHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := opencodeHelperConfig() + cfg.OpenCodeBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_bad", "hello", "json-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func opencodeEnvTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} + +func mustContainOpenCode(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Fatalf("expected %q in %v", needle, haystack) + } +} diff --git a/apps/parsar-daemon/internal/agent/opencode/skills.go b/apps/parsar-daemon/internal/agent/opencode/skills.go new file mode 100644 index 000000000..9a78ff441 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/skills.go @@ -0,0 +1,134 @@ +package opencode + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const ( + openCodeConfigDirEnv = "OPENCODE_CONFIG_DIR" + openCodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" +) + +func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (map[string]any, error) { + rawSkills, hasSkills := req.AgentOptions["skills"] + if !hasSkills && strings.TrimSpace(req.AgentStateKey) == "" && strings.TrimSpace(req.ConversationID) == "" && strings.TrimSpace(req.RunID) == "" { + return req.AgentOptions, nil + } + root, err := agent.ManagedSkillsRoot("opencode", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return nil, fmt.Errorf("opencode: resolve managed skills root: %w", err) + } + result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) + if err != nil { + return nil, fmt.Errorf("opencode: install skills: %w", err) + } + for _, warning := range result.Warnings { + logger.Warn("opencode: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(result.SkillDirs) == 0 { + return req.AgentOptions, nil + } + return withOpenCodeSkillRoot(req.AgentOptions, root) +} + +func withOpenCodeSkillRoot(opts map[string]any, root string) (map[string]any, error) { + out := make(map[string]any, len(opts)+1) + for key, value := range opts { + out[key] = value + } + env := map[string]any{} + if raw := opts["env"]; raw != nil { + existing, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) + } + for key, value := range existing { + env[key] = value + } + } + configDir, err := openCodeEnvValue(env, openCodeConfigDirEnv) + if err != nil { + return nil, err + } + if strings.TrimSpace(configDir) == "" { + env[openCodeConfigDirEnv] = filepath.Dir(root) + } else { + inline, err := openCodeEnvValue(env, openCodeConfigContentEnv) + if err != nil { + return nil, err + } + inline, err = addOpenCodeSkillPath(inline, root) + if err != nil { + return nil, err + } + env[openCodeConfigContentEnv] = inline + } + out["env"] = env + return out, nil +} + +func openCodeEnvValue(env map[string]any, key string) (string, error) { + if raw, ok := env[key]; ok { + value, ok := raw.(string) + if !ok { + return "", fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", key, raw) + } + return value, nil + } + return os.Getenv(key), nil +} + +func addOpenCodeSkillPath(rawConfig, root string) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: %s must be valid JSON: %w", openCodeConfigContentEnv, err) + } + if config == nil { + config = map[string]any{} + } + } + skills, ok := config["skills"].(map[string]any) + if !ok && config["skills"] != nil { + return "", fmt.Errorf("opencode: %s skills must be object, got %T", openCodeConfigContentEnv, config["skills"]) + } + if skills == nil { + skills = map[string]any{} + } + paths := []any{} + if rawPaths := skills["paths"]; rawPaths != nil { + var ok bool + paths, ok = rawPaths.([]any) + if !ok { + return "", fmt.Errorf("opencode: %s skills.paths must be array, got %T", openCodeConfigContentEnv, rawPaths) + } + } + found := false + for _, path := range paths { + value, ok := path.(string) + if !ok { + return "", fmt.Errorf("opencode: %s skills.paths entries must be strings", openCodeConfigContentEnv) + } + found = found || value == root + } + if found { + return rawConfig, nil + } + skills["paths"] = append(paths, root) + config["skills"] = skills + body, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal %s: %w", openCodeConfigContentEnv, err) + } + return string(body), nil +} diff --git a/apps/parsar-daemon/internal/agent/opencode/skills_test.go b/apps/parsar-daemon/internal/agent/opencode/skills_test.go new file mode 100644 index 000000000..5219cd67f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/skills_test.go @@ -0,0 +1,38 @@ +package opencode + +import ( + "encoding/json" + "testing" +) + +func TestWithOpenCodeSkillRootPreservesExistingConfigDir(t *testing.T) { + t.Setenv(openCodeConfigDirEnv, "/user/opencode") + t.Setenv(openCodeConfigContentEnv, `{"agent":{"review":{}},"skills":{"paths":["/user/skills"],"urls":["https://example.com/skills"]}}`) + + opts, err := withOpenCodeSkillRoot(map[string]any{ + "env": map[string]any{"KEEP": "value"}, + }, "/managed/skills") + if err != nil { + t.Fatalf("withOpenCodeSkillRoot: %v", err) + } + env := opts["env"].(map[string]any) + if _, overridden := env[openCodeConfigDirEnv]; overridden { + t.Fatalf("%s must remain inherited", openCodeConfigDirEnv) + } + if env["KEEP"] != "value" { + t.Fatalf("existing env was not preserved: %v", env) + } + var config struct { + Agent map[string]any `json:"agent"` + Skills struct { + Paths []string `json:"paths"` + URLs []string `json:"urls"` + } `json:"skills"` + } + if err := json.Unmarshal([]byte(env[openCodeConfigContentEnv].(string)), &config); err != nil { + t.Fatal(err) + } + if len(config.Agent) != 1 || len(config.Skills.Paths) != 2 || config.Skills.Paths[1] != "/managed/skills" || len(config.Skills.URLs) != 1 { + t.Fatalf("merged config = %+v", config) + } +} diff --git a/apps/parsar-daemon/internal/agent/opencode/version.go b/apps/parsar-daemon/internal/agent/opencode/version.go new file mode 100644 index 000000000..f895c317f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/version.go @@ -0,0 +1,35 @@ +package opencode + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the OpenCode documentation when the +// daemon can see the adapter but not the CLI binary. +const InstallURL = "https://opencode.ai/docs" + +// defaultBinary is the executable to probe and spawn: binpath.OpenCode() +// honours the PARSAR_OPENCODE_BIN override so a bare-name PATH lookup can +// be bypassed in images where PATH is not under our control. A function +// rather than a const so the env is read at call time. +func defaultBinary() string { return binpath.OpenCode() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish an +// install problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("opencode CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "opencode", + DefaultBinary: defaultBinary(), + MissingError: ErrCLINotFound, + TrimBinary: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/opencode/version_test.go b/apps/parsar-daemon/internal/agent/opencode/version_test.go new file mode 100644 index 000000000..21f6bf46a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/opencode/version_test.go @@ -0,0 +1,18 @@ +package opencode_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "opencode", + DefaultBinary: "opencode", + MissingError: opencode.ErrCLINotFound, + Check: opencode.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/pi/export_test.go b/apps/parsar-daemon/internal/agent/pi/export_test.go new file mode 100644 index 000000000..229ce0021 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/export_test.go @@ -0,0 +1,36 @@ +package pi + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + PiBinary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + piBinary: cfg.PiBinary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +type Translation = translation + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} diff --git a/apps/parsar-daemon/internal/agent/pi/options.go b/apps/parsar-daemon/internal/agent/pi/options.go new file mode 100644 index 000000000..164c0044d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/options.go @@ -0,0 +1,217 @@ +package pi + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// BuildResult is the pi CLI launch plan for one prompt. Cleanup is +// always non-nil so callers can defer it blindly. +type BuildResult struct { + Args []string + Env []string + WorkDir string + Cleanup func() +} + +// BuildArgs translates the daemon prompt_request into a `pi --mode json` invocation. +func BuildArgs(runID, prompt, workDir string, opts map[string]any, resumeSessionID string) (BuildResult, error) { + _ = runID + result := BuildResult{Cleanup: func() {}} + + resolvedWorkDir, err := resolveWorkDir(workDir) + if err != nil { + return result, err + } + + promptText, err := buildPrompt(prompt) + if err != nil { + return result, err + } + + // --mode json: machine-readable NDJSON output for the translator. + // (Non-interactive mode is switched on by the trailing `-p` flag + // below, which pi consumes together with the prompt argument — + // that flag must be last for pi's arg parser to bind the prompt + // correctly, so we can't add it here.) + args := []string{"--mode", "json"} + + if model := stringOpt(opts, "model"); model != "" { + args = append(args, "--model", model) + } + if provider := stringOpt(opts, "provider"); provider != "" { + args = append(args, "--provider", provider) + } + // A managed api_key is deliberately NOT forwarded as --api-key: secrets + // ride the environment (PARSAR_PI_API_KEY, referenced from the + // materialised models.json) so they never land on the pi child's argv, + // where `ps` would leak them. See server injectPiManagedModel. + + // override replaces the base system prompt and wins over append. + if override := stringOpt(opts, "override_system_prompt"); override != "" { + args = append(args, "--system-prompt", override) + } else if sys := stringOpt(opts, "system_prompt"); sys != "" { + args = append(args, "--append-system-prompt", sys) + } + + if sessionDir := stringOpt(opts, "session_dir"); sessionDir != "" { + resolvedSessionDir, err := resolveSessionDirOption(sessionDir) + if err != nil { + return result, err + } + args = append(args, "--session-dir", resolvedSessionDir) + } + + resume := strings.TrimSpace(resumeSessionID) + if resume != "" { + args = append(args, "--session", resume) + } + + skillDirs, err := stringSlice(opts["skill_dirs"]) + if err != nil { + return result, fmt.Errorf("pi.BuildArgs: skill_dirs: %w", err) + } + for _, d := range skillDirs { + if d = strings.TrimSpace(d); d != "" { + args = append(args, "--skill", d) + } + } + + // pi's -p consumes the immediately-following arg as the prompt, so it + // must be appended last, after every other flag. + args = append(args, "-p", promptText) + + env, err := buildEnv(opts) + if err != nil { + return result, err + } + + result.Args = args + result.Env = env + result.WorkDir = resolvedWorkDir + return result, nil +} + +func resolveWorkDir(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pi: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("pi: work_dir must be absolute or start with ~/, got %q", trimmed) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("pi: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func resolveSessionDirOption(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pi: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("pi: session_dir must be absolute or start with ~/, got %q", trimmed) + } + if err := os.MkdirAll(abs, 0o700); err != nil { + return "", fmt.Errorf("pi: mkdir session_dir %s: %w", abs, err) + } + return abs, nil +} + +func buildPrompt(prompt string) (string, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "", fmt.Errorf("pi: empty prompt") + } + return prompt, nil +} + +func buildEnv(opts map[string]any) ([]string, error) { + // PI_TELEMETRY=0 force-disables pi's opt-in install telemetry for + // unattended daemon runs (pi reads PI_TELEMETRY, not DISABLE_TELEMETRY). + env := []string{"PI_TELEMETRY=0"} + raw, ok := opts["env"] + if !ok || raw == nil { + return env, nil + } + envMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("pi.BuildArgs: env must be object, got %T", raw) + } + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return nil, fmt.Errorf("pi.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +// stringSlice coerces a value to []string, accepting a typed []string or +// the []any json.Unmarshal produces for a JSON array. nil yields nil. +func stringSlice(v any) ([]string, error) { + switch x := v.(type) { + case nil: + return nil, nil + case []string: + return x, nil + case []any: + out := make([]string, 0, len(x)) + for i, el := range x { + s, ok := el.(string) + if !ok { + return nil, fmt.Errorf("element %d must be string, got %T", i, el) + } + out = append(out, s) + } + return out, nil + default: + return nil, fmt.Errorf("must be array of strings, got %T", v) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/options_test.go b/apps/parsar-daemon/internal/agent/pi/options_test.go new file mode 100644 index 000000000..9576c5610 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/options_test.go @@ -0,0 +1,240 @@ +package pi_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" +) + +func TestBuildArgsUsesModeJsonAndPromptLast(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ + "model": "anthropic/claude-opus-4-7", + "api_key": "sk-test", + "provider": "anthropic", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + if !containsPair(res.Args, "--mode", "json") { + t.Fatalf("args missing --mode json: %v", res.Args) + } + if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { + t.Fatalf("args missing --model: %v", res.Args) + } + // Secrets must never ride on argv (ps would leak them): a provided + // api_key is intentionally dropped here and delivered via env instead + // (see server injectPiManagedModel / PARSAR_PI_API_KEY). + if slices.Contains(res.Args, "--api-key") || slices.Contains(res.Args, "sk-test") { + t.Fatalf("api_key must not leak onto argv: %v", res.Args) + } + if !containsPair(res.Args, "--provider", "anthropic") { + t.Fatalf("args missing --provider: %v", res.Args) + } + // pi's -p consumes the immediately-following arg as the prompt, so the + // prompt MUST be the final arg, preceded by -p. + n := len(res.Args) + if n < 2 || res.Args[n-2] != "-p" || res.Args[n-1] != "hello" { + t.Fatalf("expected args to end with -p hello, got %v", res.Args) + } + if res.WorkDir != os.TempDir() { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "./relative", nil, "") + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := pi.BuildArgs("run-1", "hello", target, nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if res.WorkDir != target { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, target) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildArgsSystemPromptAppends(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "system_prompt": "be terse", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--append-system-prompt", "be terse") { + t.Fatalf("args missing --append-system-prompt: %v", res.Args) + } + if slices.Contains(res.Args, "--system-prompt") { + t.Fatalf("system_prompt must map to append, not override: %v", res.Args) + } +} + +func TestBuildArgsOverrideSystemPromptReplaces(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are root", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--system-prompt", "you are root") { + t.Fatalf("args missing --system-prompt override: %v", res.Args) + } + // Override wins: the append we tentatively added must be stripped. + if slices.Contains(res.Args, "--append-system-prompt") { + t.Fatalf("override must strip the append: %v", res.Args) + } +} + +func TestBuildArgsResumeSessionUsesExplicitID(t *testing.T) { + sessionDir := filepath.Join(t.TempDir(), "sessions") + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "resume_session_id": "from-opts", + "session_dir": sessionDir, + }, "from-param") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--session-dir", sessionDir) { + t.Fatalf("args missing --session-dir: %v", res.Args) + } + if !containsPair(res.Args, "--session", "from-param") { + t.Fatalf("explicit resume id must win: %v", res.Args) + } +} + +func TestBuildArgsSessionDirCreatesAndAddsFlag(t *testing.T) { + sessionDir := filepath.Join(t.TempDir(), "missing", "sessions") + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "session_dir": sessionDir, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--session-dir", sessionDir) { + t.Fatalf("args missing --session-dir: %v", res.Args) + } + info, err := os.Stat(sessionDir) + if err != nil { + t.Fatalf("stat session_dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("session_dir %q is not a directory", sessionDir) + } +} + +func TestBuildArgsRejectsRelativeSessionDir(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"session_dir": "./sessions"}, "") + if err == nil || !strings.Contains(err.Error(), "session_dir") { + t.Fatalf("BuildArgs session_dir err = %v, want session_dir error", err) + } +} + +func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "resume_session_id": "sess-42", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "--session") || slices.Contains(res.Args, "sess-42") { + t.Fatalf("resume_session_id option must be ignored: %v", res.Args) + } +} + +func TestBuildArgsSkillDirsRepeatFlag(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "skill_dirs": []any{"/skills/a", "/skills/b"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--skill", "/skills/a") { + t.Fatalf("args missing first --skill: %v", res.Args) + } + if !containsPair(res.Args, "--skill", "/skills/b") { + t.Fatalf("args missing second --skill: %v", res.Args) + } +} + +func TestBuildArgsTelemetryOptOutEnv(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if envValue(res.Env, "PI_TELEMETRY") != "0" { + t.Fatalf("expected PI_TELEMETRY=0 opt-out, env=%v", res.Env) + } +} + +func TestBuildArgsPassesThroughEnvSorted(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "env": map[string]any{"BBB": "2", "AAA": "1"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if envValue(res.Env, "AAA") != "1" || envValue(res.Env, "BBB") != "2" { + t.Fatalf("env passthrough missing: %v", res.Env) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}, "") + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { + _, err := pi.BuildArgs("run-1", " ", "", nil, "") + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if v, ok := strings.CutPrefix(item, prefix); ok { + return v + } + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/pi/parser.go b/apps/parsar-daemon/internal/agent/pi/parser.go new file mode 100644 index 000000000..ac7d59bf9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/parser.go @@ -0,0 +1,337 @@ +package pi + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// translator converts one NDJSON line from pi's `--mode json` stdout +// into zero or more proto.Envelope frames. One translator lives per +// session. pi has no explicit terminal frame: the stream ends at process +// EOF, so the session pump calls terminalEnvelopes after cmd.Wait. +type translator struct { + runID string + seq atomic.Uint64 + + deltaBuf strings.Builder + finalText string + rawLines []string + usage proto.Usage + usageSet bool + sessionID string + failed bool +} + +type translation struct { + Envelopes []proto.Envelope + // SessionID is surfaced from the session header line so session.go + // can write it into binding metadata for --session resume. + SessionID string +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + t.rawLines = append(t.rawLines, string(line)) + + var head struct { + Type string `json:"type"` + } + // pi emits strict JSON per line; a non-JSON line is a stray log, not + // a fatal error — skip it to keep one bad line from killing the run. + if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { + return translation{}, nil + } + + switch head.Type { + case "session": + return t.translateSessionHeader(line) + case "message_update": + return t.translateMessageUpdate(line) + case "tool_execution_start": + return t.translateToolStart(line) + case "tool_execution_end": + return t.translateToolEnd(line) + case "message_end": + return t.translateMessageEnd(line) + default: + return translation{}, nil + } +} + +func (t *translator) translateSessionHeader(line []byte) (translation, error) { + var msg struct { + ID string `json:"id"` + } + _ = json.Unmarshal(line, &msg) + if msg.ID != "" { + t.sessionID = msg.ID + } + return translation{SessionID: msg.ID}, nil +} + +func (t *translator) translateMessageUpdate(line []byte) (translation, error) { + var msg struct { + Event struct { + Type string `json:"type"` + Delta string `json:"delta"` + } `json:"assistantMessageEvent"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse message_update: %w", err) + } + switch msg.Event.Type { + case "text_delta": + if msg.Event.Delta == "" { + return translation{}, nil + } + t.deltaBuf.WriteString(msg.Event.Delta) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: msg.Event.Delta, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + case "thinking_delta": + if msg.Event.Delta == "" { + return translation{}, nil + } + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: msg.Event.Delta, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + default: + return translation{}, nil + } +} + +func (t *translator) translateToolStart(line []byte) (translation, error) { + var msg struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Args map[string]any `json:"args"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse tool_execution_start: %w", err) + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: msg.ToolCallID, + Name: msg.ToolName, + Stage: "before", + Args: msg.Args, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateToolEnd(line []byte) (translation, error) { + var msg struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Result any `json:"result"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse tool_execution_end: %w", err) + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: msg.ToolCallID, + Name: msg.ToolName, + Stage: "after", + Result: map[string]any{ + "content": msg.Result, + "is_error": msg.IsError, + }, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +type piUsage struct { + Input int32 `json:"input"` + Output int32 `json:"output"` + CacheRead int32 `json:"cacheRead"` + CacheWrite int32 `json:"cacheWrite"` + CacheWrite1h int32 `json:"cacheWrite1h"` + Reasoning int32 `json:"reasoning"` + TotalTokens int32 `json:"totalTokens"` + Cost struct { + Total float64 `json:"total"` + } `json:"cost"` +} + +func (t *translator) translateMessageEnd(line []byte) (translation, error) { + var msg struct { + Message struct { + Role string `json:"role"` + Content []json.RawMessage + Provider string `json:"provider"` + Model string `json:"model"` + Usage piUsage `json:"usage"` + StopReason string `json:"stopReason"` + ErrorMessage string `json:"errorMessage"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse message_end: %w", err) + } + if msg.Message.Role != "assistant" { + return translation{}, nil + } + + t.mergeUsage(msg.Message.Usage, msg.Message.Provider, msg.Message.Model) + if text := extractText(msg.Message.Content); text != "" { + t.finalText = text + } + + if msg.Message.StopReason == "error" { + t.failed = true + errMsg := msg.Message.ErrorMessage + if errMsg == "" { + errMsg = "pi: assistant message ended with error" + } + env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + } + return translation{}, nil +} + +func extractText(content []json.RawMessage) string { + var b strings.Builder + for _, raw := range content { + var item struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + if item.Type == "text" { + b.WriteString(item.Text) + } + } + return b.String() +} + +func (t *translator) mergeUsage(u piUsage, provider, model string) { + t.usageSet = true + t.usage.InputTokens += u.Input + t.usage.OutputTokens += u.Output + t.usage.CostUSD += u.Cost.Total + if provider != "" { + t.usage.Provider = provider + } + if model != "" { + t.usage.Model = model + } + // pi reports usage per assistant message; a tool loop yields several + // message_end frames, so accumulate cache/reasoning/total the same way as + // input/output above — overwriting would leave Raw showing only the last + // frame while the summed token counts reflect all of them. + t.addRawTokens("cache_read_tokens", u.CacheRead) + t.addRawTokens("cache_write_tokens", u.CacheWrite) + t.addRawTokens("cache_write_1h_tokens", u.CacheWrite1h) + t.addRawTokens("reasoning_tokens", u.Reasoning) + t.addRawTokens("total_tokens", u.TotalTokens) +} + +// addRawTokens sums one counter into usage.Raw. In-process the values stay +// int32 (matching piUsage); they only become float64 once the envelope is +// JSON-encoded downstream, which is why the lookup asserts int32. +func (t *translator) addRawTokens(key string, v int32) { + if v == 0 { + return + } + if t.usage.Raw == nil { + t.usage.Raw = map[string]any{} + } + if existing, ok := t.usage.Raw[key].(int32); ok { + v += existing + } + t.usage.Raw[key] = v +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + + content := strings.TrimSpace(t.deltaBuf.String()) + if content == "" && t.finalText != "" { + content = strings.TrimSpace(t.finalText) + if content != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: content, Sequence: t.seq.Add(1)}); err == nil { + envs = append(envs, env) + } + } + } + + usage := t.usage + if usage.Provider == "" { + usage.Provider = "pi" + } + if t.usageSet { + if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { + envs = append(envs, env) + } + } + + terminalFailed := t.failed || waitErr != nil || cancelled + if waitErr != nil || cancelled { + msg := "pi: subprocess exited without success" + if waitErr != nil { + msg = fmt.Sprintf("pi: subprocess exited: %v", waitErr) + } + if strings.TrimSpace(stderr) != "" { + msg += ": " + truncate(strings.TrimSpace(stderr), 400) + } + if cancelled { + msg = "pi: cancelled" + } + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { + envs = append(envs, env) + } + } + + metadata := map[string]any{"connector_path": "pi_print"} + if t.sessionID != "" && !terminalFailed { + metadata[proto.DoneMetaAgentSessionID] = t.sessionID + metadata[proto.DoneMetaAgentSessionType] = "pi_session" + } + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ + Content: content, + Transcript: strings.Join(t.rawLines, "\n"), + Usage: usage, + Metadata: metadata, + }); err == nil { + envs = append(envs, env) + } + return envs +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/parsar-daemon/internal/agent/pi/parser_test.go b/apps/parsar-daemon/internal/agent/pi/parser_test.go new file mode 100644 index 000000000..86f744164 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/parser_test.go @@ -0,0 +1,259 @@ +package pi_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestTranslateSessionHeaderSurfacesSessionID(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if tx.SessionID != "sess-abc" { + t.Fatalf("SessionID = %q, want sess-abc", tx.SessionID) + } + if len(tx.Envelopes) != 0 { + t.Fatalf("session header should emit no envelopes, got %#v", tx.Envelopes) + } +} + +func TestTranslateTextDeltaEmitsDelta(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "hello" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } +} + +func TestTranslateThinkingDeltaEmitsThinking(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"pondering"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeThinking { + t.Fatalf("thinking envelopes = %#v", tx.Envelopes) + } + think := decodePayload[proto.ThinkingPayload](t, tx.Envelopes[0]) + if think.Text != "pondering" || think.Sequence == 0 { + t.Fatalf("thinking payload = %#v", think) + } +} + +func TestTranslateToolExecutionStartEmitsBeforeToolCall(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"tool_execution_start","toolCallId":"t1","toolName":"bash","args":{"cmd":"ls"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { + t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) + } + tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) + if tc.ID != "t1" || tc.Name != "bash" || tc.Stage != "before" { + t.Fatalf("toolcall payload = %#v", tc) + } + if tc.Args["cmd"] != "ls" { + t.Fatalf("toolcall args = %#v", tc.Args) + } +} + +func TestTranslateToolExecutionEndEmitsAfterToolCall(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"tool_execution_end","toolCallId":"t1","toolName":"bash","result":{"stdout":"x"},"isError":true}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { + t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) + } + tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) + if tc.ID != "t1" || tc.Stage != "after" { + t.Fatalf("toolcall payload = %#v", tc) + } + if tc.Result["is_error"] != true { + t.Fatalf("toolcall result = %#v", tc.Result) + } +} + +func TestTranslateMessageEndCapturesUsage(t *testing.T) { + tr := pi.NewTranslatorForTest("run-u") + line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"provider":"anthropic","model":"claude-x","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"input":0.1,"output":0.2,"cacheRead":0,"cacheWrite":0,"total":0.3}},"stopReason":"stop"}}` + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.Provider != "anthropic" || got.Model != "claude-x" { + t.Fatalf("usage provider/model = %#v", got) + } + if got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.3 { + t.Fatalf("usage tokens/cost = %#v", got) + } + if got.Raw["cache_read_tokens"] != float64(2) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +// A tool loop makes pi emit one message_end per assistant turn. Every +// counter — including the cache/reasoning/total ones parked in Raw — must +// sum across frames, not get clobbered by the final frame. +func TestTranslateMessageEndAccumulatesUsageAcrossFrames(t *testing.T) { + tr := pi.NewTranslatorForTest("run-multi") + first := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"a"}],"provider":"anthropic","model":"m","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"total":0.3}},"stopReason":"tool_use"}}` + second := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"provider":"anthropic","model":"m","usage":{"input":5,"output":4,"cacheRead":6,"cacheWrite":2,"reasoning":1,"totalTokens":12,"cost":{"total":0.2}},"stopReason":"stop"}}` + for _, line := range []string{first, second} { + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.InputTokens != 15 || got.OutputTokens != 11 { + t.Fatalf("summed input/output = %d/%d, want 15/11", got.InputTokens, got.OutputTokens) + } + if got.CostUSD < 0.49 || got.CostUSD > 0.51 { + t.Fatalf("summed cost = %v, want ~0.5", got.CostUSD) + } + // Raw round-trips through JSON, so the counters decode back as float64. + for key, want := range map[string]float64{ + "cache_read_tokens": 8, + "cache_write_tokens": 3, + "reasoning_tokens": 4, + "total_tokens": 35, + } { + if got.Raw[key] != want { + t.Fatalf("Raw[%q] = %#v, want %v (must sum across frames)", key, got.Raw[key], want) + } + } +} + +// pi exits 0 even when the model errors: it emits a message_end whose +// assistant message carries stopReason "error". The parser MUST surface +// that as TypeError despite the clean process exit. +func TestTranslateMessageEndErrorStopReasonEmitsError(t *testing.T) { + tr := pi.NewTranslatorForTest("run-err") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-bad","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + line := `{"type":"message_end","message":{"role":"assistant","content":[],"provider":"anthropic","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"boom"}}` + tx, err := tr.Translate([]byte(line)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + var found bool + for _, env := range tx.Envelopes { + if env.Type == proto.TypeError { + ep := decodePayload[proto.ErrorPayload](t, env) + if strings.Contains(ep.Error, "boom") { + found = true + } + } + } + if !found { + t.Fatalf("expected TypeError mentioning boom, got %#v", tx.Envelopes) + } + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("failed pi turn must not persist session metadata: %#v", done.Metadata) + } +} + +func TestTerminalAlwaysEmitsDoneWithSessionMetadata(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + if _, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)); err != nil { + t.Fatalf("Translate delta: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + last := envs[len(envs)-1] + if last.Type != proto.TypeDone { + t.Fatalf("last env = %q, want done", last.Type) + } + done := decodePayload[proto.DonePayload](t, last) + if done.Content != "hello" { + t.Fatalf("done content = %q, want hello", done.Content) + } + if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-abc" { + t.Fatalf("done metadata = %#v, want agent_session_id sess-abc", done.Metadata) + } + if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { + t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) + } +} + +func TestTerminalErrorIncludesStderr(t *testing.T) { + tr := pi.NewTranslatorForTest("run-e") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-failed","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) + if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("error terminal envs = %#v", envs) + } + errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) + if !strings.Contains(errPayload.Error, "bad auth") { + t.Fatalf("error payload = %#v", errPayload) + } + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("failed pi process must not persist session metadata: %#v", done.Metadata) + } +} + +func TestMessageEndContentFallbackWhenNoDeltas(t *testing.T) { + tr := pi.NewTranslatorForTest("run-f") + line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"final answer"}],"provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop"}}` + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "final answer" { + t.Fatalf("done content = %q, want final answer", done.Content) + } +} + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config.go b/apps/parsar-daemon/internal/agent/pi/provider_config.go new file mode 100644 index 000000000..48794e60c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/provider_config.go @@ -0,0 +1,211 @@ +package pi + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// piManagedProviderSlug is the provider key the daemon always writes into +// models.json, and the server pins opts["model"] to "parsar/" so +// pi routes through this entry instead of a built-in provider. +const piManagedProviderSlug = "parsar" + +// piAgentDirEnvVar is pi's sole override for its config directory +// (config.ts ENV_AGENT_DIR); pi reads models.json from /models.json. +const piAgentDirEnvVar = "PI_CODING_AGENT_DIR" + +type piProviderConfig struct { + Name string + BaseURL string + API string + APIKeyEnv string + Model string + Headers map[string]string + AuthHeader bool +} + +func writePiModelsJSON(agentDir string, cfg piProviderConfig) error { + if strings.TrimSpace(cfg.BaseURL) == "" { + return fmt.Errorf("pi: provider base_url is required") + } + if strings.TrimSpace(cfg.API) == "" { + return fmt.Errorf("pi: provider api is required") + } + if strings.TrimSpace(cfg.APIKeyEnv) == "" { + return fmt.Errorf("pi: provider api_key_env is required") + } + if strings.TrimSpace(cfg.Model) == "" { + return fmt.Errorf("pi: provider model is required") + } + if err := os.MkdirAll(agentDir, 0o700); err != nil { + return fmt.Errorf("pi: mkdir agent dir %s: %w", agentDir, err) + } + + provider := map[string]any{ + "baseUrl": cfg.BaseURL, + "api": cfg.API, + // pi runs apiKey through resolveConfigValue (resolve-config-value.ts): + // only a "$NAME" / "${NAME}" template is looked up in process.env; a + // bare string is treated as a LITERAL key. So the env var name must be + // written with a "$" prefix, otherwise pi sends "PARSAR_PI_API_KEY" + // verbatim to the provider and the request 401s. + "apiKey": "$" + cfg.APIKeyEnv, + "models": []map[string]any{{"id": cfg.Model}}, + } + if cfg.Name != "" { + provider["name"] = cfg.Name + } + if len(cfg.Headers) > 0 { + provider["headers"] = cfg.Headers + } + if cfg.AuthHeader { + provider["authHeader"] = true + } + + doc := map[string]any{"providers": map[string]any{piManagedProviderSlug: provider}} + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("pi: marshal models.json: %w", err) + } + path := filepath.Join(agentDir, "models.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("pi: write %s: %w", path, err) + } + return nil +} + +// normalisePiProvider flattens agent_options["pi_provider"] (the string-keyed +// map injectPiManagedModel emits) into a typed piProviderConfig. Returns +// hasProvider=false when the key is absent so callers skip materialisation. +// Required-field validation lives in writePiModelsJSON (single source). +func normalisePiProvider(raw any) (piProviderConfig, bool, error) { + if raw == nil { + return piProviderConfig{}, false, nil + } + m, ok := raw.(map[string]any) + if !ok { + return piProviderConfig{}, false, fmt.Errorf("pi: pi_provider must be object, got %T", raw) + } + cfg := piProviderConfig{ + Name: stringOpt(m, "name"), + BaseURL: stringOpt(m, "base_url"), + API: stringOpt(m, "api"), + APIKeyEnv: stringOpt(m, "api_key_env"), + Model: stringOpt(m, "model"), + } + if v, ok := m["auth_header"].(bool); ok { + cfg.AuthHeader = v + } + if hdrs, ok := m["headers"].(map[string]any); ok { + cfg.Headers = make(map[string]string, len(hdrs)) + for k, v := range hdrs { + if s, ok := v.(string); ok { + cfg.Headers[k] = s + } + } + } + return cfg, true, nil +} + +// resolveAgentDir returns the directory set as PI_CODING_AGENT_DIR for this +// prompt. AgentStateKey is preferred because it scopes by conversation, agent, +// and engine; conversation/run fallbacks exist for older callers and tests. +func resolveAgentDir(agentStateKey, conversationID, runID string) (string, error) { + root, err := paths.Root() + if err != nil { + return "", fmt.Errorf("pi: resolve state root: %w", err) + } + base := filepath.Join(root, "runtime", "pi") + if key := strings.TrimSpace(agentStateKey); key != "" { + parts := safeStatePathParts(key) + if len(parts) == 0 { + return "", fmt.Errorf("pi: invalid agentStateKey %q", agentStateKey) + } + dirParts := append([]string{base, "state"}, parts...) + return filepath.Join(append(dirParts, "agent")...), nil + } + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "agent"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "agent"), nil +} + +func resolveSessionDir(agentDir string) (string, error) { + sessionDir := filepath.Join(agentDir, "sessions") + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + return "", fmt.Errorf("pi: mkdir session dir %s: %w", sessionDir, err) + } + return sessionDir, nil +} + +// applyPiRuntimeState returns a clone of opts with a stable pi --session-dir. +// When opts["pi_provider"] is present it also writes models.json and injects +// PI_CODING_AGENT_DIR into opts["env"] so buildEnv forwards it. +func applyPiRuntimeState(opts map[string]any, agentStateKey, conversationID, runID string) (map[string]any, error) { + agentDir, err := resolveAgentDir(agentStateKey, conversationID, runID) + if err != nil { + return opts, err + } + sessionDir, err := resolveSessionDir(agentDir) + if err != nil { + return opts, err + } + + out := cloneAgentOptions(opts) + out["session_dir"] = sessionDir + + cfg, ok, err := normalisePiProvider(opts["pi_provider"]) + if err != nil { + return opts, err + } + if !ok { + return out, nil + } + if err := writePiModelsJSON(agentDir, cfg); err != nil { + return opts, err + } + out["env"] = withAgentDirEnv(opts["env"], agentDir) + return out, nil +} + +func safeStatePathParts(key string) []string { + rawParts := strings.Split(key, "/") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + if safe := safeStatePathPart(part); safe != "" { + parts = append(parts, safe) + } + } + return parts +} + +func safeStatePathPart(part string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(part) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "." || out == ".." { + return "" + } + return out +} + +func withAgentDirEnv(existing any, agentDir string) map[string]any { + out := map[string]any{} + if m, ok := existing.(map[string]any); ok { + maps.Copy(out, m) + } + out[piAgentDirEnvVar] = agentDir + return out +} diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config_test.go b/apps/parsar-daemon/internal/agent/pi/provider_config_test.go new file mode 100644 index 000000000..618abb15b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/provider_config_test.go @@ -0,0 +1,308 @@ +package pi + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// modelsFile mirrors the subset of pi's models.json schema this adapter +// emits (packages/coding-agent/src/core/model-registry.ts ProviderConfigSchema). +type modelsFile struct { + Providers map[string]struct { + Name string `json:"name"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + API string `json:"api"` + Headers map[string]string `json:"headers"` + AuthHeader bool `json:"authHeader"` + Models []struct { + ID string `json:"id"` + } `json:"models"` + } `json:"providers"` +} + +func readModelsJSON(t *testing.T, dir string) modelsFile { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, "models.json")) + if err != nil { + t.Fatalf("read models.json: %v", err) + } + var mf modelsFile + if err := json.Unmarshal(raw, &mf); err != nil { + t.Fatalf("models.json is not valid JSON: %v\n%s", err, raw) + } + return mf +} + +func TestWritePiModelsJSON_AnthropicProvider(t *testing.T) { + dir := t.TempDir() + cfg := piProviderConfig{ + Name: "Parsar Anthropic", + BaseURL: "https://platform-api.example.com", + API: "anthropic-messages", + APIKeyEnv: "PARSAR_PI_API_KEY", + Model: "claude-opus-4-6-thinking-max", + Headers: map[string]string{"X-Sub-Module": "claude-code-internal"}, + } + if err := writePiModelsJSON(dir, cfg); err != nil { + t.Fatalf("writePiModelsJSON: %v", err) + } + + p, ok := readModelsJSON(t, dir).Providers[piManagedProviderSlug] + if !ok { + t.Fatalf("models.json missing provider %q", piManagedProviderSlug) + } + if p.BaseURL != cfg.BaseURL { + t.Errorf("baseUrl = %q, want %q", p.BaseURL, cfg.BaseURL) + } + if p.API != "anthropic-messages" { + t.Errorf("api = %q, want anthropic-messages", p.API) + } + // pi's resolveConfigValue() only resolves a "$NAME" template from + // process.env; a bare string is a literal key. So the env var name must + // be written with a "$" prefix. + if p.APIKey != "$PARSAR_PI_API_KEY" { + t.Errorf("apiKey = %q, want $PARSAR_PI_API_KEY (env ref, with $)", p.APIKey) + } + if p.Headers["X-Sub-Module"] != "claude-code-internal" { + t.Errorf("headers[X-Sub-Module] = %q, want claude-code-internal", p.Headers["X-Sub-Module"]) + } + if len(p.Models) != 1 || p.Models[0].ID != cfg.Model { + t.Errorf("models = %+v, want one model id %q", p.Models, cfg.Model) + } + if p.Name != cfg.Name { + t.Errorf("name = %q, want %q", p.Name, cfg.Name) + } + // anthropic-messages carries auth via x-api-key (the resolved apiKey), + // not an Authorization bearer header, so authHeader must stay false. + if p.AuthHeader { + t.Errorf("authHeader = true, want false for anthropic-messages") + } +} + +func TestWritePiModelsJSON_OpenAIAuthHeader(t *testing.T) { + dir := t.TempDir() + cfg := piProviderConfig{ + BaseURL: "https://gw.example.com/v1", + API: "openai-completions", + APIKeyEnv: "PARSAR_PI_API_KEY", + Model: "gpt-5.5", + AuthHeader: true, + } + if err := writePiModelsJSON(dir, cfg); err != nil { + t.Fatalf("writePiModelsJSON: %v", err) + } + p := readModelsJSON(t, dir).Providers[piManagedProviderSlug] + if p.API != "openai-completions" { + t.Errorf("api = %q, want openai-completions", p.API) + } + if !p.AuthHeader { + t.Errorf("authHeader = false, want true so pi sends Authorization: Bearer") + } +} + +func TestWritePiModelsJSON_RejectsMissingFields(t *testing.T) { + base := piProviderConfig{ + BaseURL: "https://x/v1", + API: "anthropic-messages", + APIKeyEnv: "PARSAR_PI_API_KEY", + Model: "m", + } + cases := map[string]func(*piProviderConfig){ + "missing base_url": func(c *piProviderConfig) { c.BaseURL = "" }, + "missing api": func(c *piProviderConfig) { c.API = "" }, + "missing api_key_env": func(c *piProviderConfig) { c.APIKeyEnv = "" }, + "missing model": func(c *piProviderConfig) { c.Model = "" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := base + mutate(&cfg) + dir := t.TempDir() + if err := writePiModelsJSON(dir, cfg); err == nil { + t.Fatalf("expected error for %s, got nil", name) + } + if _, err := os.Stat(filepath.Join(dir, "models.json")); err == nil { + t.Fatalf("%s: models.json must not be written on invalid config", name) + } + }) + } +} + +func TestNormalisePiProvider_FullRoundTrip(t *testing.T) { + raw := map[string]any{ + "name": "Parsar Anthropic", + "base_url": "https://platform-api.example.com", + "api": "openai-completions", + "api_key_env": "PARSAR_PI_API_KEY", + "model": "gpt-5.5", + "auth_header": true, + // Headers cross the daemon boundary as JSON, so they arrive as + // map[string]any even though the server typed them map[string]string. + "headers": map[string]any{"X-Sub-Module": "codex-internal"}, + } + cfg, ok, err := normalisePiProvider(raw) + if err != nil { + t.Fatalf("normalisePiProvider: %v", err) + } + if !ok { + t.Fatal("hasProvider must be true for non-nil raw") + } + if cfg.Name != "Parsar Anthropic" || cfg.BaseURL != "https://platform-api.example.com" { + t.Fatalf("scalar fields wrong: %+v", cfg) + } + if cfg.API != "openai-completions" || cfg.APIKeyEnv != "PARSAR_PI_API_KEY" || cfg.Model != "gpt-5.5" { + t.Fatalf("scalar fields wrong: %+v", cfg) + } + if !cfg.AuthHeader { + t.Fatalf("auth_header lost: %+v", cfg) + } + if cfg.Headers["X-Sub-Module"] != "codex-internal" { + t.Fatalf("headers lost: %+v", cfg.Headers) + } +} + +func TestNormalisePiProvider_Nil(t *testing.T) { + cfg, ok, err := normalisePiProvider(nil) + if err != nil { + t.Fatalf("normalisePiProvider nil: %v", err) + } + if ok { + t.Fatal("hasProvider must be false for nil") + } + _ = cfg +} + +func TestNormalisePiProvider_WrongType(t *testing.T) { + if _, _, err := normalisePiProvider("not-an-object"); err == nil { + t.Fatal("expected error for non-object pi_provider") + } +} + +func TestResolveAgentDirConversationScoped(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveAgentDir("", "conv-abc", "run-1") + if err != nil { + t.Fatalf("resolveAgentDir: %v", err) + } + // Sibling of resolveSkillsRoot's conv-/skills so one conversation's + // pi runtime state (models.json, sessions) co-locates under one dir. + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "conv-conv-abc", "agent") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveAgentDirStateKeyScoped(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveAgentDir("conv-abc/agent-xyz/pi", "ignored-conv", "run-1") + if err != nil { + t.Fatalf("resolveAgentDir: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "state", "conv-abc", "agent-xyz", "pi", "agent") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveAgentDirSanitizesStateKey(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveAgentDir("../conv abc/agent:xyz/pi", "ignored-conv", "run-1") + if err != nil { + t.Fatalf("resolveAgentDir: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "state", "conv_abc", "agent_xyz", "pi", "agent") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveAgentDirRunScopedFallback(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveAgentDir("", "", "run-9") + if err != nil { + t.Fatalf("resolveAgentDir: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "run-run-9", "agent") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestApplyPiRuntimeState_WritesModelsSetsEnvAndSessionDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + callerEnv := map[string]any{"PARSAR_PI_API_KEY": "sk-proxy", "OTHER": "x"} + opts := map[string]any{ + "model": "parsar/claude-opus-4-6-thinking-max", + "env": callerEnv, + "pi_provider": map[string]any{ + "base_url": "https://platform-api.example.com", + "api": "anthropic-messages", + "api_key_env": "PARSAR_PI_API_KEY", + "model": "claude-opus-4-6-thinking-max", + "headers": map[string]any{"X-Sub-Module": "claude-code-internal"}, + }, + } + + out, err := applyPiRuntimeState(opts, "conv-xyz/agent-1/pi", "ignored-conv", "run-1") + if err != nil { + t.Fatalf("applyPiRuntimeState: %v", err) + } + + agentDir := filepath.Join(tmp, ".parsar", "runtime", "pi", "state", "conv-xyz", "agent-1", "pi", "agent") + sessionDir := filepath.Join(agentDir, "sessions") + env, ok := out["env"].(map[string]any) + if !ok { + t.Fatalf("out[env] not a map: %T", out["env"]) + } + if env["PI_CODING_AGENT_DIR"] != agentDir { + t.Errorf("PI_CODING_AGENT_DIR = %v, want %q", env["PI_CODING_AGENT_DIR"], agentDir) + } + if env["PARSAR_PI_API_KEY"] != "sk-proxy" || env["OTHER"] != "x" { + t.Errorf("pre-existing env not preserved: %+v", env) + } + if out["session_dir"] != sessionDir { + t.Errorf("session_dir = %v, want %q", out["session_dir"], sessionDir) + } + if info, err := os.Stat(sessionDir); err != nil || !info.IsDir() { + t.Fatalf("session dir not created at %s: %v", sessionDir, err) + } + + p := readModelsJSON(t, agentDir).Providers[piManagedProviderSlug] + if p.APIKey != "$PARSAR_PI_API_KEY" || p.BaseURL != "https://platform-api.example.com" { + t.Errorf("models.json not materialised correctly: %+v", p) + } + + // The caller's env map must be untouched — buildEnv reads opts["env"] + // and a shared reference would leak PI_CODING_AGENT_DIR back to the + // server-owned options map across turns. + if _, leaked := callerEnv["PI_CODING_AGENT_DIR"]; leaked { + t.Error("applyPiRuntimeState mutated the caller's env map") + } +} + +func TestApplyPiRuntimeState_NoProviderStillPinsSessionDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + opts := map[string]any{"model": "anthropic/x"} + out, err := applyPiRuntimeState(opts, "conv-1/agent-1/pi", "ignored-conv", "run-1") + if err != nil { + t.Fatalf("applyPiRuntimeState: %v", err) + } + if env, ok := out["env"].(map[string]any); ok { + if _, set := env["PI_CODING_AGENT_DIR"]; set { + t.Fatal("PI_CODING_AGENT_DIR must not be set when no pi_provider present") + } + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "state", "conv-1", "agent-1", "pi", "agent", "sessions") + if out["session_dir"] != want { + t.Fatalf("session_dir = %v, want %q", out["session_dir"], want) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/session.go b/apps/parsar-daemon/internal/agent/pi/session.go new file mode 100644 index 000000000..4abb3a195 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/session.go @@ -0,0 +1,211 @@ +// Package pi is the agent_kind="pi" adapter. It drives the pi CLI via +// `pi --mode json -p `, translating pi's NDJSON event stream on +// stdout into proto.Envelope frames for the dispatch router. +package pi + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +type sessionConfig struct { + piBinary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{piBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="pi". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `pi --mode json` subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + out chan<- proto.Envelope + + cancelCtx context.Context + + cancelOnce sync.Once + closeOutOnce sync.Once + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("pi: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.piBinary == "" { + cfg.piBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + // pi needs an explicit --skill flag per skill (unlike Claude Code's + // auto-scan), so installSkills returns dirs even on a cache hit. + opts := req.AgentOptions + if rawSkills, ok := opts["skills"]; ok { + descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) + for _, w := range decodeWarns { + cfg.logger.Warn("pi: skill descriptor decode warning", "run_id", req.RunID, "msg", w) + } + root, rErr := resolveSkillsRoot(req.ConversationID, req.RunID) + if rErr != nil { + return nil, fmt.Errorf("pi: resolve skills root: %w", rErr) + } + installRes, iErr := installSkills(parent, cfg.logger, root, descriptors) + if iErr != nil { + return nil, fmt.Errorf("pi: install skills: %w", iErr) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("pi: skill install warning", "run_id", req.RunID, "msg", w) + } + if len(installRes.SkillDirs) > 0 { + opts = cloneAgentOptions(req.AgentOptions) + opts["skill_dirs"] = mergeSkillDirs(opts["skill_dirs"], installRes.SkillDirs) + } + } + + // Materialise pi's managed config and pin --session-dir to the stable + // conversation/agent/engine state key so --session can resolve reliably. + provOpts, provErr := applyPiRuntimeState(opts, req.AgentStateKey, req.ConversationID, req.RunID) + if provErr != nil { + return nil, fmt.Errorf("pi: apply managed provider: %w", provErr) + } + opts = provOpts + + buildRes, err := BuildArgs(req.RunID, req.Prompt, req.WorkDir, opts, req.AgentSessionID) + if err != nil { + return nil, fmt.Errorf("pi: build args: %w", err) + } + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + dir := "" + if buildRes.WorkDir != "" { + dir = buildRes.WorkDir + } + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.piBinary, + Args: args, + Dir: dir, + Env: append(os.Environ(), buildRes.Env...), + KillTimeout: cfg.killTimeout, + }) + if err != nil { + buildRes.Cleanup() + return nil, fmt.Errorf("pi: start %q: %w", cfg.piBinary, err) + } + + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + out: out, + cancelCtx: proc.Context(), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + s.proc.Cancel() + }) + return nil +} + +func (s *Session) run(stdout io.Reader) { + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tx, err := tr.Translate(sc.Bytes()) + if err != nil { + s.cfg.logger.Warn("pi: translate line", "run_id", s.runID, "err", err) + continue + } + for _, env := range tx.Envelopes { + select { + case s.out <- env: + case <-s.cancelCtx.Done(): + _ = s.proc.Wait() + return + } + } + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("pi: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.proc.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("pi stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("pi: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/pi/session_provider_test.go b/apps/parsar-daemon/internal/agent/pi/session_provider_test.go new file mode 100644 index 000000000..2e2cfd2da --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/session_provider_test.go @@ -0,0 +1,75 @@ +package pi + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestNewSessionMaterialisesPiProviderModelsJSON proves agent_options["pi_provider"] +// flows through newSession → models.json on disk at the per-conversation agent +// dir. The env-forwarding of PI_CODING_AGENT_DIR is covered by the +// applyPiRuntimeState + buildEnv unit tests. +func TestNewSessionMaterialisesPiProviderModelsJSON(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + out := make(chan proto.Envelope, 64) + req := proto.PromptRequestPayload{ + RunID: "run_prov", + ConversationID: "conv-prov", + AgentStateKey: "conv-prov/agent-prov/pi", + Prompt: "hello", + AgentOptions: map[string]any{ + "model": "parsar/claude-opus-4-6-thinking-max", + "pi_provider": map[string]any{ + "base_url": "https://platform-api.example.com", + "api": "anthropic-messages", + "api_key_env": "PARSAR_PI_API_KEY", + "model": "claude-opus-4-6-thinking-max", + "headers": map[string]any{"X-Sub-Module": "claude-code-internal"}, + }, + "env": map[string]any{ + "PI_TESTHELPER_ROLE": "json-success", + "PARSAR_PI_API_KEY": "sk-proxy", + }, + }, + } + sess, err := newSession(context.Background(), req, out, sessionConfig{ + piBinary: os.Args[0], + extraArgs: []string{"-test.run=^$"}, + killTimeout: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("newSession: %v", err) + } + defer sess.Cancel(context.Background()) + + deadline := time.After(5 * time.Second) + for draining := true; draining; { + select { + case _, ok := <-out: + if !ok { + draining = false + } + case <-deadline: + t.Fatal("out did not close") + } + } + + agentDir := filepath.Join(home, ".parsar", "runtime", "pi", "state", "conv-prov", "agent-prov", "pi", "agent") + p := readModelsJSON(t, agentDir).Providers[piManagedProviderSlug] + if p.BaseURL != "https://platform-api.example.com" { + t.Fatalf("models.json baseUrl wrong: %+v", p) + } + if p.APIKey != "$PARSAR_PI_API_KEY" { + t.Fatalf("models.json apiKey = %q, want $PARSAR_PI_API_KEY", p.APIKey) + } + if _, err := os.Stat(filepath.Join(agentDir, "sessions")); err != nil { + t.Fatalf("session dir not created: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/session_skills_test.go b/apps/parsar-daemon/internal/agent/pi/session_skills_test.go new file mode 100644 index 000000000..ed7d03181 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/session_skills_test.go @@ -0,0 +1,86 @@ +package pi + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestNewSessionInstallsSkillsAndInjectsSkillFlag is the end-to-end proof +// that agent_options["skills"] flows download → disk → repeated --skill +// argv on the pi subprocess. The fake pi records its argv into +// PI_TESTHELPER_ARGS_FILE (see runFakePi). +func TestNewSessionInstallsSkillsAndInjectsSkillFlag(t *testing.T) { + body := validSkillZip(t) + srv := startZipServer(t, body) + home := t.TempDir() + t.Setenv("HOME", home) + + argsFile := filepath.Join(t.TempDir(), "argv") + out := make(chan proto.Envelope, 64) + req := proto.PromptRequestPayload{ + RunID: "run_skill", + ConversationID: "conv-skill", + Prompt: "hello", + AgentOptions: map[string]any{ + "skills": []any{ + map[string]any{ + "name": "code-review", "version": "1.0.0", + "download_url": srv.URL, "sha256": sha256Hex(body), + }, + }, + "env": map[string]any{ + "PI_TESTHELPER_ROLE": "json-success", + "PI_TESTHELPER_ARGS_FILE": argsFile, + }, + }, + } + sess, err := newSession(context.Background(), req, out, sessionConfig{ + piBinary: os.Args[0], + extraArgs: []string{"-test.run=^$"}, + killTimeout: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("newSession: %v", err) + } + defer sess.Cancel(context.Background()) + + deadline := time.After(5 * time.Second) + for draining := true; draining; { + select { + case _, ok := <-out: + if !ok { + draining = false + } + case <-deadline: + t.Fatal("out did not close") + } + } + + wantDir := filepath.Join(home, ".parsar", "runtime", "pi", "conv-conv-skill", "skills", "code-review") + if _, err := os.Stat(filepath.Join(wantDir, "SKILL.md")); err != nil { + t.Fatalf("SKILL.md not installed at %s: %v", wantDir, err) + } + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + argv := strings.Split(string(raw), "\n") + if !containsArgPair(argv, "--skill", wantDir) { + t.Fatalf("argv missing --skill %s: %v", wantDir, argv) + } +} + +func containsArgPair(argv []string, flag, val string) bool { + for i, a := range argv { + if a == flag && i+1 < len(argv) && argv[i+1] == val { + return true + } + } + return false +} diff --git a/apps/parsar-daemon/internal/agent/pi/session_test.go b/apps/parsar-daemon/internal/agent/pi/session_test.go new file mode 100644 index 000000000..4260c45df --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/session_test.go @@ -0,0 +1,331 @@ +package pi_test + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `pi` when +// PI_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS +// line doesn't pollute fake stdout. +const piHelperEnvKey = "PI_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(piHelperEnvKey); role != "" { + runFakePi(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakePi(role string) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + + // Record argv so the skill-injection test can assert --skill + // reached the subprocess. + if argsFile := os.Getenv("PI_TESTHELPER_ARGS_FILE"); argsFile != "" { + _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o644) + } + + sawModeJSON := false + for i, arg := range os.Args { + if arg == "--mode" && i+1 < len(os.Args) && os.Args[i+1] == "json" { + sawModeJSON = true + } + } + + header := map[string]any{"type": "session", "id": "sess-xyz", "cwd": "/", "timestamp": "t"} + delta := func(s string) map[string]any { + return map[string]any{ + "type": "message_update", + "message": map[string]any{"role": "assistant"}, + "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": s}, + } + } + + switch role { + case "json-success": + if !sawModeJSON { + _, _ = os.Stderr.WriteString("missing --mode json\n") + os.Exit(64) + } + _ = enc.Encode(header) + _ = enc.Encode(delta("hi ")) + _ = enc.Encode(delta("there")) + _ = enc.Encode(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + "content": []any{map[string]any{"type": "text", "text": "hi there"}}, + "provider": "anthropic", + "model": "claude-x", + "stopReason": "stop", + "usage": map[string]any{ + "input": 4, "output": 2, "cacheRead": 0, "cacheWrite": 0, + "totalTokens": 6, + "cost": map[string]any{"input": 0.1, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.12}, + }, + }, + }) + + case "error-stop": + // pi exits 0 even when the model errors — it just emits a + // message_end with stopReason "error". + _ = enc.Encode(header) + _ = enc.Encode(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + "content": []any{}, + "provider": "anthropic", + "model": "claude-x", + "stopReason": "error", + "errorMessage": "model boom", + "usage": map[string]any{ + "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0, + "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}, + }, + }, + }) + + case "nonzero": + _, _ = os.Stderr.WriteString("bad auth from fake pi\n") + os.Exit(17) + + case "hang": + _ = enc.Encode(header) + _ = enc.Encode(delta("started")) + time.Sleep(10 * time.Minute) + } +} + +func piHelperConfig() pi.SessionConfigForTest { + return pi.SessionConfigForTest{ + PiBinary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func piHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Prompt: prompt, + AgentOptions: map[string]any{ + "env": map[string]any{ + piHelperEnvKey: role, + }, + }, + } +} + +func drainPi(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_json", "hello", "json-success"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeDelta) + mustContainPi(t, types, proto.TypeUsage) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + for _, env := range got { + if env.ID != "run_json" { + t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) + } + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "hi there" { + t.Fatalf("done content = %q, want hi there", done.Content) + } + if done.Usage.Provider != "anthropic" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { + t.Fatalf("done usage = %#v", done.Usage) + } + if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-xyz" { + t.Fatalf("done metadata = %#v, want agent_session_id sess-xyz", done.Metadata) + } + if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { + t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) + } +} + +// pi exits 0 on a model error: the session must still surface TypeError +// (from stopReason) and Done, and close out. +func TestSessionModelErrorStopReasonStillEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_mod_err", "hello", "error-stop"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeError) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "model boom") { + t.Fatalf("error payload = %#v, want model boom", errPayload) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("model error must not persist session metadata: %#v", done.Metadata) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_err", "hello", "nonzero"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeError) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "bad auth from fake pi") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_cancel", "hello", "hang"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } +} + +func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { + var session any = (*pi.Session)(nil) + if _, ok := session.(agent.PermissionResponder); ok { + t.Fatal("unexpected permission responder") + } + if _, ok := session.(agent.UserChoiceResponder); ok { + t.Fatal("unexpected user-choice responder") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_nil", "hello", "json-success"), nil, piHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + out := make(chan proto.Envelope, 4) + _, err := pi.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "run_empty", Prompt: ""}, out, piHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := piHelperConfig() + cfg.PiBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_bad", "hello", "json-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func piEnvTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} + +func mustContainPi(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Fatalf("expected %q in %v", needle, haystack) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/skills.go b/apps/parsar-daemon/internal/agent/pi/skills.go new file mode 100644 index 000000000..b82c57d6a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/skills.go @@ -0,0 +1,393 @@ +package pi + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// skillDescriptor is the daemon-side view of one server-sent skill entry +// under agent_options["skills"]: +// +// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } +type skillDescriptor struct { + Name string + Version string + DownloadURL string + SHA256 string +} + +// SkillInstallResult carries the per-skill directories to feed into +// repeated `--skill ` flags plus warnings the session surfaces. +// Unlike Claude Code (which auto-scans .claude/skills/), pi needs an +// explicit flag per skill, so SkillDirs is populated even on a cache hit. +type SkillInstallResult struct { + SkillDirs []string + Warnings []string +} + +const skillInstallTimeout = 60 * time.Second + +// maxSkillZipBytes mirrors the server-side cap. Defense in depth. +const maxSkillZipBytes int64 = 32 * 1024 * 1024 + +var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} + +// fetchSkillZip GETs url into dst, capping the body at maxSkillZipBytes. +// Returns an OPEN file descriptor at offset 0; the caller closes it. +// Holding the FD across verify + extract closes the TOCTOU between +// hashing the on-disk bytes and reading them for extract. +// +// Only http/https are accepted to defend against a future download_url +// reaching this code with file:// or http://internal-ip/... values. +func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { + parsed, err := url.Parse(downloadURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, errors.New("download_url must be http(s)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, errors.New("build request failed") + } + resp, err := skillsHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + return nil, fmt.Errorf("get: status %d", resp.StatusCode) + } + + f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open dst: %w", err) + } + + limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) + written, err := io.Copy(f, limited) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("copy body: %w", err) + } + if written > maxSkillZipBytes { + _ = f.Close() + return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = f.Close() + return nil, fmt.Errorf("seek after write: %w", err) + } + return f, nil +} + +// sanitizeHTTPClientError strips the URL embedded by *url.Error so a +// presigned download_url (OSSAccessKeyId + Signature) never lands in the +// daemon log. Format is ` "": `. +func sanitizeHTTPClientError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + open := strings.Index(msg, `"`) + if open < 0 { + return msg + } + closeRel := strings.Index(msg[open+1:], `"`) + if closeRel < 0 { + return msg + } + closeAbs := open + 1 + closeRel + if closeAbs+2 > len(msg) { + return msg + } + return msg[:open] + "" + msg[closeAbs+1:] +} + +func verifySHA256FromFD(fd *os.File, want string) error { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" { + return errors.New("verify: empty expected sha256") + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("verify: seek: %w", err) + } + h := sha256.New() + if _, err := io.Copy(h, fd); err != nil { + return fmt.Errorf("verify: hash: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) + } + return nil +} + +// extractSkillZipFromFD reads via io.NewSectionReader rather than +// re-opening the path so the byte stream stays identical to the verified +// one (TOCTOU defense). +func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { + zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) + if err != nil { + return fmt.Errorf("extract: open zip: %w", err) + } + + root := detectSingleZipRoot(zr.File) + absDst, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("extract: abs dst: %w", err) + } + + for _, f := range zr.File { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + // Skip non-regular entries (symlinks, devices). A symlink entry + // would otherwise be written as a plain file holding the link + // target string — an exfil vector. + mode := f.Mode() + if !f.FileInfo().IsDir() && !mode.IsRegular() { + continue + } + if root != "" { + if !strings.HasPrefix(name, root) { + continue + } + name = strings.TrimPrefix(name, root) + if name == "" { + continue + } + } + + target := filepath.Join(absDst, name) + rel, err := filepath.Rel(absDst, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("extract: entry %q escapes target", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("extract: mkdir %s: %w", target, err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) + } + if err := writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("extract: open entry %s: %w", f.Name, err) + } + defer rc.Close() + + mode := f.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return fmt.Errorf("extract: open target %s: %w", target, err) + } + defer out.Close() + if _, err := io.Copy(out, rc); err != nil { + return fmt.Errorf("extract: copy %s: %w", target, err) + } + return nil +} + +// detectSingleZipRoot returns the common wrapping directory (with +// trailing slash) shared by every non-MACOSX entry, or "" when there is +// none. Bare directory entries (no internal "/") are skipped when picking +// the first candidate so `zip -r skill skill/` doesn't short-circuit on +// its own leading directory entry. Hidden roots (".*") are NOT treated as +// wrappers. +func detectSingleZipRoot(files []*zip.File) string { + var first string + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if !strings.Contains(name, "/") { + continue + } + first = name + break + } + if first == "" { + return "" + } + idx := strings.Index(first, "/") + if idx <= 0 { + return "" + } + root := first[:idx+1] + if strings.HasPrefix(root, ".") { + return "" + } + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if name+"/" == root { + continue + } + if !strings.HasPrefix(name, root) { + return "" + } + } + return root +} + +func normaliseZipPath(name string) string { + p := strings.ReplaceAll(name, "\\", "/") + return strings.TrimSuffix(p, "/") +} + +// decodeSkillDescriptors converts agent_options["skills"] into typed +// descriptors. Entries that fail to decode are dropped with a warning; +// the rest may still be installable. +func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} + } + out := make([]skillDescriptor, 0, len(items)) + warnings := make([]string, 0) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) + continue + } + s := skillDescriptor{ + Name: stringField(obj, "name"), + Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), + SHA256: stringField(obj, "sha256"), + } + if err := s.validate(); err != nil { + warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) + continue + } + out = append(out, s) + } + return out, warnings +} + +func stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func (s skillDescriptor) validate() error { + if strings.TrimSpace(s.Name) == "" { + return errors.New("name is required") + } + // Block path-traversal names before they hit filepath.Join. + if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { + return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) + } + if strings.TrimSpace(s.DownloadURL) == "" { + return errors.New("download_url is required") + } + if len(s.SHA256) != 64 { + return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) + } + return nil +} + +func (s skillDescriptor) cacheKey() string { + return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) +} + +// resolveSkillsRoot returns the absolute directory under which managed +// skills install, one subdir per skill. Kept under ~/.parsar/ (runtime +// state lives there, not the user's project tree) and scoped per +// conversation so consecutive turns reuse .cache-key files without two +// conversations racing the same skill dir. runID scopes the one-shot +// fallback when there is no conversation. +func resolveSkillsRoot(conversationID, runID string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pi skills: resolve home: %w", err) + } + base := filepath.Join(home, ".parsar", "runtime", "pi") + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "skills"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil +} + +// mergeSkillDirs combines a caller-supplied skill_dirs override (accepted +// as []string OR []any) with the install-resolved list, preserving order +// and deduplicating. Override wins on collision. +func mergeSkillDirs(existing any, resolved []string) []string { + preset := coerceStringSlice(existing) + seen := make(map[string]bool, len(preset)+len(resolved)) + out := make([]string, 0, len(preset)+len(resolved)) + for _, d := range append(append([]string{}, preset...), resolved...) { + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +func coerceStringSlice(v any) []string { + switch t := v.(type) { + case nil: + return nil + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// cloneAgentOptions returns a shallow copy so we never mutate the +// caller's map when overwriting the top-level "skill_dirs" key. +func cloneAgentOptions(opts map[string]any) map[string]any { + if opts == nil { + return map[string]any{} + } + out := make(map[string]any, len(opts)) + maps.Copy(out, opts) + return out +} diff --git a/apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go b/apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go new file mode 100644 index 000000000..d0864c5ac --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go @@ -0,0 +1,78 @@ +package pi + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestInstallSkillsConcurrentSameRoot(t *testing.T) { + const content = "Complete skill contents.\n" + body := buildZipBytes(t, []zipFile{{Name: "SKILL.md", Body: content}}) + started, release := make(chan struct{}, 8), make(chan struct{}) + var downloads atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downloads.Add(1) + started <- struct{}{} + select { + case <-release: + _, _ = w.Write(body) + case <-r.Context().Done(): + } + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + root := t.TempDir() + skills := []skillDescriptor{{Name: "fixture", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}} + install := func(ctx context.Context) error { + result, err := installSkills(ctx, discardLogger(), root, skills) + if err != nil { + return err + } + if len(result.SkillDirs) != 1 || len(result.Warnings) != 0 { + return fmt.Errorf("dirs=%v warnings=%v", result.SkillDirs, result.Warnings) + } + data, err := os.ReadFile(filepath.Join(result.SkillDirs[0], "SKILL.md")) + if err != nil { + return err + } + if string(data) != content { + return fmt.Errorf("incomplete skill: %q", data) + } + return nil + } + results := make(chan error, 8) + go func() { results <- install(ctx) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("first download did not start") + } + for range 7 { + go func() { results <- install(ctx) }() + } + waiting, cancelWait := context.WithTimeout(ctx, 30*time.Millisecond) + defer cancelWait() + if err := install(waiting); err != context.DeadlineExceeded { + t.Errorf("waiting install error=%v, want deadline exceeded", err) + } + close(release) + for range 8 { + if err := <-results; err != nil { + t.Error(err) + } + } + if err := install(ctx); err != nil { + t.Errorf("subsequent cached install: %v", err) + } + if downloads.Load() != 1 { + t.Errorf("downloads=%d, want one cached installation", downloads.Load()) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/skills_install.go b/apps/parsar-daemon/internal/agent/pi/skills_install.go new file mode 100644 index 000000000..2a00e250b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/skills_install.go @@ -0,0 +1,134 @@ +package pi + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/google/uuid" +) + +// installSkills materialises every skill under // and returns +// the local paths. Per skill: +// +// 1. Cache hit (/.cache-key == name@sha256) returns the dir without +// a network round-trip — but still returns it, so --skill is injected +// on every turn. +// 2. Fetch → verify SHA-256 → extract (single wrapping dir stripped, +// __MACOSX/ ignored) → stamp .cache-key. +// +// Errors during fetch/verify/extract demote one skill to a warning and +// continue. A hard error means the root dir itself was uncreatable. +func installSkills( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, +) (SkillInstallResult, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + if strings.TrimSpace(root) == "" { + return SkillInstallResult{}, errors.New("pi skills: root is required") + } + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return SkillInstallResult{}, err + } + defer unlock() + + result := SkillInstallResult{} + for _, s := range skills { + if err := s.validate(); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) + logger.Warn("pi skills: invalid descriptor", "err", err.Error()) + continue + } + + dir := filepath.Join(root, s.Name) + cacheKey := filepath.Join(dir, ".cache-key") + expectedKey := s.cacheKey() + + if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { + logger.Info("pi skills: cache hit", "name", s.Name, "version", s.Version, "dir", dir) + result.SkillDirs = append(result.SkillDirs, dir) + continue + } + + perCtx, cancel := context.WithTimeout(ctx, skillInstallTimeout) + err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s) + cancel() + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) + logger.Warn("pi skills: install failed", "name", s.Name, "version", s.Version, "err", err.Error()) + continue + } + result.SkillDirs = append(result.SkillDirs, dir) + logger.Info("pi skills: installed", "name", s.Name, "version", s.Version, "dir", dir) + } + return result, nil +} + +func installOneSkill( + ctx context.Context, + logger *slog.Logger, + root, dir, cacheKey, expectedKey string, + s skillDescriptor, +) error { + tmpDir := filepath.Join(root, ".tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + + // Per-call uuid so concurrent installs of the same (name, version) + // don't truncate each other's bytes, and nothing on disk between + // verify and extract can be a different file than the one hashed. + zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) + defer func() { _ = os.Remove(zipPath) }() + + fd, err := fetchSkillZip(ctx, s.DownloadURL, zipPath) + if err != nil { + return err + } + defer fd.Close() + + // Verify and extract BOTH read through the same FD (not the path): + // Unix file semantics pin the inode, so a swap on disk between + // hashing and extraction cannot change the bytes we use. + if err := verifySHA256FromFD(fd, s.SHA256); err != nil { + return err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", err) + } + fi, err := fd.Stat() + if err != nil { + return fmt.Errorf("stat: %w", err) + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("rm old dir: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir target: %w", err) + } + if err := extractSkillZipFromFD(fd, fi.Size(), dir); err != nil { + _ = os.RemoveAll(dir) + return err + } + + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + logger.Warn("pi skills: write cache key failed", "path", cacheKey, "err", err.Error()) + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/pi/skills_test.go b/apps/parsar-daemon/internal/agent/pi/skills_test.go new file mode 100644 index 000000000..fdb83cefc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/skills_test.go @@ -0,0 +1,318 @@ +package pi + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +type zipFile struct { + Name string + Body string +} + +func buildZipBytes(t *testing.T, files []zipFile) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + w, err := zw.CreateHeader(&zip.FileHeader{Name: f.Name, Method: zip.Deflate}) + if err != nil { + t.Fatalf("zip header %q: %v", f.Name, err) + } + if _, err := w.Write([]byte(f.Body)); err != nil { + t.Fatalf("zip write %q: %v", f.Name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + return buf.Bytes() +} + +func validSkillZip(t *testing.T) []byte { + return buildZipBytes(t, []zipFile{ + {Name: "SKILL.md", Body: "---\nname: code-review\ndescription: Review code\n---\nBody"}, + }) +} + +func sha256Hex(body []byte) string { + h := sha256.Sum256(body) + return hex.EncodeToString(h[:]) +} + +type zipServer struct { + *httptest.Server + hits *int + body []byte + stat int +} + +func startZipServer(t *testing.T, body []byte) *zipServer { + t.Helper() + var hits int + zs := &zipServer{hits: &hits, body: body, stat: http.StatusOK} + zs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + w.WriteHeader(zs.stat) + _, _ = w.Write(zs.body) + })) + t.Cleanup(zs.Close) + return zs +} + +func (s *zipServer) Hits() int { return *s.hits } + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestInstallSkillsHappyPathReturnsDirAndStampsCacheKey(t *testing.T) { + body := validSkillZip(t) + srv := startZipServer(t, body) + root := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("installSkills: %v", err) + } + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v, want 1 entry", res.SkillDirs) + } + dir := res.SkillDirs[0] + if filepath.Base(dir) != "code-review" { + t.Fatalf("dir basename = %q, want code-review", filepath.Base(dir)) + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Fatalf("SKILL.md missing: %v", err) + } + stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) + if err != nil { + t.Fatalf("read cache-key: %v", err) + } + if want := "code-review@" + sha256Hex(body); string(stamped) != want { + t.Fatalf("cache-key = %q, want %q", stamped, want) + } +} + +func TestInstallSkillsCacheHitSkipsDownloadButStillReturnsDir(t *testing.T) { + body := validSkillZip(t) + srv := startZipServer(t, body) + root := t.TempDir() + desc := []skillDescriptor{ + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + } + + first, err := installSkills(context.Background(), discardLogger(), root, desc) + if err != nil { + t.Fatalf("first install: %v", err) + } + if len(first.SkillDirs) != 1 || srv.Hits() != 1 { + t.Fatalf("first install dirs=%v hits=%d", first.SkillDirs, srv.Hits()) + } + + second, err := installSkills(context.Background(), discardLogger(), root, desc) + if err != nil { + t.Fatalf("second install: %v", err) + } + if srv.Hits() != 1 { + t.Fatalf("cache should prevent second download; hits=%d", srv.Hits()) + } + // A cache hit must STILL surface the dir so --skill is injected on + // every turn, not just the first. + if len(second.SkillDirs) != 1 || second.SkillDirs[0] != first.SkillDirs[0] { + t.Fatalf("cache hit must still return the dir; got %v", second.SkillDirs) + } +} + +func TestInstallSkillsSHA256MismatchDemotesToWarning(t *testing.T) { + body := validSkillZip(t) + srv := startZipServer(t, body) + root := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ + {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: strings.Repeat("0", 64)}, + }) + if err != nil { + t.Fatalf("installSkills should not hard-error on sha mismatch: %v", err) + } + if len(res.SkillDirs) != 0 { + t.Fatalf("SkillDirs = %v, want empty after sha mismatch", res.SkillDirs) + } + if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "sha256 mismatch") { + t.Fatalf("want 1 sha-mismatch warning, got %v", res.Warnings) + } + if _, err := os.Stat(filepath.Join(root, "code-review", "SKILL.md")); err == nil { + t.Fatal("SKILL.md should not exist after sha mismatch") + } +} + +func TestInstallSkillsEmptyListIsNoop(t *testing.T) { + res, err := installSkills(context.Background(), discardLogger(), t.TempDir(), nil) + if err != nil { + t.Fatalf("empty install: %v", err) + } + if len(res.SkillDirs) != 0 || len(res.Warnings) != 0 { + t.Fatalf("expected empty result; got %+v", res) + } +} + +func TestInstallSkillsStripsWrappingRoot(t *testing.T) { + body := buildZipBytes(t, []zipFile{ + {Name: "wrapper/SKILL.md", Body: "---\nname: x\n---\nbody"}, + {Name: "wrapper/refs/note.md", Body: "note"}, + }) + srv := startZipServer(t, body) + root := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v", res.SkillDirs) + } + dir := res.SkillDirs[0] + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Fatalf("SKILL.md at expected path missing (wrapper not stripped?): %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "wrapper")); err == nil { + t.Fatal("wrapper survived the strip") + } +} + +func TestInstallSkillsPathTraversalRejected(t *testing.T) { + body := buildZipBytes(t, []zipFile{ + {Name: "SKILL.md", Body: "---\nname: x\n---"}, + {Name: "../../escape", Body: "should never land outside dir"}, + }) + srv := startZipServer(t, body) + root := t.TempDir() + + res, _ := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ + {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if len(res.SkillDirs) != 0 { + t.Fatalf("SkillDirs = %v, want empty on path-traversal", res.SkillDirs) + } + if len(res.Warnings) == 0 || !strings.Contains(res.Warnings[0], "escapes") { + t.Fatalf("want escape warning, got %v", res.Warnings) + } +} + +func TestInstallSkillsPartialInstall(t *testing.T) { + bodyOK := validSkillZip(t) + srvOK := startZipServer(t, bodyOK) + srvBAD := startZipServer(t, bodyOK) + root := t.TempDir() + + res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ + {Name: "good", Version: "1", DownloadURL: srvOK.URL, SHA256: sha256Hex(bodyOK)}, + {Name: "bad", Version: "1", DownloadURL: srvBAD.URL, SHA256: strings.Repeat("0", 64)}, + }) + if err != nil { + t.Fatalf("install: %v", err) + } + if len(res.SkillDirs) != 1 || filepath.Base(res.SkillDirs[0]) != "good" { + t.Fatalf("SkillDirs = %v, want only the good one", res.SkillDirs) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning for the bad skill") + } +} + +func TestInstallSkillsDescriptorValidatorRejectsBadNames(t *testing.T) { + body := validSkillZip(t) + srv := startZipServer(t, body) + res, _ := installSkills(context.Background(), discardLogger(), t.TempDir(), []skillDescriptor{ + {Name: "../escape", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, + }) + if len(res.SkillDirs) != 0 { + t.Fatalf("SkillDirs = %v; bad name should be rejected", res.SkillDirs) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning") + } +} + +func TestDecodeSkillDescriptorsArrayShape(t *testing.T) { + raw := []any{ + map[string]any{"name": "a", "version": "1", "download_url": "https://x/a.zip", "sha256": strings.Repeat("a", 64)}, + map[string]any{"name": "", "version": "1", "download_url": "https://x/b.zip", "sha256": strings.Repeat("b", 64)}, + "not an object", + } + got, warns := decodeSkillDescriptors(raw) + if len(got) != 1 || got[0].Name != "a" { + t.Fatalf("got = %v, want 1 valid entry", got) + } + if len(warns) != 2 { + t.Fatalf("warns = %v, want 2", warns) + } +} + +func TestDecodeSkillDescriptorsNilAndWrongType(t *testing.T) { + if got, warns := decodeSkillDescriptors(nil); got != nil || warns != nil { + t.Fatalf("nil raw should be (nil, nil), got (%v, %v)", got, warns) + } + if got, warns := decodeSkillDescriptors("not-array"); got != nil || len(warns) != 1 { + t.Fatalf("string raw should warn, got got=%v warns=%v", got, warns) + } +} + +func TestMergeSkillDirsOverrideWinsAndDedupes(t *testing.T) { + got := mergeSkillDirs([]any{"/a", "/b"}, []string{"/b", "/c"}) + want := []string{"/a", "/b", "/c"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestMergeSkillDirsNilExisting(t *testing.T) { + got := mergeSkillDirs(nil, []string{"/x"}) + if len(got) != 1 || got[0] != "/x" { + t.Fatalf("got %v, want [/x]", got) + } +} + +func TestResolveSkillsRootConversationScoped(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveSkillsRoot("conv-abc", "run-1") + if err != nil { + t.Fatalf("resolveSkillsRoot: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "conv-conv-abc", "skills") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveSkillsRootRunScopedFallback(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + got, err := resolveSkillsRoot("", "run-9") + if err != nil { + t.Fatalf("resolveSkillsRoot: %v", err) + } + want := filepath.Join(tmp, ".parsar", "runtime", "pi", "run-run-9", "skills") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/version.go b/apps/parsar-daemon/internal/agent/pi/version.go new file mode 100644 index 000000000..f217c2516 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/version.go @@ -0,0 +1,36 @@ +package pi + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the pi documentation when the daemon +// can see the adapter but not the CLI binary. +const InstallURL = "https://github.com/earendil-works/pi" + +// defaultBinary is the executable to probe and spawn: binpath.Pi() +// honours the PARSAR_PI_BIN override so a bare-name PATH lookup can be +// bypassed in images where PATH is not under our control. A function +// rather than a const so the env is read at call time. +func defaultBinary() string { return binpath.Pi() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot +// be located on PATH. Callers use errors.Is to distinguish an install +// problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("pi CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "pi", + DefaultBinary: defaultBinary(), + MissingError: ErrCLINotFound, + TrimBinary: true, + StderrFallback: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/pi/version_test.go b/apps/parsar-daemon/internal/agent/pi/version_test.go new file mode 100644 index 000000000..629ddffbc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/pi/version_test.go @@ -0,0 +1,30 @@ +package pi_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "pi", + DefaultBinary: "pi", + MissingError: pi.ErrCLINotFound, + Check: pi.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} + +func TestCheckCLIAvailableFallsBackToStderr(t *testing.T) { + stub := testutil.WriteStub(t, "stderr-pi", "#!/bin/sh\nprintf ' pi 0.74.2\\nextra line \\n' 1>&2\n") + version, err := pi.CheckCLIAvailable(context.Background(), stub) + if err != nil { + t.Fatalf("check CLI: %v", err) + } + if version != "pi 0.74.2" { + t.Fatalf("version = %q, want %q", version, "pi 0.74.2") + } +} diff --git a/apps/parsar-daemon/internal/agent/preparation.go b/apps/parsar-daemon/internal/agent/preparation.go new file mode 100644 index 000000000..e02d45e28 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/preparation.go @@ -0,0 +1,59 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Prepared owns native resources until Start returns a non-nil Session. The +// preparation owner context spans the eventual Session; Start's context is local +// to that operation. A nil Session leaves preparation cleanup with the caller. +type Prepared interface { + // Start transfers output ownership only when it returns a non-nil Session. + // A nil Session leaves the caller as the sole owner of closing out, and the + // implementation must not retain or write to it after Start returns. + Start(context.Context, string, string, chan<- proto.Envelope) (Session, error) + // Close retains unused ownership on error; callers may retry settlement. + Close() error +} + +// PreparedCancellation is required for executable preparations and follows the +// same native resource across Start. Read-only preparations need only Prepared. +type PreparedCancellation interface { + Prepared + // Cancel returns after local cleanup and all output writes have stopped. + // An error retains ownership so callers can retry this exact object serially. + Cancel(context.Context) error + CancellationOutcome() proto.DonePayload +} + +// A factory may return both a resource and an error when construction failed but +// cleanup remains unconfirmed. The caller must retain and close that resource. +type PreparationFactory func(context.Context, proto.PromptRequestPayload) (Prepared, error) + +// RegisterPreparation installs a separate execution-only path. Product factory +// wrappers must not add authoring or capability-download side effects to it. +func (r *Registry) RegisterPreparation(kind string, workspaceRead bool, prepare PreparationFactory) { + r.mu.Lock() + defer r.mu.Unlock() + info, exists := r.kinds[kind] + if !exists || prepare == nil { + panic("agent.Registry.RegisterPreparation: registered kind and factory required") + } + r.preparers[kind] = prepare + info.Capabilities.Preparation = true + info.Capabilities.WorkspaceReadPreparation = workspaceRead + r.kinds[kind] = info +} + +func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + f := r.preparers[kind] + if f == nil { + return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) + } + return f, nil +} diff --git a/apps/parsar-daemon/internal/agent/registry.go b/apps/parsar-daemon/internal/agent/registry.go new file mode 100644 index 000000000..d9f6459f5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/registry.go @@ -0,0 +1,144 @@ +// Package agent is the registration surface for agent_kind +// implementations. The dispatch router consults agent.Registry to map +// an inbound prompt_request's AgentKind to its factory. +// +// Lifetime / channel ownership: +// +// - Factory takes an out chan<- proto.Envelope owned by the dispatch +// router. The agent SENDS upstream events on it and OWNS the +// close: it MUST close(out) exactly once after emitting the current +// run's terminal "done" or "error" frame. The underlying CLI may +// remain alive during the router's idle window and is terminated +// through Session.Cancel. +// +// - Session.Cancel is best-effort and idempotent: a session that +// already finished naturally must accept a Cancel call without +// panicking. +package agent + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Factory builds a Session for one prompt_request. out is the upstream +// channel the agent writes into; the agent owns its close (see package +// doc). ctx is cancelled by the router to wind the session down. +type Factory func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (Session, error) + +// Session owns one prompt run and any CLI process retained after that +// run. Run completion is signalled by closing out; process teardown is +// signalled separately through Cancel. +type Session interface { + // Cancel signals the session to abort. Idempotent. Actual teardown + // happens asynchronously and is signalled via the out channel close. + Cancel(ctx context.Context) error +} + +// ErrUnknownPermission is returned by PermissionResponder.SubmitPermission when +// the permID doesn't match any outstanding request. The router uses +// this to distinguish a benign race from a real forwarding failure. +var ErrUnknownPermission = errors.New("agent: unknown permission id") + +// ErrUnknownAsk is returned by UserChoiceResponder.SubmitPromptForUserChoice when +// the askID doesn't match any outstanding ask. Same race semantics as +// ErrUnknownPermission. +var ErrUnknownAsk = errors.New("agent: unknown ask id") + +var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") + +// Registry maps agent_kind → Factory and keeps the daemon-advertised +// capability descriptor for each kind. Safe for concurrent use. +type Registry struct { + mu sync.RWMutex + factories map[string]Factory + preparers map[string]PreparationFactory + kinds map[string]proto.SupportedAgentKind +} + +func NewRegistry() *Registry { + return &Registry{ + factories: make(map[string]Factory), + preparers: make(map[string]PreparationFactory), + kinds: make(map[string]proto.SupportedAgentKind), + } +} + +// Register installs f as the factory for kind with a basic available +// descriptor. Panics on empty kind or nil factory. +func (r *Registry) Register(kind string, f Factory) { + r.RegisterKind(proto.SupportedAgentKind{Kind: kind, Available: true}, f) +} + +// RegisterKind installs f and the heartbeat descriptor for an +// agent_kind. Callers may set Available=false when an adapter exists +// but its underlying CLI is not usable. +func (r *Registry) RegisterKind(info proto.SupportedAgentKind, f Factory) { + kind := info.Kind + if kind == "" { + panic("agent.Registry.Register: empty kind") + } + if f == nil { + panic("agent.Registry.Register: nil factory") + } + r.mu.Lock() + defer r.mu.Unlock() + r.factories[kind] = f + delete(r.preparers, kind) + info.Capabilities.Preparation = false + info.Capabilities.WorkspaceReadPreparation = false + r.kinds[kind] = info +} + +// Resolve returns the factory for kind, or wraps ErrUnsupportedKind. +func (r *Registry) Resolve(kind string) (Factory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + f, ok := r.factories[kind] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnsupportedKind, kind) + } + return f, nil +} + +// Kinds returns the list of registered kinds sorted lexicographically. +func (r *Registry) Kinds() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.factories)) + for k := range r.factories { + out = append(out, k) + } + slices.Sort(out) + return out +} + +// SupportedAgentKinds returns the daemon-advertised capability +// descriptors sorted by kind so heartbeat payloads are stable. +func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]proto.SupportedAgentKind, 0, len(r.factories)) + for kind := range r.factories { + info := r.kinds[kind] + if info.Kind == "" { + info = proto.SupportedAgentKind{Kind: kind, Available: true} + } + out = append(out, info) + } + slices.SortFunc(out, func(a, b proto.SupportedAgentKind) int { + if a.Kind < b.Kind { + return -1 + } + if a.Kind > b.Kind { + return 1 + } + return 0 + }) + return out +} diff --git a/apps/parsar-daemon/internal/agent/registry_test.go b/apps/parsar-daemon/internal/agent/registry_test.go new file mode 100644 index 000000000..58c4488a4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/registry_test.go @@ -0,0 +1,138 @@ +package agent_test + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func stubFactory(marker string) agent.Factory { + return func(_ context.Context, _ proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + return stubSession{marker: marker}, nil + } +} + +type stubSession struct{ marker string } + +func (stubSession) Cancel(context.Context) error { return nil } +func (stubSession) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return nil +} +func (stubSession) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return nil +} + +func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { + reg := agent.NewRegistry() + reg.Register("claude_code", stubFactory("cc")) + + f, err := reg.Resolve("claude_code") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "claude_code"}, nil) + if err != nil { + t.Fatalf("factory: %v", err) + } + stub, ok := sess.(stubSession) + if !ok || stub.marker != "cc" { + t.Errorf("resolved factory returned %#v, want stubSession{marker:\"cc\"}", sess) + } +} + +func TestRegistryResolveUnknownKindReturnsTypedError(t *testing.T) { + reg := agent.NewRegistry() + reg.Register("claude_code", stubFactory("cc")) + + _, err := reg.Resolve("opencode") + if !errors.Is(err, agent.ErrUnsupportedKind) { + t.Errorf("Resolve unknown = %v, want ErrUnsupportedKind chain", err) + } +} + +func TestRegistryRegisterOverwrites(t *testing.T) { + reg := agent.NewRegistry() + reg.Register("k", stubFactory("v1")) + reg.Register("k", stubFactory("v2")) + + f, err := reg.Resolve("k") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + sess, _ := f(context.Background(), proto.PromptRequestPayload{}, nil) + if got := sess.(stubSession).marker; got != "v2" { + t.Errorf("overwrite: marker = %q, want v2", got) + } +} + +func TestRegistryKindsReportsRegistered(t *testing.T) { + reg := agent.NewRegistry() + reg.Register("claude_code", stubFactory("cc")) + reg.Register("opencode", stubFactory("oc")) + + got := reg.Kinds() + slices.Sort(got) + want := []string{"claude_code", "opencode"} + if !slices.Equal(got, want) { + t.Errorf("Kinds = %v, want %v", got, want) + } +} + +func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("Register(\"\", ...) did not panic") + } + }() + agent.NewRegistry().Register("", stubFactory("x")) +} + +func TestRegistryRegisterPanicsOnNilFactory(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("Register(kind, nil) did not panic") + } + }() + agent.NewRegistry().Register("k", nil) +} + +func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{ + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + }, + }, stubFactory("oc")) + reg.RegisterKind(proto.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Version: "1.2.3", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, stubFactory("cc")) + + got := reg.SupportedAgentKinds() + if len(got) != 2 { + t.Fatalf("SupportedAgentKinds len = %d, want 2: %#v", len(got), got) + } + if got[0].Kind != "claude_code" || got[1].Kind != "opencode" { + t.Fatalf("SupportedAgentKinds sort = %#v, want claude_code then opencode", got) + } + if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Permissions || !got[0].Capabilities.Resume { + t.Fatalf("claude_code descriptor not preserved: %#v", got[0]) + } + if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.Streaming { + t.Fatalf("opencode descriptor not preserved: %#v", got[1]) + } +} diff --git a/apps/parsar-daemon/internal/agent/runtime_paths.go b/apps/parsar-daemon/internal/agent/runtime_paths.go new file mode 100644 index 000000000..eddf6634d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/runtime_paths.go @@ -0,0 +1,64 @@ +package agent + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// ManagedSkillsRoot returns an adapter-owned skill directory scoped to one +// agent state. It never derives runtime state from the subprocess cwd. +func ManagedSkillsRoot(agentKind, agentStateKey, conversationID, runID string) (string, error) { + root, err := paths.Root() + if err != nil { + return "", fmt.Errorf("agent: resolve managed skills root: %w", err) + } + kind := safeRuntimePathPart(agentKind) + if kind == "" { + return "", fmt.Errorf("agent: invalid agent kind %q", agentKind) + } + base := filepath.Join(root, "runtime", kind) + if key := strings.TrimSpace(agentStateKey); key != "" { + parts := safeRuntimePathParts(key) + if len(parts) == 0 { + return "", fmt.Errorf("agent: invalid agent state key %q", agentStateKey) + } + return filepath.Join(append([]string{base, "state"}, append(parts, "skills")...)...), nil + } + if id := safeRuntimePathPart(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "skills"), nil + } + if id := safeRuntimePathPart(runID); id != "" { + return filepath.Join(base, "run-"+id, "skills"), nil + } + return "", fmt.Errorf("agent: agent state key, conversation id, or run id is required") +} + +func safeRuntimePathParts(value string) []string { + raw := strings.Split(value, "/") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + if safe := safeRuntimePathPart(part); safe != "" { + parts = append(parts, safe) + } + } + return parts +} + +func safeRuntimePathPart(value string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(value) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + value = b.String() + if value == "." || value == ".." { + return "" + } + return value +} diff --git a/apps/parsar-daemon/internal/agent/runtime_paths_test.go b/apps/parsar-daemon/internal/agent/runtime_paths_test.go new file mode 100644 index 000000000..6367fd00d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/runtime_paths_test.go @@ -0,0 +1,32 @@ +package agent + +import ( + "path/filepath" + "testing" +) + +func TestManagedSkillsRootUsesStableAgentState(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + got, err := ManagedSkillsRoot("codex", "conv-1/agent-1/codex", "ignored", "ignored") + if err != nil { + t.Fatalf("ManagedSkillsRoot: %v", err) + } + want := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills") + if got != want { + t.Fatalf("root = %q, want %q", got, want) + } +} + +func TestManagedSkillsRootSanitizesFallback(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + got, err := ManagedSkillsRoot("opencode", "", "../conv name", "ignored") + if err != nil { + t.Fatalf("ManagedSkillsRoot: %v", err) + } + want := filepath.Join(home, "runtime", "opencode", "conv-.._conv_name", "skills") + if got != want { + t.Fatalf("root = %q, want %q", got, want) + } +} diff --git a/apps/parsar-daemon/internal/agent/steering.go b/apps/parsar-daemon/internal/agent/steering.go new file mode 100644 index 000000000..a9807a74f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/steering.go @@ -0,0 +1,27 @@ +package agent + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Steerer optionally delivers additional text to the session's active turn. +type Steerer interface { + Steer(context.Context, proto.PromptSteerPayload) error +} + +// DurableSteerer reports one complete write synchronously, then waits for the native receipt. +type DurableSteerer interface { + SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error +} + +// ErrSteeringNotReady means no input was sent because the turn is starting. +var ErrSteeringNotReady = errors.New("agent: turn is not ready for input") + +// ErrSteeringInactive means no input was sent because the run ended or was cancelled. +var ErrSteeringInactive = errors.New("agent: run is no longer active") + +// ErrSteeringRejected means the engine explicitly rejected the input. +var ErrSteeringRejected = errors.New("agent: input rejected") diff --git a/apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go b/apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go new file mode 100644 index 000000000..701386bb7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go @@ -0,0 +1,120 @@ +package testutil + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" +) + +// CheckFunc is an adapter CLI availability check. +type CheckFunc func(context.Context, string) (string, error) + +// Contract describes the shared behavior expected from a CLI version probe. +type Contract struct { + Name string + DefaultBinary string + MissingError error + Check CheckFunc + WhitespaceDefaults bool +} + +// RunContract verifies an adapter's shared CLI version probe behavior. +func RunContract(t *testing.T, contract Contract) { + t.Helper() + + t.Run("missing binary wraps exported sentinel", func(t *testing.T) { + binary := "parsar-daemon-nonexistent-" + contract.Name + "-stub" + _, err := contract.Check(context.Background(), binary) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, contract.MissingError) { + t.Fatalf("error %v does not wrap %v", err, contract.MissingError) + } + want := fmt.Sprintf("%s: %s", contract.MissingError, binary) + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } + }) + + t.Run("empty binary uses adapter default", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := contract.Check(context.Background(), "") + want := fmt.Sprintf("%s: %s", contract.MissingError, contract.DefaultBinary) + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + + t.Run("whitespace binary preserves adapter behavior", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := contract.Check(context.Background(), " ") + binary := " " + if contract.WhitespaceDefaults { + binary = contract.DefaultBinary + } + want := fmt.Sprintf("%s: %s", contract.MissingError, binary) + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + + if runtime.GOOS == "windows" { + return + } + + t.Run("returns trimmed first stdout line", func(t *testing.T) { + stub := writeStub(t, "fake-"+contract.Name, "#!/bin/sh\nprintf ' "+contract.Name+" 9.9.9\\nextra line \\n'\n") + version, err := contract.Check(context.Background(), stub) + if err != nil { + t.Fatalf("check CLI: %v", err) + } + want := contract.Name + " 9.9.9" + if version != want { + t.Fatalf("version = %q, want %q", version, want) + } + }) + + t.Run("surfaces adapter-specific nonzero error", func(t *testing.T) { + stub := writeStub(t, "broken-"+contract.Name, "#!/bin/sh\necho 'kaboom' 1>&2\nexit 17\n") + _, err := contract.Check(context.Background(), stub) + want := contract.Name + " --version failed: kaboom" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + if errors.Is(err, contract.MissingError) { + t.Fatalf("broken present binary wraps missing sentinel: %v", err) + } + }) + + t.Run("rejects empty output with adapter-specific error", func(t *testing.T) { + stub := writeStub(t, "silent-"+contract.Name, "#!/bin/sh\nexit 0\n") + _, err := contract.Check(context.Background(), stub) + want := contract.Name + " --version returned empty output" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) +} + +// WriteStub creates a POSIX executable for adapter-specific tests. +func WriteStub(t *testing.T, name, body string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX-only stub script; daemon does not target Windows") + } + return writeStub(t, name, body) +} + +func writeStub(t *testing.T, name, body string) string { + t.Helper() + stub := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(stub, []byte(body), 0o755); err != nil { + t.Fatalf("write stub: %v", err) + } + return stub +} diff --git a/apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go b/apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go new file mode 100644 index 000000000..92effd9a5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go @@ -0,0 +1,52 @@ +package versionprobe + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// Config describes an adapter's CLI version command and error contract. +type Config struct { + Name string + DefaultBinary string + MissingError error + TrimBinary bool + StderrFallback bool +} + +// Check runs ` --version` and returns its trimmed first output line. +func Check(ctx context.Context, binary string, config Config) (string, error) { + if (config.TrimBinary && strings.TrimSpace(binary) == "") || (!config.TrimBinary && binary == "") { + binary = config.DefaultBinary + } + if _, err := exec.LookPath(binary); err != nil { + return "", fmt.Errorf("%w: %s", config.MissingError, binary) + } + + var stdout, stderr bytes.Buffer + command := exec.CommandContext(ctx, binary, "--version") + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return "", fmt.Errorf("%s --version failed: %s", config.Name, message) + } + + output := strings.TrimSpace(stdout.String()) + if output == "" && config.StderrFallback { + output = strings.TrimSpace(stderr.String()) + } + if index := strings.IndexByte(output, '\n'); index >= 0 { + output = output[:index] + } + if output == "" { + return "", fmt.Errorf("%s --version returned empty output", config.Name) + } + return output, nil +} diff --git a/apps/parsar-daemon/internal/agent/workspace_directory.go b/apps/parsar-daemon/internal/agent/workspace_directory.go new file mode 100644 index 000000000..6ed788a0c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/workspace_directory.go @@ -0,0 +1,30 @@ +package agent + +import "context" + +// WorkspaceDirectoryEntry describes an entry observed without following its final symlink. +type WorkspaceDirectoryEntry struct { + Name string + Kind string + SizeBytes *int64 +} + +// WorkspaceDirectoryResult is a live, bounded observation, not a filesystem snapshot. +type WorkspaceDirectoryResult struct { + Entries []WorkspaceDirectoryEntry + Truncated bool +} + +// WorkspaceDirectoryLister reads one directory through an existing workspace owner. +// An empty directory selects the root; other paths contain only relative components. +// Entry names are single components. Kind is file, directory, symlink or other; +// SizeBytes is present and nonnegative only for regular files. Results have no +// prescribed order, and Truncated must not be presented as a complete inventory. +// maxEntries is positive; adapters may reject limits above their private bound. +// Successful return requires settled directory/metadata access and handle cleanup. +// Implementations retain workspace authorization and isolation and use the existing +// WorkspaceRead errors for unsupported, unavailable, busy, invalid or uncertain reads. +// This interface does not establish public Files pagination or feature admission. +type WorkspaceDirectoryLister interface { + ListWorkspaceDirectory(context.Context, string, int) (WorkspaceDirectoryResult, error) +} diff --git a/apps/parsar-daemon/internal/agent/workspace_read.go b/apps/parsar-daemon/internal/agent/workspace_read.go new file mode 100644 index 000000000..be85bc9a2 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/workspace_read.go @@ -0,0 +1,24 @@ +package agent + +import ( + "context" + "errors" +) + +type WorkspaceReadResult struct { + Data []byte + Truncated bool +} + +// WorkspaceReader returns success only after acknowledged native close on an existing owner. +type WorkspaceReader interface { + ReadWorkspaceFile(context.Context, string, int) (WorkspaceReadResult, error) +} + +var ( + ErrWorkspaceReadUnsupported = errors.New("workspace read unsupported") + ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") + ErrWorkspaceReadBusy = errors.New("workspace read busy") + ErrWorkspaceReadInvalid = errors.New("workspace read invalid") + ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain") +) diff --git a/apps/parsar-daemon/internal/agent/workspace_write.go b/apps/parsar-daemon/internal/agent/workspace_write.go new file mode 100644 index 000000000..77d69c939 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/workspace_write.go @@ -0,0 +1,24 @@ +package agent + +import ( + "context" + "errors" +) + +type WorkspaceWriteResult struct { + SizeBytes int64 +} + +// WorkspaceWriter confirms a native commit on an already authorized prepared owner. +type WorkspaceWriter interface { + WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) +} + +var ( + ErrWorkspaceWriteUnsupported = errors.New("workspace write unsupported") + ErrWorkspaceWriteUnavailable = errors.New("workspace write unavailable") + ErrWorkspaceWriteBusy = errors.New("workspace write busy") + ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") + ErrWorkspaceWriteRejected = errors.New("workspace write rejected") + ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") +) diff --git a/apps/parsar-daemon/internal/auth/store.go b/apps/parsar-daemon/internal/auth/store.go new file mode 100644 index 000000000..09122c9c0 --- /dev/null +++ b/apps/parsar-daemon/internal/auth/store.go @@ -0,0 +1,123 @@ +// Package auth persists the credential bundle from +// /api/v1/runtimes/pair: server URL, runtime row id (= device_id), and +// the long-lived runner_credential. Stored as JSON per-profile at +// ~/.parsar/parsar-daemon//auth.json (0o600), written via +// atomic rename so a half-flushed pair never leaves the daemon paired +// with garbage state. +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// Profile is the on-disk representation of one paired credential. +type Profile struct { + // ServerURL is the absolute base URL the daemon dials (no + // trailing slash). The daemon joins this with paths like + // /agent-daemon/bootstrap. + ServerURL string `json:"server_url"` + + // RuntimeID is the runtimes row id minted at pair time. The + // gateway uses it verbatim as device_id on WS upgrade. + RuntimeID string `json:"runtime_id"` + + // RunnerCredential is the bearer presented on every + // /agent-daemon/* call. Stored plaintext in a 0o600 file; the + // server holds only the hash, so this is the only proof of + // identity and MUST NOT be checked into VCS. + RunnerCredential string `json:"runner_credential"` + + DeviceName string `json:"device_name,omitempty"` + + Hostname string `json:"hostname,omitempty"` + + // PairedAt is when the credential was minted. `omitzero` because + // `omitempty` doesn't elide zero structs like time.Time. + PairedAt time.Time `json:"paired_at,omitzero"` + + // RunnerPublicKey is the base64 X25519 public half generated at + // pair time. Server stores the matching value in + // runtimes.config.runner_public_key for SealAnonymous addressed + // to this daemon. + RunnerPublicKey string `json:"runner_public_key,omitempty"` + + // RunnerPrivateKey is the base64 X25519 private half — used by + // runtimecrypto.OpenSeal to decrypt incoming sealed payloads. + // MUST NEVER appear in logs or leave the box. + RunnerPrivateKey string `json:"runner_private_key,omitempty"` +} + +// ErrNotPaired is returned by Load when no auth.json exists for the +// requested profile. +var ErrNotPaired = errors.New("auth: not paired — use `parsar-daemon connect --url ... --token ...`") + +// Save writes p atomically to the profile's auth.json (0o600 even if +// the previous file was world-readable). +func Save(profile string, p Profile) error { + if profile == "" { + return fmt.Errorf("auth: profile name required") + } + dir, err := paths.EnsureProfileDir(profile) + if err != nil { + return err + } + authFile := filepath.Join(dir, "auth.json") + tmp := authFile + ".tmp" + raw, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("auth: marshal profile: %w", err) + } + // os.WriteFile with the final mode in one shot, so umask is + // irrelevant. + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("auth: write tmp: %w", err) + } + if err := os.Rename(tmp, authFile); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("auth: rename: %w", err) + } + return nil +} + +// Load reads the profile's auth.json. Returns ErrNotPaired wrapping +// fs.ErrNotExist when the file is missing. +func Load(profile string) (Profile, error) { + authPath, err := paths.AuthFile(profile) + if err != nil { + return Profile{}, err + } + raw, err := os.ReadFile(authPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // Multi-%w so errors.Is matches both ErrNotPaired AND + // fs.ErrNotExist. + return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNotPaired, authPath, err) + } + return Profile{}, fmt.Errorf("auth: read: %w", err) + } + var p Profile + if err := json.Unmarshal(raw, &p); err != nil { + return Profile{}, fmt.Errorf("auth: parse %s: %w", authPath, err) + } + return p, nil +} + +// Delete removes the auth.json for a profile. Idempotent — missing +// file is not an error. +func Delete(profile string) error { + authPath, err := paths.AuthFile(profile) + if err != nil { + return err + } + if err := os.Remove(authPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("auth: delete: %w", err) + } + return nil +} diff --git a/apps/parsar-daemon/internal/auth/store_test.go b/apps/parsar-daemon/internal/auth/store_test.go new file mode 100644 index 000000000..7406a8c81 --- /dev/null +++ b/apps/parsar-daemon/internal/auth/store_test.go @@ -0,0 +1,180 @@ +package auth_test + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +func withTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("PARSAR_HOME", dir) + return dir +} + +func TestSaveLoadRoundTrip(t *testing.T) { + _ = withTempHome(t) + now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) + want := auth.Profile{ + ServerURL: "https://parsar.example.com", + RuntimeID: "rt_abc123", + RunnerCredential: "secret-credential", + DeviceName: "alice-mac", + Hostname: "alice-mac.local", + PairedAt: now, + } + if err := auth.Save("test", want); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := auth.Load("test") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.ServerURL != want.ServerURL || + got.RuntimeID != want.RuntimeID || + got.RunnerCredential != want.RunnerCredential || + got.DeviceName != want.DeviceName || + got.Hostname != want.Hostname || + !got.PairedAt.Equal(want.PairedAt) { + t.Fatalf("Load round-trip mismatch:\n got=%+v\nwant=%+v", got, want) + } +} + +func TestSaveSetsRestrictivePerms(t *testing.T) { + _ = withTempHome(t) + if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { + t.Fatalf("Save: %v", err) + } + authPath, err := paths.AuthFile("default") + if err != nil { + t.Fatalf("AuthFile: %v", err) + } + info, err := os.Stat(authPath) + if err != nil { + t.Fatalf("stat auth.json: %v", err) + } + // auth.json holds the long-lived runner_credential — anything + // but 0600 leaks it on a shared CI box. + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("auth.json perm = %o, want 0600", mode) + } +} + +func TestSaveIsAtomicNoStrayTempFile(t *testing.T) { + _ = withTempHome(t) + if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { + t.Fatalf("Save: %v", err) + } + authPath, err := paths.AuthFile("default") + if err != nil { + t.Fatalf("AuthFile: %v", err) + } + // Writes to auth.json.tmp then renames — no stray .tmp on success. + entries, err := os.ReadDir(filepath.Dir(authPath)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, e := range entries { + if filepath.Ext(e.Name()) == ".tmp" { + t.Fatalf("found stray temp file after Save: %s", e.Name()) + } + } +} + +func TestSaveOverwritesAndHealsPerms(t *testing.T) { + _ = withTempHome(t) + if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt1", RunnerCredential: "c1"}); err != nil { + t.Fatalf("Save first: %v", err) + } + authPath, err := paths.AuthFile("default") + if err != nil { + t.Fatalf("AuthFile: %v", err) + } + // Simulate a previously-world-readable file (user chmod'd it); + // atomic-rename Save must re-establish 0600 on the new inode. + if err := os.Chmod(authPath, 0o644); err != nil { + t.Fatalf("chmod loose perms: %v", err) + } + if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt2", RunnerCredential: "c2"}); err != nil { + t.Fatalf("Save second: %v", err) + } + info, err := os.Stat(authPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode) + } + got, err := auth.Load("default") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.RuntimeID != "rt2" || got.RunnerCredential != "c2" { + t.Errorf("overwrite did not take effect: %+v", got) + } +} + +func TestLoadMissingReturnsErrNotPaired(t *testing.T) { + _ = withTempHome(t) + _, err := auth.Load("default") + if !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on missing profile returned %v, want ErrNotPaired", err) + } + // ErrNotPaired must also wrap fs.ErrNotExist for the canonical + // "missing file" check. + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("ErrNotPaired must wrap fs.ErrNotExist, got %v", err) + } +} + +func TestLoadCorruptJSONReturnsError(t *testing.T) { + _ = withTempHome(t) + dir, err := paths.EnsureProfileDir("default") + if err != nil { + t.Fatalf("EnsureProfileDir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + _, err = auth.Load("default") + if err == nil { + t.Fatal("Load returned nil error on corrupt JSON") + } + if errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on corrupt JSON should not be ErrNotPaired: %v", err) + } +} + +func TestDeleteIsIdempotent(t *testing.T) { + _ = withTempHome(t) + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) + } + if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { + t.Fatalf("Save: %v", err) + } + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load after Delete = %v, want ErrNotPaired", err) + } + // Second Delete must still succeed. + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete second call = %v, want nil", err) + } +} + +func TestSaveRejectsEmptyProfile(t *testing.T) { + _ = withTempHome(t) + if err := auth.Save("", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err == nil { + t.Fatal("Save with empty profile should error") + } +} diff --git a/apps/parsar-daemon/internal/authoring/bridge.go b/apps/parsar-daemon/internal/authoring/bridge.go new file mode 100644 index 000000000..3706c6f06 --- /dev/null +++ b/apps/parsar-daemon/internal/authoring/bridge.go @@ -0,0 +1,134 @@ +package authoring + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type Sender interface { + Send(context.Context, proto.Envelope) error +} + +type Bridge struct { + sender Sender + mu sync.Mutex + waiters map[string]waiter +} + +type waiter struct { + runID string + response chan proto.AuthoringResponsePayload +} + +func New(sender Sender) *Bridge { + return &Bridge{sender: sender, waiters: make(map[string]waiter)} +} + +// Deliver only resolves the matching request from the same active run. +func (b *Bridge) Deliver(env proto.Envelope) { + var response proto.AuthoringResponsePayload + if env.DecodePayload(&response) != nil { + return + } + b.mu.Lock() + w, ok := b.waiters[response.RequestID] + b.mu.Unlock() + if ok && w.runID == env.ID { + select { + case w.response <- response: + default: + } + } +} + +func (b *Bridge) request(ctx context.Context, runID string, request proto.AuthoringRequestPayload) (proto.AuthoringResponsePayload, error) { + request.RequestID = uuid.NewString() + w := waiter{runID: runID, response: make(chan proto.AuthoringResponsePayload, 1)} + b.mu.Lock() + b.waiters[request.RequestID] = w + b.mu.Unlock() + defer func() { b.mu.Lock(); delete(b.waiters, request.RequestID); b.mu.Unlock() }() + env, err := proto.NewEnvelope(proto.TypeAuthoringRequest, runID, request) + if err != nil { + return proto.AuthoringResponsePayload{}, err + } + if err := b.sender.Send(ctx, env); err != nil { + return proto.AuthoringResponsePayload{}, err + } + select { + case response := <-w.response: + return response, nil + case <-ctx.Done(): + return proto.AuthoringResponsePayload{}, ctx.Err() + } +} + +func (b *Bridge) Listen(parent context.Context, runID string) (string, func(), error) { + home, err := os.UserHomeDir() + if err != nil { + return "", nil, err + } + dir := filepath.Join(home, ".parsar", "authoring") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", nil, err + } + path := filepath.Join(dir, uuid.NewString()[:8]+".sock") + listener, err := net.Listen("unix", path) + if err != nil { + return "", nil, fmt.Errorf("open daemon authoring socket: %w", err) + } + if err := os.Chmod(path, 0o600); err != nil { + _ = listener.Close() + return "", nil, err + } + ctx, cancel := context.WithCancel(parent) + stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) + closeListener := func() { cancel(); stop(); _ = listener.Close() } + go func() { + defer closeListener() + for { + conn, err := listener.Accept() + if err != nil { + return + } + go b.serve(ctx, runID, conn) + } + }() + return path, closeListener, nil +} + +func (b *Bridge) serve(parent context.Context, runID string, conn net.Conn) { + defer func() { _ = conn.Close() }() + ctx, cancel := context.WithTimeout(parent, 30*time.Second) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + var request proto.AuthoringRequestPayload + decoder := json.NewDecoder(io.LimitReader(conn, proto.AuthoringMaxBytes+1)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&request) + if err == nil && decoder.InputOffset() > proto.AuthoringMaxBytes { + err = errors.New("authoring request is too large") + } + var response proto.AuthoringResponsePayload + if err == nil { + response, err = b.request(ctx, runID, request) + } + if err != nil { + response.Error = err.Error() + } + _ = json.NewEncoder(conn).Encode(response) +} diff --git a/apps/parsar-daemon/internal/authoring/bridge_test.go b/apps/parsar-daemon/internal/authoring/bridge_test.go new file mode 100644 index 000000000..5a5ff9822 --- /dev/null +++ b/apps/parsar-daemon/internal/authoring/bridge_test.go @@ -0,0 +1,106 @@ +package authoring + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type testSender struct{ frames chan proto.Envelope } + +func (s testSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.frames <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestBridgeUsesRunAttributionAndClosesAccess(t *testing.T) { + home, err := os.MkdirTemp("/tmp", "pa-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + sender := testSender{frames: make(chan proto.Envelope, 1)} + b := New(sender) + path, release, err := b.Listen(t.Context(), "run-a") + if err != nil { + t.Fatal(err) + } + defer release() + if filepath.Dir(path) != filepath.Join(home, ".parsar", "authoring") { + t.Fatal("socket outside Parsar state") + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("socket permissions: %v %v", info, err) + } + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + if err := json.NewEncoder(conn).Encode(proto.AuthoringRequestPayload{RequestID: "client-supplied", Operation: proto.AuthoringContext}); err != nil { + t.Fatal(err) + } + var frame proto.Envelope + select { + case frame = <-sender.frames: + case <-time.After(time.Second): + t.Fatal("request not forwarded") + } + var request proto.AuthoringRequestPayload + if frame.DecodePayload(&request) != nil || frame.ID != "run-a" || request.RequestID == "client-supplied" || frame.Type != proto.TypeAuthoringRequest { + t.Fatalf("wrong attribution: %+v", frame) + } + wrong, _ := proto.NewEnvelope(proto.TypeAuthoringResponse, "run-b", proto.AuthoringResponsePayload{RequestID: request.RequestID, Data: json.RawMessage(`"wrong"`)}) + b.Deliver(wrong) + good, _ := proto.NewEnvelope(proto.TypeAuthoringResponse, "run-a", proto.AuthoringResponsePayload{RequestID: request.RequestID, Data: json.RawMessage(`"right"`)}) + b.Deliver(good) + var response proto.AuthoringResponsePayload + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatal(err) + } + if string(response.Data) != `"right"` { + t.Fatalf("wrong response: %+v", response) + } + release() + if _, err := net.DialTimeout("unix", path, time.Second); err == nil { + t.Fatal("completed run still accepts commands") + } +} + +func TestBridgeCancellationClearsWaiters(t *testing.T) { + sender := testSender{frames: make(chan proto.Envelope, 1)} + b := New(sender) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, err := b.request(ctx, "run", proto.AuthoringRequestPayload{Operation: proto.AuthoringSkillList}) + done <- err + }() + <-sender.frames + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled request succeeded") + } + case <-time.After(time.Second): + t.Fatal("cancelled request remained blocked") + } + b.mu.Lock() + defer b.mu.Unlock() + if len(b.waiters) != 0 { + t.Fatal("waiter retained after cancellation") + } +} diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go new file mode 100644 index 000000000..4f6a10552 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -0,0 +1,191 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. +type agentCLIDiscovery struct { + MCodeWorkspace *mcode.WorkspaceConfig + ClaudeSDK *claudeSDKDiscovery + ClaudeCode proto.SupportedAgentKind + OpenCode proto.SupportedAgentKind + Codex proto.SupportedAgentKind + Pi proto.SupportedAgentKind + MCode proto.SupportedAgentKind +} + +type agentCLIChecks struct { + ClaudeSDK func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) + ClaudeCode func(context.Context, string) (string, error) + OpenCode func(context.Context, string) (string, error) + Codex func(context.Context, string) (string, error) + Pi func(context.Context, string) (string, error) + MCode func(context.Context, string) (string, error) +} + +func defaultAgentCLIChecks() agentCLIChecks { + return agentCLIChecks{ + ClaudeCode: claudecode.CheckCLIAvailable, + OpenCode: opencodeagent.CheckCLIAvailable, + Codex: codex.CheckCLIAvailable, + Pi: pi.CheckCLIAvailable, + MCode: mcode.CheckCLIAvailable, + } +} + +func preflightAgentCLIs(rc *runContext, profile string) (agentCLIDiscovery, error) { + return discoverAgentCLIs(rc, profile, defaultAgentCLIChecks()) +} + +func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { + if checks.ClaudeCode == nil { + checks.ClaudeCode = claudecode.CheckCLIAvailable + } + if checks.OpenCode == nil { + checks.OpenCode = opencodeagent.CheckCLIAvailable + } + if checks.Codex == nil { + checks.Codex = codex.CheckCLIAvailable + } + if checks.Pi == nil { + checks.Pi = pi.CheckCLIAvailable + } + out := agentCLIDiscovery{ + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + Steering: true, + DurableTurns: true, + DurableInputReceipts: true, + FunctionTools: true, + MCPHTTPTools: true, + MCPHTTPBearerAuth: true, + MessageItems: true, + ToolItems: true, + ToolObservations: true, + EnvironmentNone: true, + WebSearchControl: true, + TextVerbosity: codex.SupportsTextVerbosity, + ExecutionControls: codex.SupportsTextVerbosity, + SubagentControl: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Capabilities: proto.AgentKindCapabilities{ + // pi runs --no-approve, so no permission cards; streaming, + // usage, and --session resume are all wired. + Streaming: true, + Usage: true, + Resume: true, + }, + }, + } + + claudeCtx, cancelClaude := context.WithTimeout(context.Background(), cliVersionTimeout) + claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") + cancelClaude() + if claudeErr == nil { + out.ClaudeCode.Available = true + out.ClaudeCode.Version = claudeVersion + fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) + } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "parsar-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "parsar-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) + } + + opencodeCtx, cancelOpenCode := context.WithTimeout(context.Background(), cliVersionTimeout) + opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") + cancelOpenCode() + if opencodeErr == nil { + out.OpenCode.Available = true + out.OpenCode.Version = opencodeVersion + fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) + } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "parsar-daemon: OpenCode CLI not found on PATH; opencode unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "parsar-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + } + + codexCtx, cancelCodex := context.WithTimeout(context.Background(), cliVersionTimeout) + codexVersion, codexErr := checks.Codex(codexCtx, "") + cancelCodex() + if codexErr == nil { + out.Codex.Available = true + out.Codex.Version = codexVersion + out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) + out.Codex.Capabilities.RemoteEnvironment = codex.SupportsRemoteEnvironment(codexVersion) + out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) + out.Codex.Capabilities.LocalEnvironmentNetworkPolicy = codex.SupportsLocalNetworkPolicy(codexVersion) + out.Codex.Capabilities.MCPHTTPRemoteEnvironment = out.Codex.Capabilities.RemoteEnvironment + out.Codex.Capabilities.MCPHTTPRequired = out.Codex.Capabilities.RemoteEnvironment + out.Codex.Capabilities.MCPHTTPRemoteBearerAuth = out.Codex.Capabilities.RemoteEnvironment + fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) + } else if errors.Is(codexErr, codex.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "parsar-daemon: Codex CLI not found on PATH; codex unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "parsar-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) + } + + piCtx, cancelPi := context.WithTimeout(context.Background(), cliVersionTimeout) + piVersion, piErr := checks.Pi(piCtx, "") + cancelPi() + if piErr == nil { + out.Pi.Available = true + out.Pi.Version = piVersion + fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) + } else if errors.Is(piErr, pi.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "parsar-daemon: pi CLI not found on PATH; pi unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "parsar-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) + } + + out.MCode = discoverMCode(rc, checks.MCode) + discoverMCodeWorkspace(rc, &out) + out.ClaudeSDK = discoverClaudeSDK(rc, profile, checks.ClaudeSDK) + + if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available && !out.MCode.Available && (out.ClaudeSDK == nil || !out.ClaudeSDK.Info.Available) { + return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or mcode, or configure a Claude SDK runtime)") + } + return out, nil +} diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/parsar-daemon/internal/cli/agent_registration.go new file mode 100644 index 000000000..51b7797dd --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_registration.go @@ -0,0 +1,41 @@ +package cli + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, serverURL string) { + registerProductAgentKind(registry, agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) + registerProductAgentKind(registry, agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) + registerProductAgentKind(registry, agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) + if agentCLIs.Codex.Available && (agentCLIs.Codex.Capabilities.RemoteEnvironment || agentCLIs.Codex.Capabilities.LocalEnvironment) { + registry.RegisterPreparation("codex", codex.SupportsWorkspaceReadPreparation() || agentCLIs.Codex.Capabilities.LocalEnvironment, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + prepared, err := codex.Prepare(ctx, req) + if prepared == nil { + return nil, err + } + return prepared, err + }) + } + registerProductAgentKind(registry, agentCLIs.Pi, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) + if agentCLIs.MCodeWorkspace != nil { + registry.RegisterKind(agentCLIs.MCode, mcode.Factory) + registry.RegisterPreparation("mcode", true, mcode.NewPreparationFactory(*agentCLIs.MCodeWorkspace)) + } else { + registerProductAgentKind(registry, agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) + } + registerClaudeSDK(registry, agentCLIs.ClaudeSDK) +} + +func registerProductAgentKind(registry *agent.Registry, info proto.SupportedAgentKind, factory agent.Factory) { + info.Capabilities.WorkspaceAuthoring = true + registry.RegisterKind(info, factory) +} diff --git a/apps/parsar-daemon/internal/cli/authoring.go b/apps/parsar-daemon/internal/cli/authoring.go new file mode 100644 index 000000000..b6b456906 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/authoring.go @@ -0,0 +1,70 @@ +package cli + +import ( + "context" + "maps" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func withAuthoringBridge(factory agent.Factory, bridge *authoring.Bridge) agent.Factory { + return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + if !req.WorkspaceAuthoring { + return factory(ctx, req, out) + } + path, release, err := bridge.Listen(ctx, req.RunID) + if err != nil { + return nil, err + } + req.AgentOptions = maps.Clone(req.AgentOptions) + if req.AgentOptions == nil { + req.AgentOptions = make(map[string]any) + } + env, _ := req.AgentOptions["env"].(map[string]any) + env = maps.Clone(env) + if env == nil { + env = make(map[string]any) + } + env[proto.AuthoringSocketEnv] = path + if executable, err := os.Executable(); err == nil { + addCompanionCLIPath(env, filepath.Dir(executable)) + } + req.AgentOptions["env"] = env + upstream := make(chan proto.Envelope, 64) + session, err := factory(ctx, req, upstream) + if err != nil { + release() + return nil, err + } + go func() { + defer close(out) + defer release() + for event := range upstream { + if event.Type == proto.TypeDone { + release() + } + out <- event + } + }() + return session, nil + } +} + +func authoringRegistry(registry *agent.Registry, bridge *authoring.Bridge) *agent.Registry { + wrapped := agent.NewRegistry() + for _, info := range registry.SupportedAgentKinds() { + factory, _ := registry.Resolve(info.Kind) + if info.Capabilities.WorkspaceAuthoring { + factory = withAuthoringBridge(factory, bridge) + } + wrapped.RegisterKind(info, factory) + if prepare, err := registry.ResolvePreparation(info.Kind); err == nil { + wrapped.RegisterPreparation(info.Kind, info.Capabilities.WorkspaceReadPreparation, prepare) + } + } + return wrapped +} diff --git a/apps/parsar-daemon/internal/cli/authoring_test.go b/apps/parsar-daemon/internal/cli/authoring_test.go new file mode 100644 index 000000000..5332f8063 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/authoring_test.go @@ -0,0 +1,98 @@ +package cli + +import ( + "context" + "errors" + "net" + "os" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestAuthoringSocketEndsWithTurnWhileSessionIsRetained(t *testing.T) { + home, err := os.MkdirTemp("/tmp", "pa-wrap-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + env := map[string]any{"MODEL_KEY": "preserved"} + var path string + var events chan<- proto.Envelope + factory := withAuthoringBridge(func(_ context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + injected := req.AgentOptions["env"].(map[string]any) + path, _ = injected[proto.AuthoringSocketEnv].(string) + if path == "" || injected["MODEL_KEY"] != "preserved" { + t.Fatal("missing per-run context") + } + events = out + return nil, nil + }, authoring.New(nil)) + out := make(chan proto.Envelope, 1) + _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "run", WorkspaceAuthoring: true, AgentOptions: map[string]any{"env": env}}, out) + if err != nil { + t.Fatal(err) + } + if _, exists := env[proto.AuthoringSocketEnv]; exists { + t.Fatal("mutated caller environment") + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } + done, _ := proto.NewEnvelope(proto.TypeDone, "run", proto.DonePayload{}) + events <- done + select { + case <-out: + case <-time.After(time.Second): + t.Fatal("terminal event blocked") + } + if conn, err := net.DialTimeout("unix", path, time.Second); err == nil { + _ = conn.Close() + t.Fatal("authoring remained available after done") + } + close(events) +} + +func TestAuthoringRegistryRequiresExplicitCapability(t *testing.T) { + root, err := os.MkdirTemp("/tmp", "pa-opt-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + t.Setenv("PARSAR_HOME", root) + for _, optIn := range []bool{false, true} { + reg := agent.NewRegistry() + called := false + stop := errors.New("controlled factory stop") + out := make(chan proto.Envelope, 1) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "engine", Available: true, Capabilities: proto.AgentKindCapabilities{WorkspaceAuthoring: optIn}}, func(_ context.Context, req proto.PromptRequestPayload, events chan<- proto.Envelope) (agent.Session, error) { + called = true + env, _ := req.AgentOptions["env"].(map[string]any) + socket, _ := env[proto.AuthoringSocketEnv].(string) + if (socket != "") != optIn { + t.Fatal("authoring capability was not respected") + } + if optIn { + if _, err := os.Stat(socket); err != nil { + t.Fatal(err) + } + } else if events != out { + t.Fatal("non-product event channel was wrapped") + } + return nil, stop + }) + wrapped := authoringRegistry(reg, authoring.New(nil)) + factory, err := wrapped.Resolve("engine") + if err != nil { + t.Fatal(err) + } + _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "run", WorkspaceAuthoring: true}, out) + if !called || !errors.Is(err, stop) { + t.Fatal("registered factory was not preserved", err) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/capability_downloads.go b/apps/parsar-daemon/internal/cli/capability_downloads.go new file mode 100644 index 000000000..2f5e95819 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/capability_downloads.go @@ -0,0 +1,67 @@ +package cli + +import ( + "context" + "maps" + "net" + "net/url" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Use the paired server address for loopback PG downloads: inside Compose, +// the public loopback address points to the runtime container itself. +func withCapabilityDownloads(factory agent.Factory, serverURL string) agent.Factory { + base, err := url.Parse(serverURL) + if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") { + return factory + } + return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + opts := maps.Clone(req.AgentOptions) + for _, key := range []string{"skills", "plugins"} { + items, ok := opts[key].([]any) + if !ok { + continue + } + items = slices.Clone(items) + for i, item := range items { + descriptor, ok := item.(map[string]any) + if !ok { + continue + } + raw, _ := descriptor["download_url"].(string) + if resolved := capabilityDownloadURL(raw, base); resolved != raw { + updated := maps.Clone(descriptor) + updated["download_url"] = resolved + items[i] = updated + } + } + opts[key] = items + } + req.AgentOptions = opts + return factory(ctx, req, out) + } +} + +func capabilityDownloadURL(raw string, base *url.URL) string { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return raw + } + host := u.Hostname() + if !strings.EqualFold(host, "localhost") && !net.ParseIP(host).IsLoopback() { + return raw + } + const blobPath = "/internal/blobs/pg:" + start := strings.Index(u.Path, blobPath) + if start < 0 { + return raw + } + u.Scheme, u.Host, u.User = base.Scheme, base.Host, nil + u.Path = strings.TrimRight(base.Path, "/") + u.Path[start:] + u.RawPath = "" + return u.String() +} diff --git a/apps/parsar-daemon/internal/cli/capability_downloads_test.go b/apps/parsar-daemon/internal/cli/capability_downloads_test.go new file mode 100644 index 000000000..5dcaf97e8 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/capability_downloads_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCapabilityDownloadUsesPairedServerOnlyForLoopbackPG(t *testing.T) { + base, _ := url.Parse("https://parsar.example.test/team") + for _, tc := range []struct{ raw, want string }{ + {"http://127.0.0.1:28080/internal/blobs/pg:id?token=a%2Bb", "https://parsar.example.test/team/internal/blobs/pg:id?token=a%2Bb"}, + {"http://localhost:28080/old/internal/blobs/pg:id?token=a", "https://parsar.example.test/team/internal/blobs/pg:id?token=a"}, + {"http://[::1]:28080/internal/blobs/pg:id?token=a", "https://parsar.example.test/team/internal/blobs/pg:id?token=a"}, + {"https://bucket.example.test/skill.zip?Signature=a", "https://bucket.example.test/skill.zip?Signature=a"}, + {"http://localhost:28080/other.zip", "http://localhost:28080/other.zip"}, + {"https://public.example.test/internal/blobs/pg:id?token=a", "https://public.example.test/internal/blobs/pg:id?token=a"}, + {"file:///internal/blobs/pg:id", "file:///internal/blobs/pg:id"}, + } { + if got := capabilityDownloadURL(tc.raw, base); got != tc.want { + t.Errorf("%s: got %s, want %s", tc.raw, got, tc.want) + } + } +} + +func TestCapabilityDownloadsInstallZIPThroughPairedServer(t *testing.T) { + var archive bytes.Buffer + w := zip.NewWriter(&archive) + for name, content := range map[string]string{ + "SKILL.md": "---\nname: qa-download\ndescription: Test downloaded context\n---\nRead references/policy.md.\n", + "references/policy.md": "Collect the laptop on the third working day.\n", + } { + file, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/team/internal/blobs/pg:test" || r.URL.RawQuery != "token=synthetic%2Bsignature" { + t.Errorf("download request changed signed resource: %s", r.URL) + http.Error(w, "unexpected request", http.StatusForbidden) + return + } + _, _ = w.Write(archive.Bytes()) + })) + defer server.Close() + root := t.TempDir() + rawURL := "http://127.0.0.1:1/internal/blobs/pg:test?token=synthetic%2Bsignature" + descriptor := map[string]any{"name": "qa-download", "version": "1.0.0", "download_url": rawURL, "sha256": fmt.Sprintf("%x", sha256.Sum256(archive.Bytes()))} + opts := map[string]any{"skills": []any{descriptor}, "plugins": []any{descriptor}, "model": "preserved"} + factory := withCapabilityDownloads(func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + if req.RunID != "run-test" || req.AgentOptions["model"] != "preserved" { + t.Fatal("unrelated request fields changed") + } + plugin := req.AgentOptions["plugins"].([]any)[0].(map[string]any) + skill := req.AgentOptions["skills"].([]any)[0].(map[string]any) + if plugin["download_url"] != skill["download_url"] { + t.Fatal("plugin and skill routing differs") + } + result, err := claudecode.InstallManagedSkills(ctx, nil, root, req.AgentOptions["skills"]) + if err == nil && len(result.Warnings) > 0 { + err = fmt.Errorf("installation warnings: %v", result.Warnings) + } + return nil, err + }, server.URL+"/team") + if _, err := factory(context.Background(), proto.PromptRequestPayload{RunID: "run-test", AgentOptions: opts}, nil); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, "qa-download", "references", "policy.md")) + if err != nil || string(content) != "Collect the laptop on the third working day.\n" { + t.Fatalf("installed reference: %q, %v", content, err) + } + if descriptor["download_url"] != rawURL { + t.Fatal("rewrote the original request options") + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go new file mode 100644 index 000000000..442f4d6dc --- /dev/null +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -0,0 +1,125 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const claudeSDKEntrypointEnv = "PARSAR_CLAUDE_SDK_ENTRYPOINT" +const claudeSDKNodeEnv = "PARSAR_CLAUDE_SDK_NODE" + +type claudeSDKDiscovery struct { + Info proto.SupportedAgentKind + Config claudesdk.Config +} + +func discoverClaudeSDK(rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { + entrypoint := os.Getenv(claudeSDKEntrypointEnv) + if entrypoint == "" { + return nil + } + out := &claudeSDKDiscovery{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ + Streaming: true, Usage: true, Resume: true, Steering: true, MessageItems: true, + ToolObservations: true, EnvironmentNone: true, SubagentControl: true, + DurableTurns: true, DurableInputReceipts: true, FunctionTools: true, ExecutionControls: true, + }}} + fail := func(err error) *claudeSDKDiscovery { + fmt.Fprintf(rc.stderr, "parsar-daemon: configured Claude SDK runtime unavailable: %v\n", err) + return out + } + if !filepath.IsAbs(entrypoint) { + return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) + } + profileDir, err := paths.ProfileDir(profile) + if err != nil { + return fail(err) + } + if !filepath.IsAbs(profileDir) { + return fail(fmt.Errorf("Claude SDK state requires an absolute PARSAR_HOME")) + } + node := os.Getenv(claudeSDKNodeEnv) + if node == "" { + node = "node" + } + node, err = exec.LookPath(node) + if err != nil { + return fail(fmt.Errorf("Claude SDK Node executable is unavailable")) + } + node, err = filepath.Abs(node) + if err != nil { + return fail(err) + } + out.Config = claudesdk.Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} + if mode := os.Getenv("PARSAR_CLAUDE_SDK_WORKSPACE"); mode != "" { + if mode != "managed" { + return fail(fmt.Errorf("unsupported Claude SDK workspace profile")) + } + binding, err := localworkspace.Load() + if err != nil || binding == nil { + return fail(fmt.Errorf("Claude SDK workspace requires a dedicated local Runtime binding")) + } + root, err := paths.Root() + if err != nil { + return fail(err) + } + out.Config.Node, err = filepath.EvalSymlinks(node) + if err != nil { + return fail(err) + } + out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkAccess(), os.Getenv("PARSAR_RUNTIME_STAGING")) + if err != nil { + return fail(err) + } + } + if check == nil { + check = claudesdk.CheckRuntime + } + info, err := check(context.Background(), out.Config) + if err != nil { + return fail(err) + } + if out.Config.Workspace != nil { + if !info.SupportsLocalRuntime() { + return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) + } + caps := &out.Info.Capabilities + caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() + caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true + } + out.Info.Available, out.Info.Version = true, info.SDK + out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() + out.Info.Capabilities.MCPHTTPBearerAuth = info.SupportsHTTPMCPBearer() + out.Info.Capabilities.MCPHTTPRequired = info.SupportsHTTPMCPRequired() + if out.Config.Workspace != nil { + out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = false, false + out.Info.Capabilities.MCPHTTPRequired = false + } + fmt.Fprintf(rc.stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) + return out +} + +func registerClaudeSDK(registry *agent.Registry, discovery *claudeSDKDiscovery) { + if discovery == nil { + return + } + factory := claudesdk.NewFactory(discovery.Config) + if !discovery.Info.Available { + factory = func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, fmt.Errorf("claude_sdk: configured runtime is unavailable") + } + } + registry.RegisterKind(discovery.Info, factory) + if discovery.Info.Available && discovery.Info.Capabilities.LocalEnvironment { + registry.RegisterPreparation("claude_sdk", true, claudesdk.NewPreparationFactory(discovery.Config)) + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go new file mode 100644 index 000000000..94b90bc67 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go @@ -0,0 +1,189 @@ +//go:build linux + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +type registeredSDKSender chan proto.Envelope + +func (s registeredSDKSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestLiveRegisteredClaudeSDK(t *testing.T) { + entrypoint, keyFile := os.Getenv(claudeSDKEntrypointEnv), os.Getenv("PARSAR_CLAUDE_SDK_MINIMAX_KEY_FILE") + if entrypoint == "" || keyFile == "" { + t.Skip("requires explicit SDK runtime and real provider key file") + } + proofRoot := os.Getenv("PARSAR_CLAUDE_SDK_PROOF_DIR") + if !filepath.IsAbs(proofRoot) { + t.Fatal("real acceptance requires an absolute managed proof directory") + } + root, err := os.MkdirTemp(proofRoot, "claude-registered-") + if err != nil { + t.Fatal(err) + } + t.Logf("registered SDK evidence: %s", root) + t.Setenv("PARSAR_HOME", root) + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.minimax.cn/anthropic", "ANTHROPIC_AUTH_TOKEN": strings.TrimSpace(string(key)), + "ANTHROPIC_API_KEY": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M3", + } { + t.Setenv(name, value) + } + stdout, stderr := &strings.Builder{}, &strings.Builder{} + discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) + if err != nil || discovery.ClaudeSDK == nil || !discovery.ClaudeSDK.Info.Available { + t.Fatal("SDK-only discovery failed", err) + } + type execution struct { + Outcome proto.DonePayload `json:"outcome"` + Events []proto.Envelope `json:"events"` + FunctionCalls int `json:"function_calls"` + AppliedResults int `json:"applied_results"` + Cancelled bool `json:"cancelled"` + } + nonce := "registered-function-" + uuid.NewString() + run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution { + t.Helper() + reg := agent.NewRegistry() + registerAgentKinds(reg, discovery, "https://product.invalid") + reg = authoringRegistry(reg, authoring.New(nil)) + sender := make(registeredSDKSender, 256) + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Error("router shutdown", err) + } + }() + ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) + defer cancel() + id := uuid.NewString() + request := proto.PromptRequestPayload{RunID: id, AgentKind: "claude_sdk", Prompt: prompt, AgentStateKey: "registered-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": nil}} + if callFunction { + request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} + } + handle := func(kind string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + if err := router.Handle(ctx, env); err != nil { + t.Fatal("registered router request failed", err) + } + } + handle(proto.TypePromptRequest, request) + proof := execution{} + defer func() { + data, _ := json.MarshalIndent(proof, "", " ") + if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", index)), data, 0o600); err != nil { + t.Error(err) + } + }() + completed, cancelAck := false, false + for !completed || (callFunction && proof.AppliedResults == 0) || (cancelOnText && !cancelAck) { + var event proto.Envelope + select { + case event = <-sender: + case <-ctx.Done(): + t.Fatal("registered execution timed out", ctx.Err()) + } + if event.ID != id { + t.Fatal("event identity changed") + } + proof.Events = append(proof.Events, event) + switch event.Type { + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + proof.FunctionCalls++ + if !callFunction || proof.FunctionCalls != 1 || call.Name != "lookup" { + t.Fatal("unexpected registered function call") + } + handle(proto.TypeFunctionResult, proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: "result", Success: true, Content: []proto.FunctionResultContent{{Type: "input_text", Text: &nonce}}}) + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + if err := event.DecodePayload(&ack); err != nil { + t.Fatal(err) + } + if !ack.Applied { + t.Fatal("registered interaction was not applied", ack) + } + if ack.DeliveryID == "result" { + proof.AppliedResults++ + } + if ack.DeliveryID == "cancel" { + cancelAck = true + } + case proto.TypeDelta: + if cancelOnText && !proof.Cancelled { + proof.Cancelled = true + handle(proto.TypePromptCancel, proto.PromptCancelPayload{DeliveryID: "cancel"}) + } + case proto.TypeError: + if !proof.Cancelled { + t.Fatalf("registered native execution failed: %s", event.Payload) + } + case proto.TypeDone: + completed = true + if err := event.DecodePayload(&proof.Outcome); err != nil { + t.Fatal(err) + } + } + } + if proof.Outcome.Content == "" || proof.Cancelled != cancelOnText { + t.Fatal("missing registered text/cancellation outcome") + } + return proof + } + first := run(1, "Call lookup exactly once with id 42 as a string. Reply with its exact returned verification value.", "", true, false) + id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) + if id == "" || !strings.Contains(first.Outcome.Content, nonce) || first.FunctionCalls != 1 || first.AppliedResults != 1 { + t.Fatal("registered function flow failed") + } + second := run(2, "First repeat the verification value from the lookup result, then write two hundred numbered sentences about trees. Use no tools.", id, false, true) + if second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id { + t.Fatal("registered cancellation lost native identity") + } + third := run(3, "Return only the exact registered-function verification value from the earlier lookup result. Ignore the prior tree request.", id, false, false) + if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Outcome.Content, nonce) { + t.Fatal("registered cold continuation lost identity or history") + } + data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration/authoring registry -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery.ClaudeSDK.Info, "node": discovery.ClaudeSDK.Config.Node, "entrypoint": discovery.ClaudeSDK.Config.Entrypoint, "state_dir": discovery.ClaudeSDK.Config.StateDir, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") + if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go new file mode 100644 index 000000000..b238280f1 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func unavailableCLIChecks() agentCLIChecks { + missing := func(context.Context, string) (string, error) { return "", errors.New("unavailable test CLI") } + return agentCLIChecks{ClaudeCode: missing, OpenCode: missing, Codex: missing, Pi: missing, MCode: missing} +} + +func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { + for _, tc := range []struct { + name string + configured, ready, legacy bool + }{ + {"unconfigured", false, false, true}, {"SDK-only", true, true, false}, + {"failed-with-legacy", true, false, true}, {"none-ready", true, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + entrypoint := "" + if tc.configured { + entrypoint = filepath.Join(root, "replaceable bundle", "dist", "main.js") + } + t.Setenv(claudeSDKEntrypointEnv, entrypoint) + node, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, node) + calls := 0 + checks := unavailableCLIChecks() + checks.ClaudeSDK = func(_ context.Context, config claudesdk.Config) (claudesdk.RuntimeInfo, error) { + calls++ + if config.Node != node || config.Entrypoint != entrypoint || config.StateDir != filepath.Join(root, "parsar-daemon", "test", "runtime", "claude-sdk") || config.Env != nil { + t.Fatalf("readiness configuration differs from operator configuration: %+v", config) + } + if !tc.ready { + return claudesdk.RuntimeInfo{}, errors.New("controlled readiness failure") + } + return claudesdk.RuntimeInfo{SDK: "test-sdk", Native: "test-native"}, nil + } + if tc.legacy { + checks.Pi = func(context.Context, string) (string, error) { return "test-pi", nil } + } + stdout, stderr := &strings.Builder{}, &strings.Builder{} + discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "test", checks) + if (err == nil) != (tc.ready || tc.legacy) { + t.Fatalf("startup readiness: %v", err) + } + if (calls == 1) != tc.configured { + t.Fatalf("SDK readiness calls: %d", calls) + } + reg := agent.NewRegistry() + registerAgentKinds(reg, discovery, "https://product.invalid") + reg = authoringRegistry(reg, authoring.New(nil)) + factory, err := reg.Resolve("claude_sdk") + if !tc.configured { + if discovery.ClaudeSDK != nil || err == nil { + t.Fatal("unconfigured SDK was registered") + } + return + } + if err != nil { + t.Fatal(err) + } + info := discovery.ClaudeSDK.Info + if info.Available != tc.ready { + t.Fatal(info) + } + for _, registered := range reg.SupportedAgentKinds() { + if registered.Kind == "claude_sdk" && registered != info { + t.Fatalf("SDK descriptor changed: %+v", registered) + } + if registered.Kind != "claude_sdk" && !registered.Capabilities.WorkspaceAuthoring { + t.Fatalf("product authoring lost: %+v", registered) + } + } + caps := info.Capabilities + if caps.WorkspaceAuthoring || caps.Permissions || caps.ToolItems || caps.WebSearchControl || caps.TextVerbosity || !caps.DurableTurns || !caps.DurableInputReceipts || !caps.FunctionTools || !caps.EnvironmentNone { + t.Fatalf("incorrect SDK capability scope: %+v", caps) + } + // Even a ready SDK must not acquire product write access through the wrapper. + _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "sdk", Prompt: "hello", WorkspaceAuthoring: true}, make(chan proto.Envelope, 1)) + if err == nil || (!tc.ready && !strings.Contains(err.Error(), "runtime is unavailable")) { + t.Fatalf("SDK request did not fail closed: %v", err) + } + }) + } +} + +func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { + for _, relative := range []string{"entrypoint", "home"} { + t.Run(relative, func(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + if relative == "entrypoint" { + t.Setenv(claudeSDKEntrypointEnv, "main.js") + } else { + t.Setenv("PARSAR_HOME", "relative-home") + } + out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + t.Fatal("invalid paths reached runtime probe") + return claudesdk.RuntimeInfo{}, nil + }) + if out == nil || out.Info.Available { + t.Fatal("invalid runtime advertised as ready") + } + }) + } +} + +func TestClaudeSDKMCPFeatureDiscovery(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + node, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, node) + for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}} { + out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} + return info, nil + }) + supported := len(features) > 0 && features[0] == "mcp_http_tools" + if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools != supported || out.Info.Capabilities.MCPHTTPBearerAuth != (supported && slices.Contains(features, "mcp_http_bearer_auth")) || out.Info.Capabilities.MCPHTTPRequired != (supported && slices.Contains(features, "mcp_http_required")) { + t.Fatal("MCP feature discovery widened the runtime profile") + } + } +} diff --git a/apps/parsar-daemon/internal/cli/companion_path_test.go b/apps/parsar-daemon/internal/cli/companion_path_test.go new file mode 100644 index 000000000..4d8b560fa --- /dev/null +++ b/apps/parsar-daemon/internal/cli/companion_path_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCompanionCLIPath(t *testing.T) { + for _, tc := range []struct { + name string + mode os.FileMode + want bool + }{ + {"executable", 0o700, true}, + {"download awaiting review", 0o600, false}, + {"missing", 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if tc.mode != 0 { + if err := os.WriteFile(filepath.Join(dir, "parsar"), []byte("binary"), tc.mode); err != nil { + t.Fatal(err) + } + } + env := map[string]any{"PATH": "/existing/tools", "OTHER": "retained"} + addCompanionCLIPath(env, dir) + want := "/existing/tools" + if tc.want { + want = dir + string(os.PathListSeparator) + want + } + if env["PATH"] != want || env["OTHER"] != "retained" { + t.Fatalf("unexpected child environment: %v", env) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go new file mode 100644 index 000000000..385e9f529 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -0,0 +1,415 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +const ( + // cliVersionTimeout caps CLI `--version` preflights so a hung agent + // binary can't keep `parsar-daemon connect` blocked at startup. + cliVersionTimeout = 15 * time.Second + + bootstrapTimeout = 10 * time.Second + + killTimeout = 3 * time.Second + + connectInlineURLEnv = "PARSAR_DAEMON_CONNECT_URL" + connectInlineTokenEnv = "PARSAR_DAEMON_CONNECT_TOKEN" + connectInlineDeviceNameEnv = "PARSAR_DAEMON_CONNECT_DEVICE_NAME" +) + +// runConnect dials /agent-daemon/bootstrap, opens /agent-daemon/ws, +// wires the dispatch router, and routes Envelope traffic both ways +// until either SIGINT/SIGTERM or a permanent credential rejection. +// +// `connect --url --token` folds one-shot pairing into the connect step: +// the daemon consumes the pairing token, persists the returned runner +// credential to auth.json, and connects. Subsequent `connect -b` +// invocations reload the persisted profile. +// +// -b re-execs the binary in the background with stdio redirected to +// connect.log and the child PID written to connect.pid. The child +// re-enters runConnect via BackgroundSentinelEnv. When --token is +// supplied, the parent forks before pairing so the one-shot token is +// consumed by the long-lived child. Inline pairing flags are scrubbed +// from child argv and passed via environment to keep the token out of +// process listings. +func runConnect(ctx *runContext, args []string) error { + fs := newFlagSet("connect") + var ( + profile = fs.String("profile", paths.DefaultProfile, "profile name for reading legacy auth.json state or writing pid/log files") + background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") + serverURL = fs.String("url", "", "Parsar server base URL; with --token, pair inline before connecting") + token = fs.String("token", "", "pairing token; with --url, connect consumes it without writing auth.json") + deviceName = fs.String("device-name", "", "human label for inline pairing (defaults to hostname)") + ) + if err := fs.Parse(args); err != nil { + return fmt.Errorf("connect: parse flags: %w", err) + } + // Hydrate inline pairing inputs from env in BOTH parent and the + // re-execed background child. Server-spawned sandboxes pass the + // token via PARSAR_DAEMON_CONNECT_TOKEN/URL env rather than --url + // /--token flags; without this hydration before the pre-fork + // auth.json check below, the parent would take the "rely on + // auth.json" branch and bail with "not paired". Idempotent — + // fills only empty flags and unsets the env after consuming. + loadInlineConnectEnv(serverURL, token, deviceName) + if err := paths.ValidateProfile(*profile); err != nil { + return fmt.Errorf("connect: %w", err) + } + + inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" + if inlinePair { + if strings.TrimSpace(*serverURL) == "" { + return fmt.Errorf("connect: --url is required when --token is supplied") + } + if strings.TrimSpace(*token) == "" { + return fmt.Errorf("connect: --token is required when --url is supplied") + } + } + + // -b mode: parent forks, child re-enters with sentinel env set + // and skips this branch. Fork before inline pairing so the + // one-shot token is consumed by the child that owns the WS loop. + if *background && !daemonize.IsBackgroundChild() { + // Validate auth.json exists before forking so the error + // surfaces in the user's terminal instead of the background + // child's log. + if !inlinePair { + if _, err := auth.Load(*profile); err != nil { + return fmt.Errorf("connect: %w", err) + } + } + argv := os.Args + extraEnv := []string(nil) + if inlinePair { + argv = scrubInlineConnectArgs(os.Args) + extraEnv = inlineConnectEnv(*serverURL, *token, *deviceName) + } + return spawnBackground(ctx, *profile, argv, extraEnv) + } + + // Self-check before pairing/loading credentials so a machine with + // no supported agent CLI fails before consuming a one-shot token. + agentCLIs, err := preflightAgentCLIs(ctx, *profile) + if err != nil { + return err + } + + prof, err := resolveConnectProfile(*profile, *serverURL, *token, *deviceName) + if err != nil { + return err + } + + return mainLoop(ctx, *profile, prof, agentCLIs) +} + +func loadInlineConnectEnv(serverURL, token, deviceName *string) { + if strings.TrimSpace(*serverURL) == "" { + *serverURL = os.Getenv(connectInlineURLEnv) + } + if strings.TrimSpace(*token) == "" { + *token = os.Getenv(connectInlineTokenEnv) + } + if strings.TrimSpace(*deviceName) == "" { + *deviceName = os.Getenv(connectInlineDeviceNameEnv) + } + _ = os.Unsetenv(connectInlineURLEnv) + _ = os.Unsetenv(connectInlineTokenEnv) + _ = os.Unsetenv(connectInlineDeviceNameEnv) +} + +func inlineConnectEnv(serverURL, token, deviceName string) []string { + out := []string{ + connectInlineURLEnv + "=" + serverURL, + connectInlineTokenEnv + "=" + token, + } + if strings.TrimSpace(deviceName) != "" { + out = append(out, connectInlineDeviceNameEnv+"="+deviceName) + } + return out +} + +func scrubInlineConnectArgs(argv []string) []string { + out := make([]string, 0, len(argv)) + for i := 0; i < len(argv); i++ { + arg := argv[i] + switch { + case arg == "--url" || arg == "--token" || arg == "--device-name": + i++ + continue + case strings.HasPrefix(arg, "--url=") || strings.HasPrefix(arg, "--token=") || strings.HasPrefix(arg, "--device-name="): + continue + default: + out = append(out, arg) + } + } + return out +} + +func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.Profile, error) { + if strings.TrimSpace(serverURL) == "" && strings.TrimSpace(token) == "" { + prof, err := auth.Load(profile) + if err != nil { + return auth.Profile{}, fmt.Errorf("connect: %w", err) + } + return prof, nil + } + + pairCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + prof, _, err := pairProfile(pairCtx, serverURL, token, deviceName) + if err != nil { + return auth.Profile{}, fmt.Errorf("connect: pair with server: %w", err) + } + if err := auth.Save(profile, prof); err != nil { + return auth.Profile{}, fmt.Errorf("connect: save auth profile: %w", err) + } + return prof, nil +} + +// spawnBackground forks the daemon into the background. Parent +// returns after printing the child PID; child re-enters runConnect +// with BackgroundSentinelEnv set so the same mainLoop runs in either +// mode. +func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []string) error { + logPath, err := paths.LogFile(profile) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + pidPath, err := paths.PIDFile(profile) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + // Refuse to start a second background daemon for the same profile. + if pid, err := daemonize.ReadPIDFile(pidPath); err == nil { + return fmt.Errorf("connect: background daemon already running (pid=%d); run `parsar-daemon stop` first", pid) + } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, daemonize.ErrStaleOrCorrupt) { + return fmt.Errorf("connect: check pidfile: %w", err) + } + // Stale pidfile → remove so WritePIDFile starts clean. + _ = daemonize.RemovePIDFile(pidPath) + + if err := daemonize.EnsureLogFile(logPath); err != nil { + return fmt.Errorf("connect: %w", err) + } + + pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + ExtraEnv: extraEnv, + }) + if err != nil { + return fmt.Errorf("connect: spawn background: %w", err) + } + + fmt.Fprintf(rc.stdout, "parsar-daemon: backgrounded (pid=%d)\n", pid) + fmt.Fprintf(rc.stdout, " logs : %s\n", logPath) + fmt.Fprintf(rc.stdout, " pid : %s\n", pidPath) + fmt.Fprintf(rc.stdout, " stop : parsar-daemon stop --profile %s\n", profile) + return nil +} + +// mainLoop is the daemon body — runs in foreground and in the re-execed +// background process. SIGINT / SIGTERM cancels the root context, which +// unblocks the read pump and any in-flight Send so the daemon exits +// without orphaning agent subprocesses. +func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery) error { + // Route through obs/log so daemon log lines pick up the same + // trace_id / span_id auto-injection as the server side — when the + // daemon adopts an envelope's trace, every log call under that ctx + // gets the same trace_id so `grep ` finds the line on + // both ends. + obslog.Init(obslog.Config{ + Format: "text", + Level: slog.LevelInfo, + Out: rc.stderr, + }) + + rootCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Honour SIGINT / SIGTERM as graceful shutdown. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + select { + case sig := <-sigCh: + obslog.Bg().Info("received signal, shutting down", "signal", sig.String()) + cancel() + case <-rootCtx.Done(): + } + signal.Stop(sigCh) + }() + + bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) + boot, err := transport.Bootstrap(bootCtx, prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) + bootCancel() + if err != nil { + return fmt.Errorf("connect: bootstrap: %w", err) + } + wsURL, err := transport.DeriveWSURL(*boot, prof.ServerURL) + if err != nil { + return fmt.Errorf("connect: derive ws url: %w", err) + } + obslog.Bg().Info("bootstrap ok", "device_id", boot.DeviceID, "ws_url", wsURL, "heartbeat_interval", boot.HeartbeatInterval()) + + registry := agent.NewRegistry() + registerAgentKinds(registry, agentCLIs, prof.ServerURL) + + dial := func(ctx context.Context) (*transport.Conn, error) { + return transport.Dial(ctx, transport.DialOptions{ + WSURL: wsURL, + DeviceID: boot.DeviceID, + Credential: prof.RunnerCredential, + // DaemonVersion is the WIRE-PROTOCOL version, not the build + // tag. proto.VersionCompatible is a strict major.minor + // match against proto.Version. Build-tag reporting goes + // in heartbeat's DaemonVersion field. + DaemonVersion: proto.Version, + }) + } + + for { + if err := rootCtx.Err(); err != nil { + return nil + } + + conn, err := transport.Reconnect(rootCtx, dial, transport.DefaultBackoff, func(attempt int, lastDelay time.Duration, lastErr error) { + switch { + case attempt == 1: + obslog.Bg().Info("connecting", "ws_url", wsURL) + case lastErr != nil: + // Include lastErr so a stuck Reconnect tells the + // operator WHY ("ws upgrade rejected with 426") + // instead of just "retry attempt 3 after 4s". + obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay, "err", lastErr) + default: + obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay) + } + }) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil + } + if errors.Is(err, transport.ErrPermanent) { + return fmt.Errorf("connect: permanent error (re-pair the daemon): %w", err) + } + return fmt.Errorf("connect: dial: %w", err) + } + obslog.Bg().Info("ws connected", "device_id", conn.DeviceID()) + + // pumpConn returns on conn close (peer hangup, transport + // error, root ctx cancel). Loop back into Reconnect unless + // root ctx is cancelled. + pumpErr := pumpConn(rootCtx, conn, registry, boot, agentCLIs) + if pumpErr != nil { + obslog.Bg().Warn("ws session ended", "err", pumpErr) + } else { + obslog.Bg().Info("ws session ended cleanly") + } + _ = conn.Close() + + // Server-initiated clean close (e.g. shutdown) → exit; + // otherwise loop back and reconnect. + if rootCtx.Err() != nil { + return nil + } + // Permanent error (e.g. runtime deleted) → exit instead of + // reconnecting. + if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { + return fmt.Errorf("connect: runtime deleted (re-pair the daemon): %w", pumpErr) + } + // Small breather before redialing so a flapping server doesn't + // get a tight loop of upgrade requests. + _ = transport.Sleep(rootCtx, 1*time.Second) + } +} + +// pumpConn runs the per-connection workload: a dispatch.Router fed by +// conn.Recv(), heartbeats every boot.HeartbeatInterval(), and a +// graceful router.Shutdown on exit so any in-flight subprocesses get +// SIGTERM. +func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { + local, err := localworkspace.Load() + if err != nil { + return err + } + bridge := authoring.New(conn) + registry = authoringRegistry(registry, bridge) + router, err := dispatch.New(dispatch.Config{ + Registry: registry, + Sender: conn, + Log: obslog.Bg(), + LocalWorkspace: local, + }) + if err != nil { + return fmt.Errorf("router init: %w", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = router.Shutdown(shutdownCtx) + cancel() + }() + + conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { + kinds := registry.SupportedAgentKinds() + for i := range kinds { + caps := &kinds[i].Capabilities + caps.WorkspaceOutputExport = local.CanExport() && caps.LocalEnvironment && caps.WorkspaceReadPreparation + } + return proto.HeartbeatPayload{ + Timestamp: time.Now().Unix(), + ActiveRequests: router.ActiveRuns(), + DaemonVersion: Version, + ClaudeAvailable: agentCLIs.ClaudeCode.Available, // legacy server compatibility + SupportedAgentKinds: kinds, + } + }, obslog.Bg().With("component", "heartbeat")) + + obslog.Bg().Info("pumpConn: entering recv loop") + for { + select { + case <-parentCtx.Done(): + obslog.Bg().Warn("pumpConn: parentCtx cancelled", "err", parentCtx.Err()) + return parentCtx.Err() + case <-conn.Done(): + obslog.Bg().Warn("pumpConn: conn.Done fired", "err", conn.Err()) + return conn.Err() + case env, ok := <-conn.Recv(): + if !ok { + obslog.Bg().Warn("pumpConn: recvCh closed", "err", conn.Err()) + return conn.Err() + } + if env.Type == proto.TypeAuthoringResponse { + bridge.Deliver(env) + continue + } + obslog.Bg().Info("pumpConn: received envelope, calling router.Handle", "type", env.Type, "id", env.ID) + if err := router.Handle(parentCtx, env); err != nil { + obslog.Bg().Error("router.Handle failed", "type", env.Type, "id", env.ID, "err", err) + } else { + obslog.Bg().Info("pumpConn: router.Handle ok", "type", env.Type, "id", env.ID) + } + } + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go new file mode 100644 index 000000000..fbfc7fd24 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -0,0 +1,273 @@ +package cli + +import ( + "context" + "os" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { + got := scrubInlineConnectArgs([]string{ + "parsar-daemon", "connect", + "--url", "https://parsar.example.com", + "--token=rtk_secret", + "--device-name", "dev-1", + "-b", + "--profile", "sandbox", + }) + want := []string{"parsar-daemon", "connect", "-b", "--profile", "sandbox"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("scrubInlineConnectArgs() = %#v, want %#v", got, want) + } +} + +func TestLoadInlineConnectEnvFillsMissingValuesAndUnsets(t *testing.T) { + t.Setenv(connectInlineURLEnv, "https://parsar.example.com") + t.Setenv(connectInlineTokenEnv, "rtk_secret") + t.Setenv(connectInlineDeviceNameEnv, "dev-1") + + serverURL, token, deviceName := "", "", "" + loadInlineConnectEnv(&serverURL, &token, &deviceName) + + if serverURL != "https://parsar.example.com" || token != "rtk_secret" || deviceName != "dev-1" { + t.Fatalf("loaded values = (%q, %q, %q)", serverURL, token, deviceName) + } + if got := inlineConnectEnvValue(connectInlineTokenEnv); got != "" { + t.Fatalf("%s still set to %q", connectInlineTokenEnv, got) + } +} + +func inlineConnectEnvValue(key string) string { return os.Getenv(key) } + +// Regression: pre-fork auth.json check used to run BEFORE env-to-flag +// hydration, so sandboxes passing the token via env bailed with +// "not paired". loadInlineConnectEnv now runs first. +func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { + t.Setenv(connectInlineURLEnv, "https://parsar.example.com") + t.Setenv(connectInlineTokenEnv, "rtk_secret") + + serverURL, token, deviceName := "", "", "" + + loadInlineConnectEnv(&serverURL, &token, &deviceName) + + // Same predicate runConnect uses to decide whether to skip the + // pre-fork auth.json check. + inlinePair := strings.TrimSpace(serverURL) != "" || strings.TrimSpace(token) != "" + if !inlinePair { + t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) + } +} + +func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "") + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, + ClaudeCode: func(context.Context, string) (string, error) { + return "", claudecode.ErrCLINotFound + }, + OpenCode: func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + }, + Codex: func(context.Context, string) (string, error) { + return "", codex.ErrCLINotFound + }, + Pi: func(context.Context, string) (string, error) { + return "", pi.ErrCLINotFound + }, + }) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if got.ClaudeCode.Available { + t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if got.Codex.Available { + t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) + } + if got.Pi.Available { + t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) + } + if !got.OpenCode.Capabilities.Streaming || !got.OpenCode.Capabilities.Usage || got.OpenCode.Capabilities.Permissions { + t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) + } + if !strings.Contains(stdout.String(), "OpenCode preflight ok") { + t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "claude_code unavailable") { + t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) + } +} + +func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "") + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, + ClaudeCode: func(context.Context, string) (string, error) { + return "", claudecode.ErrCLINotFound + }, + OpenCode: func(context.Context, string) (string, error) { + return "", opencodeagent.ErrCLINotFound + }, + Codex: func(context.Context, string) (string, error) { + return "", codex.ErrCLINotFound + }, + Pi: func(context.Context, string) (string, error) { + return "", pi.ErrCLINotFound + }, + }) + if err == nil { + t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) + } + if !strings.Contains(err.Error(), "no supported agent CLI") { + t.Fatalf("unexpected error: %v", err) + } + if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available { + t.Fatalf("available descriptors after missing CLIs: %#v", got) + } +} + +func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "") + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + MCode: func(context.Context, string) (string, error) { return "0.4.12", nil }, + ClaudeCode: func(context.Context, string) (string, error) { + return "claude 2.0.0", nil + }, + OpenCode: func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + }, + Codex: func(context.Context, string) (string, error) { + return "codex 0.141.0", nil + }, + Pi: func(context.Context, string) (string, error) { + return "pi 0.1.0", nil + }, + }) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { + t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { + t.Fatalf("Codex descriptor = %#v", got.Codex) + } + if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { + t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) + } + if got.Codex.Capabilities.RemoteEnvironment || got.Codex.Capabilities.ExecutionControls != codex.SupportsTextVerbosity || got.ClaudeCode.Capabilities.ExecutionControls || got.OpenCode.Capabilities.ExecutionControls || !got.Codex.Capabilities.ToolObservations || !got.Codex.Capabilities.SubagentControl || got.Codex.Capabilities.TextVerbosity != codex.SupportsTextVerbosity || !got.Codex.Capabilities.WebSearchControl || !got.Codex.Capabilities.EnvironmentNone || !got.Codex.Capabilities.ToolItems || !got.Codex.Capabilities.MessageItems || !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { + t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) + } + if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { + t.Fatalf("Pi descriptor = %#v", got.Pi) + } + if !got.Pi.Capabilities.Streaming || !got.Pi.Capabilities.Usage || !got.Pi.Capabilities.Resume || got.Pi.Capabilities.Permissions { + t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) + } + if !got.MCode.Available || !got.MCode.Capabilities.Resume || got.MCode.Capabilities.Usage { + t.Fatalf("mcode descriptor = %#v", got.MCode) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { + reg := agent.NewRegistry() + registerAgentKinds(reg, agentCLIDiscovery{ + MCode: proto.SupportedAgentKind{Kind: "mcode", Available: true, Version: "0.4.12", Capabilities: proto.AgentKindCapabilities{Streaming: true, Permissions: true, Resume: true}}, + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Version: "claude 2.0.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Available: true, + Version: "codex 0.141.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Available: true, + Version: "pi 0.1.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + Resume: true, + }, + }, + }, "https://parsar.example.test") + + kinds := reg.SupportedAgentKinds() + if len(kinds) != 5 { + t.Fatalf("SupportedAgentKinds len = %d, want 5: %#v", len(kinds), kinds) + } + // Sorted: claude_code, codex, mcode, opencode, pi. + if kinds[0].Kind != "claude_code" || kinds[1].Kind != "codex" || kinds[2].Kind != "mcode" || kinds[3].Kind != "opencode" || kinds[4].Kind != "pi" { + t.Fatalf("SupportedAgentKinds sort = %#v", kinds) + } + if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions { + t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) + } + if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume { + t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) + } + if kinds[3].Available || kinds[3].Version != "missing" || !kinds[3].Capabilities.Streaming || !kinds[3].Capabilities.Usage { + t.Fatalf("opencode descriptor not preserved: %#v", kinds[3]) + } + if !kinds[4].Available || kinds[4].Version != "pi 0.1.0" || !kinds[4].Capabilities.Resume || kinds[4].Capabilities.Permissions { + t.Fatalf("pi descriptor not preserved: %#v", kinds[4]) + } + if _, err := reg.Resolve("opencode"); err != nil { + t.Fatalf("opencode factory not registered: %v", err) + } + if _, err := reg.Resolve("codex"); err != nil { + t.Fatalf("codex factory not registered: %v", err) + } + if _, err := reg.Resolve("pi"); err != nil { + t.Fatalf("pi factory not registered: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/cli/logout.go b/apps/parsar-daemon/internal/cli/logout.go new file mode 100644 index 000000000..e73c8f270 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/logout.go @@ -0,0 +1,46 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// runLogout removes the credential file for a profile. Idempotent +// so CI scripts can re-run it on every step. +// +// Logout is local-only: revoking the runtime on the server is the +// admin UI's disable-runtime button (which also kicks any live WS). +func runLogout(ctx *runContext, args []string) error { + fs := newFlagSet("logout") + profile := fs.String("profile", paths.DefaultProfile, "profile name to forget") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("logout: parse flags: %w", err) + } + if err := paths.ValidateProfile(*profile); err != nil { + return fmt.Errorf("logout: %w", err) + } + + // Stat first so we emit a distinct message for "nothing to + // remove" vs. "removed". + authPath, err := paths.AuthFile(*profile) + if err != nil { + return fmt.Errorf("logout: %w", err) + } + _, statErr := os.Stat(authPath) + missing := errors.Is(statErr, os.ErrNotExist) + + if err := auth.Delete(*profile); err != nil { + return fmt.Errorf("logout: %w", err) + } + if missing { + fmt.Fprintf(ctx.stdout, "Profile %q already had no credential — nothing to forget.\n", *profile) + } else { + fmt.Fprintf(ctx.stdout, "Forgot credential for profile %q (removed %s).\n", *profile, authPath) + } + fmt.Fprintln(ctx.stdout, "Note: this only removes local state. To revoke the runtime on the server, disable it in the admin UI.") + return nil +} diff --git a/apps/parsar-daemon/internal/cli/logs.go b/apps/parsar-daemon/internal/cli/logs.go new file mode 100644 index 000000000..bd9055c60 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/logs.go @@ -0,0 +1,63 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// runLogs tails ~/.parsar/parsar-daemon//connect.log. -f streams +// new bytes; -n sets trailing-line history (default 100). Missing log +// file surfaces an actionable hint instead of a path error. +func runLogs(ctx *runContext, args []string) error { + fs := newFlagSet("logs") + var ( + profile = fs.String("profile", paths.DefaultProfile, "profile name whose log to tail") + follow = fs.Bool("f", false, "follow the log (like `tail -f`)") + lines = fs.Int("n", 100, "print the last N lines before optionally following") + ) + if err := fs.Parse(args); err != nil { + return fmt.Errorf("logs: parse flags: %w", err) + } + if err := paths.ValidateProfile(*profile); err != nil { + return fmt.Errorf("logs: %w", err) + } + + logPath, err := paths.LogFile(*profile) + if err != nil { + return fmt.Errorf("logs: %w", err) + } + if _, err := os.Stat(logPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(ctx.stderr, "parsar-daemon: no log file yet at %s\n", logPath) + fmt.Fprintln(ctx.stderr, " Start the daemon with `parsar-daemon connect -b` first.") + return fmt.Errorf("logs: log file does not exist") + } + return fmt.Errorf("logs: stat: %w", err) + } + + // Wire SIGINT so Ctrl-C exits the follow loop cleanly. + rootCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + select { + case <-sigCh: + cancel() + case <-rootCtx.Done(): + } + signal.Stop(sigCh) + }() + + return daemonize.Tail(logPath, daemonize.TailOptions{ + LastLines: *lines, + Follow: *follow, + }, rootCtx.Done(), ctx.stdout) +} diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go new file mode 100644 index 000000000..13a0b7bbe --- /dev/null +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -0,0 +1,35 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func discoverMCode(rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { + if check == nil { + check = mcode.CheckCLIAvailable + } + result := proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{Streaming: true, Permissions: true, Resume: true}} + ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + defer cancel() + version, err := check(ctx, "") + if err != nil { + fmt.Fprintf(rc.stderr, "parsar-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) + return result + } + result.Available, result.Version = true, version + if mcode.SupportsExecution(version) { + result.Capabilities.Steering = true + result.Capabilities.DurableTurns = true + result.Capabilities.DurableInputReceipts = true + result.Capabilities.ExecutionControls = true + result.Capabilities.ToolObservations = true + result.Capabilities.SubagentControl = true + result.Capabilities.EnvironmentNone = true + } + fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) + return result +} diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/parsar-daemon/internal/cli/mcode_execution_test.go new file mode 100644 index 000000000..d4197ad4b --- /dev/null +++ b/apps/parsar-daemon/internal/cli/mcode_execution_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "context" + "io" + "testing" +) + +func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { + for _, tc := range []struct { + enabled, version string + qualified bool + }{{"", "0.4.12", false}, {"1", "0.3.11", false}, {"1", "0.4.12", true}} { + t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { + t.Setenv("PARSAR_MCODE_AGENTS_API", tc.enabled) + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + info := discoverMCode(rc, func(context.Context, string) (string, error) { return tc.version, nil }) + if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified { + t.Fatalf("capabilities=%+v", info.Capabilities) + } + if info.Capabilities.NativeSessionRecovery || info.Capabilities.LocalEnvironment || info.Capabilities.FunctionTools { + t.Fatal("unqualified capability advertised") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go new file mode 100644 index 000000000..bf62d0015 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -0,0 +1,76 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { + mode := os.Getenv("PARSAR_MCODE_WORKSPACE") + if mode == "" { + return + } + fail := func(err error) { + discovery.MCode.Available = false + fmt.Fprintf(rc.stderr, "parsar-daemon: mcode workspace unavailable: %v\n", err) + } + if mode != "managed" || !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { + fail(fmt.Errorf("managed execution requires the qualified native version and opt-in")) + return + } + binding, err := localworkspace.Load() + if err != nil || binding == nil { + fail(fmt.Errorf("dedicated local Runtime binding required")) + return + } + root, err := paths.Root() + if err != nil { + fail(err) + return + } + node := os.Getenv("PARSAR_MCODE_NODE") + if node == "" { + node = "node" + } + node, err = exec.LookPath(node) + if err != nil { + fail(err) + return + } + node, err = filepath.Abs(node) + if err != nil { + fail(err) + return + } + binary, err := exec.LookPath(binpath.MCode()) + if err != nil { + fail(err) + return + } + binary, err = filepath.Abs(binary) + if err != nil { + fail(err) + return + } + c, err := mcode.ConfigureLocal(binary, node, os.Getenv("PARSAR_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkAccess(), os.Getenv("PARSAR_RUNTIME_STAGING")) + if err == nil { + err = mcode.CheckWorkspace(context.Background(), c) + } + if err != nil { + fail(err) + return + } + discovery.MCodeWorkspace = &c + caps := &discovery.MCode.Capabilities + caps.EnvironmentNone = false + caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.WorkspaceReadPreparation = true +} diff --git a/apps/parsar-daemon/internal/cli/mcp_test.go b/apps/parsar-daemon/internal/cli/mcp_test.go new file mode 100644 index 000000000..63ecb418a --- /dev/null +++ b/apps/parsar-daemon/internal/cli/mcp_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "") + checks := unavailableCLIChecks() + checks.Codex = func(context.Context, string) (string, error) { return "codex 0.153.4", nil } + discovery, err := discoverAgentCLIs(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + if err != nil { + t.Fatal(err) + } + registry := agent.NewRegistry() + registerAgentKinds(registry, discovery, "https://service.example") + for _, kind := range registry.SupportedAgentKinds() { + if kind.Capabilities.MCPHTTPBearerAuth != (kind.Kind == "codex") { + t.Fatal("bearer capability missing or advertised for another adapter") + } + if kind.Kind == "codex" && (!kind.Available || !kind.Capabilities.MCPHTTPTools || !kind.Capabilities.EnvironmentNone) { + t.Fatal("bearer capability lacks prerequisite profile") + } + } +} + +func TestRemoteMCPDiscoveryRequiresPinnedNative(t *testing.T) { + for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { + checks := unavailableCLIChecks() + checks.Codex = func(context.Context, string) (string, error) { return version, nil } + got, err := discoverAgentCLIs(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + if err != nil { + t.Fatal(err) + } + if got.Codex.Capabilities.MCPHTTPRequired != (version == "codex-cli 0.153.4") || got.Codex.Capabilities.MCPHTTPRemoteEnvironment != (version == "codex-cli 0.153.4") || got.Codex.Capabilities.MCPHTTPRemoteBearerAuth != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native combination advertised") + } + if got.Codex.Capabilities.NativeSessionRecovery != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native recovery advertised") + } + if got.ClaudeCode.Capabilities.MCPHTTPRequired || got.OpenCode.Capabilities.MCPHTTPRequired || got.Pi.Capabilities.MCPHTTPRequired || got.ClaudeCode.Capabilities.MCPHTTPRemoteEnvironment || got.OpenCode.Capabilities.MCPHTTPRemoteEnvironment || got.Pi.Capabilities.MCPHTTPRemoteEnvironment || got.ClaudeCode.Capabilities.MCPHTTPRemoteBearerAuth || got.OpenCode.Capabilities.MCPHTTPRemoteBearerAuth || got.Pi.Capabilities.MCPHTTPRemoteBearerAuth { + t.Fatal("other engine advertised combination") + } + } +} diff --git a/apps/parsar-daemon/internal/cli/pair.go b/apps/parsar-daemon/internal/cli/pair.go new file mode 100644 index 000000000..7307a983d --- /dev/null +++ b/apps/parsar-daemon/internal/cli/pair.go @@ -0,0 +1,145 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" +) + +func pairProfile(ctx context.Context, serverURL, token, deviceName string) (auth.Profile, *pairResponse, error) { + base, err := normalizeServerURL(serverURL) + if err != nil { + return auth.Profile{}, nil, err + } + + host, err := os.Hostname() + if err != nil { + // Stripped-down sandboxes can fail os.Hostname; "unknown" + // keeps the request well-formed (it's only a label). + host = "unknown" + } + if strings.TrimSpace(deviceName) == "" { + deviceName = host + } + + // Generate a fresh X25519 keypair before pairing. Public half is + // persisted server-side under runtimes.config.runner_public_key so + // SealForRuntime can encrypt payloads to this daemon; private half + // stays in auth.Profile (0o600) and is required to OpenSeal on + // receive. Every successful pair binds a brand-new pair. + pubB64, privB64, err := runtimecrypto.GenerateRuntimeKeypair() + if err != nil { + return auth.Profile{}, nil, fmt.Errorf("generate runner keypair: %w", err) + } + + pair, err := pairWithServer(ctx, base, pairRequest{ + PairingToken: token, + Hostname: host, + Version: Version, + RunnerPublicKey: pubB64, + }) + if err != nil { + return auth.Profile{}, nil, err + } + + prof := auth.Profile{ + ServerURL: base, + RuntimeID: pair.Runtime.ID, + RunnerCredential: pair.RunnerCredential, + DeviceName: deviceName, + Hostname: host, + PairedAt: time.Now().UTC(), + RunnerPublicKey: pubB64, + RunnerPrivateKey: privB64, + } + return prof, pair, nil +} + +// pairRequest mirrors the wire shape of server/internal/api/runtime. +// Re-declared here so the daemon doesn't import a server-internal +// package — keeps the wire schema as the only coupling. +type pairRequest struct { + PairingToken string `json:"pairing_token"` + Hostname string `json:"hostname"` + Version string `json:"version"` + RunnerPublicKey string `json:"runner_public_key,omitempty"` +} + +type pairRuntime struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Liveness string `json:"liveness"` +} + +type pairResponse struct { + Runtime pairRuntime `json:"runtime"` + RunnerCredential string `json:"runner_credential"` +} + +// pairWithServer issues POST /api/v1/runtimes/pair. Returns the parsed +// response on success; on non-2xx, returns an error containing the +// server's error code + message when present. +func pairWithServer(ctx context.Context, base string, req pairRequest) (*pairResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal pair request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/v1/runtimes/pair", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "parsar-daemon/"+Version) + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("post: %w", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(respBody))) + } + var out pairResponse + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode pair response: %w", err) + } + if out.Runtime.ID == "" || out.RunnerCredential == "" { + return nil, fmt.Errorf("pair response missing runtime.id or runner_credential") + } + if out.Runtime.Type != "" && out.Runtime.Type != "agent_daemon" { + // Refuse rather than take over the wrong runtime row if the + // user pasted a non-agent_daemon pairing token by accident. + return nil, fmt.Errorf("pair response runtime.type=%q, expected agent_daemon (was the token issued under the Agent Daemon tab?)", out.Runtime.Type) + } + return &out, nil +} + +// normalizeServerURL rejects junk inputs and strips a trailing slash so +// concatenating "/api/v1/..." paths never produces "//". +func normalizeServerURL(raw string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", fmt.Errorf("parse --url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("--url must use http or https (got %q)", u.Scheme) + } + if u.Host == "" { + return "", fmt.Errorf("--url is missing a host") + } + u.Path = strings.TrimRight(u.Path, "/") + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} diff --git a/apps/parsar-daemon/internal/cli/pair_test.go b/apps/parsar-daemon/internal/cli/pair_test.go new file mode 100644 index 000000000..7331d36d9 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/pair_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" +) + +// Regression for the "runner_public_key required" pair failure: the +// daemon used to never populate that field on the wire, so server-side +// pairing rejected with HTTP 400. Asserts the request carries a +// base64(stdEncoding) X25519 pubkey AND the resulting Profile retains +// the matching privkey for later OpenSeal. +func TestPairProfileGeneratesAndSendsRunnerPublicKey(t *testing.T) { + var sentPubKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/runtimes/pair" { + http.Error(w, "unexpected path", http.StatusNotFound) + return + } + var body pairRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + sentPubKey = body.RunnerPublicKey + _ = json.NewEncoder(w).Encode(pairResponse{ + Runtime: pairRuntime{ + ID: "rt_test_123", + Type: "agent_daemon", + Name: "test-device", + }, + RunnerCredential: "rc_test_secret", + }) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + prof, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") + if err != nil { + t.Fatalf("pairProfile: %v", err) + } + + // Server must have received a non-empty key. + if sentPubKey == "" { + t.Fatal("server did not receive runner_public_key on the wire") + } + // And it must be a valid X25519 pubkey. + if _, err := runtimecrypto.DecodeKey(sentPubKey); err != nil { + t.Fatalf("server received invalid pubkey %q: %v", sentPubKey, err) + } + + // Profile must carry both halves. + if prof.RunnerPublicKey != sentPubKey { + t.Errorf("Profile.RunnerPublicKey = %q, want sent %q", prof.RunnerPublicKey, sentPubKey) + } + if prof.RunnerPrivateKey == "" { + t.Fatal("Profile.RunnerPrivateKey is empty") + } + if _, err := runtimecrypto.DecodeKey(prof.RunnerPrivateKey); err != nil { + t.Errorf("Profile.RunnerPrivateKey is not a valid X25519 key: %v", err) + } + + // Sanity-check the rest of the Profile so a future refactor that + // drops one of these fields fails loudly. + if prof.RuntimeID != "rt_test_123" { + t.Errorf("RuntimeID = %q, want rt_test_123", prof.RuntimeID) + } + if prof.RunnerCredential != "rc_test_secret" { + t.Errorf("RunnerCredential = %q, want rc_test_secret", prof.RunnerCredential) + } +} + +// Pair-error pass-through must still surface a 400 when the server +// fails the pair for any reason (reused token, device limit, etc.). +func TestPairProfileSurfacesServerPairFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"pair_failed","message":"token reused"}`)) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") + if err == nil { + t.Fatal("pairProfile succeeded against a 400 server response, want error") + } +} diff --git a/apps/parsar-daemon/internal/cli/placement.go b/apps/parsar-daemon/internal/cli/placement.go new file mode 100644 index 000000000..d81271c3f --- /dev/null +++ b/apps/parsar-daemon/internal/cli/placement.go @@ -0,0 +1,69 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/placement" +) + +func runPlacement(ctx *runContext, args []string) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + fmt.Fprintln(ctx.stdout, "Usage: parsar-daemon placement enroll --container --owner --workspace [--environment ]") + fmt.Fprintln(ctx.stdout, " parsar-daemon placement retire --container [--environment ]") + fmt.Fprintln(ctx.stdout, "Explicit operator-managed local Linux/Docker only; normal harness release is unaffected.") + fmt.Fprintln(ctx.stdout, "Enrollment requires label parsar.runtime.placement= and the qualified private profile.") + fmt.Fprintln(ctx.stdout, "Scoped enrollment requires the same --environment on retirement; this is operator consent, not Core authentication.") + return nil + } + action := args[0] + if action != "enroll" && action != "retire" { + return fmt.Errorf("unknown placement action %q", action) + } + fs := newFlagSet("placement " + action) + id := fs.String("container", "", "full immutable container ID") + environment := fs.String("environment", "", "operator-confirmed Environment UUID") + var owner, workspace string + if action == "enroll" { + fs.StringVar(&owner, "owner", "", "operator-created placement label value") + fs.StringVar(&workspace, "workspace", "", "retained absolute host workspace path") + } + if err := fs.Parse(args[1:]); err != nil { + if err == flag.ErrHelp { + return runPlacement(ctx, []string{"--help"}) + } + return err + } + if fs.NArg() != 0 || *id == "" { + return fmt.Errorf("placement %s requires --container and no positional arguments", action) + } + scoped := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "environment" { + scoped = true + } + }) + controller, err := placement.New() + if err != nil { + return err + } + operation, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + var receipt *placement.Receipt + if action == "enroll" && scoped { + receipt, err = controller.EnrollEnvironment(operation, *id, owner, workspace, *environment) + } else if action == "enroll" { + receipt, err = controller.Enroll(operation, *id, owner, workspace) + } else if scoped { + receipt, err = controller.RetireEnvironment(operation, *id, *environment) + } else { + receipt, err = controller.Retire(operation, *id) + } + if err != nil { + return err + } + return json.NewEncoder(ctx.stdout).Encode(receipt) +} diff --git a/apps/parsar-daemon/internal/cli/preparation_test.go b/apps/parsar-daemon/internal/cli/preparation_test.go new file mode 100644 index 000000000..f04145749 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/preparation_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { + for _, supported := range []bool{false, true} { + reg := agent.NewRegistry() + registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: supported}}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code"}, OpenCode: proto.SupportedAgentKind{Kind: "opencode"}, Pi: proto.SupportedAgentKind{Kind: "pi"}, MCode: proto.SupportedAgentKind{Kind: "mcode"}}, "http://unreachable.invalid") + _, err := reg.ResolvePreparation("codex") + if (err == nil) != supported { + t.Fatal("unverified native version advertised preparation") + } + if !supported { + continue + } + stop := errors.New("controlled preparation stop") + reg.RegisterPreparation("codex", true, func(_ context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if req.RunID != "" || req.WorkspaceAuthoring || len(req.AgentOptions) != 0 { + t.Error("product wrapper injected preparation context") + } + return nil, stop + }) + wrapped := authoringRegistry(reg, authoring.New(nil)) + prepare, err := wrapped.ResolvePreparation("codex") + if err != nil { + t.Fatal(err) + } + if _, err := prepare(t.Context(), proto.PromptRequestPayload{AgentKind: "codex"}); !errors.Is(err, stop) { + t.Fatal("raw preparation was lost or wrapped", err) + } + for _, info := range wrapped.SupportedAgentKinds() { + if info.Kind == "codex" && (!info.Capabilities.Preparation || !info.Capabilities.WorkspaceReadPreparation) { + t.Fatal("real heartbeat registry lost preparation") + } + } + wrapped.Register("codex", func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, stop + }) + if _, err := wrapped.ResolvePreparation("codex"); err == nil { + t.Fatal("factory replacement retained stale preparation") + } + } +} diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go new file mode 100644 index 000000000..b6df78316 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/root.go @@ -0,0 +1,93 @@ +// Package cli is the parsar-daemon subcommand router. Stdlib-only flag +// dispatch — no cobra — so the produced binary stays small. +package cli + +import ( + "flag" + "fmt" + "io" + "os" +) + +// Version is the daemon's reported version. The Makefile overrides +// this via -ldflags at build time. +var Version = "0.0.0-dev" + +type command struct { + name string + summary string + run func(ctx *runContext, args []string) error +} + +// runContext bundles the streams a command writes to. Tests inject +// buffers; production uses the OS streams. +type runContext struct { + stdout io.Writer + stderr io.Writer +} + +func defaultRunContext() *runContext { + return &runContext{stdout: os.Stdout, stderr: os.Stderr} +} + +// commands lists subcommands in --help render order: the user's +// likely flow connect → status → stop / logs → logout. +var commands = []command{ + {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, + {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, + {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, + {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, + {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, + {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, + {name: "version", summary: "Print the daemon version and exit", run: runVersion}, +} + +// Execute is main.go's entry point with os.Args[1:]. +func Execute(argv []string) error { + return execute(defaultRunContext(), argv) +} + +func execute(ctx *runContext, argv []string) error { + if len(argv) == 0 || argv[0] == "-h" || argv[0] == "--help" || argv[0] == "help" { + printRootHelp(ctx.stdout) + if len(argv) == 0 { + return fmt.Errorf("missing subcommand") + } + return nil + } + name := argv[0] + for _, c := range commands { + if c.name == name { + return c.run(ctx, argv[1:]) + } + } + printRootHelp(ctx.stderr) + return fmt.Errorf("unknown subcommand %q", name) +} + +func printRootHelp(w io.Writer) { + fmt.Fprintln(w, "parsar-daemon — Parsar reverse-WebSocket agent daemon") + fmt.Fprintln(w) + fmt.Fprintln(w, "Usage: parsar-daemon [flags]") + fmt.Fprintln(w) + fmt.Fprintln(w, "Subcommands:") + for _, c := range commands { + fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary) + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Run `parsar-daemon --help` for subcommand-specific flags.") +} + +// newFlagSet returns a FlagSet that doesn't print its own usage to +// stderr on error — we surface the error via Execute's return value +// so stderr noise stays predictable for callers piping parsar-daemon. +func newFlagSet(name string) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + return fs +} + +func runVersion(ctx *runContext, _ []string) error { + fmt.Fprintln(ctx.stdout, Version) + return nil +} diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go new file mode 100644 index 000000000..42ba1caea --- /dev/null +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func runArgv(t *testing.T, argv ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + ctx := &runContext{stdout: &out, stderr: &errBuf} + err = execute(ctx, argv) + return out.String(), errBuf.String(), err +} + +func TestExecuteNoArgsPrintsHelpAndReturnsError(t *testing.T) { + stdout, _, err := runArgv(t) + if err == nil { + t.Fatal("expected error when called with no subcommand") + } + if !strings.Contains(stdout, "Subcommands:") { + t.Errorf("help output missing subcommand list:\n%s", stdout) + } +} + +func TestExecuteHelpFlagSucceeds(t *testing.T) { + for _, arg := range []string{"-h", "--help", "help"} { + stdout, _, err := runArgv(t, arg) + if err != nil { + t.Errorf("%s returned error: %v", arg, err) + } + if !strings.Contains(stdout, "parsar-daemon") { + t.Errorf("%s output missing parsar-daemon banner:\n%s", arg, stdout) + } + } +} + +func TestExecuteUnknownSubcommand(t *testing.T) { + _, _, err := runArgv(t, "definitely-not-a-command") + if err == nil { + t.Fatal("expected error for unknown subcommand") + } + if !strings.Contains(err.Error(), "unknown subcommand") { + t.Errorf("unexpected error %q", err.Error()) + } +} + +func TestVersionSubcommandPrintsVersion(t *testing.T) { + stdout, _, err := runArgv(t, "version") + if err != nil { + t.Fatalf("version: %v", err) + } + if !strings.Contains(stdout, Version) { + t.Errorf("version output = %q, missing %q", stdout, Version) + } +} + +func TestSubcommandsAreRegistered(t *testing.T) { + // Guards against dropping a subcommand off the commands slice — + // the public CLI surface is the shipped contract. + want := map[string]bool{ + "placement": false, + "connect": false, + "status": false, + "stop": false, + "logs": false, + "logout": false, + "version": false, + } + for _, c := range commands { + if _, ok := want[c.name]; !ok { + t.Errorf("unexpected subcommand registered: %q", c.name) + continue + } + want[c.name] = true + } + for name, seen := range want { + if !seen { + t.Errorf("expected subcommand %q to be registered", name) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/skill_upload.go b/apps/parsar-daemon/internal/cli/skill_upload.go new file mode 100644 index 000000000..935b35cd3 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/skill_upload.go @@ -0,0 +1,39 @@ +package cli + +import ( + "context" + "maps" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { + return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + env, _ := req.AgentOptions["env"].(map[string]any) + if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { + env = maps.Clone(env) + env["PARSAR_SERVER_URL"] = serverURL + if executable, err := os.Executable(); err == nil { + addCompanionCLIPath(env, filepath.Dir(executable)) + } + req.AgentOptions = maps.Clone(req.AgentOptions) + req.AgentOptions["env"] = env + } + return factory(ctx, req, out) + } +} + +func addCompanionCLIPath(env map[string]any, dir string) { + info, err := os.Stat(filepath.Join(dir, "parsar")) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return + } + path, ok := env["PATH"].(string) + if !ok { + path = os.Getenv("PATH") + } + env["PATH"] = dir + string(os.PathListSeparator) + path +} diff --git a/apps/parsar-daemon/internal/cli/skill_upload_test.go b/apps/parsar-daemon/internal/cli/skill_upload_test.go new file mode 100644 index 000000000..28550acb8 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/skill_upload_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "context" + "os" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSkillUploadUsesPairedAddressPerRequest(t *testing.T) { + t.Setenv("PARSAR_SERVER_URL", "unchanged-process-env") + env := map[string]any{"PARSAR_CAPABILITY_UPLOAD_TOKEN": "run-a", "PARSAR_SERVER_URL": "http://localhost:1234", "MODEL_KEY": "preserve"} + opts := map[string]any{"env": env, "model": "preserve"} + calls := 0 + factory := withSkillUploadServer(func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + calls++ + if calls == 1 { + got := req.AgentOptions["env"].(map[string]any) + if got["PARSAR_SERVER_URL"] != "http://parsar-server:8080" || got["PARSAR_CAPABILITY_UPLOAD_TOKEN"] != "run-a" || got["MODEL_KEY"] != "preserve" || req.AgentOptions["model"] != "preserve" { + t.Fatal("per-run context incorrect") + } + if _, exists := got["PARSAR_RUNNER_TOKEN"]; exists { + t.Fatal("exported device credential") + } + } else if len(req.AgentOptions) != 0 { + t.Fatal("previous run's context leaked") + } + return nil, nil + }, "http://parsar-server:8080") + if _, err := factory(t.Context(), proto.PromptRequestPayload{AgentOptions: opts}, nil); err != nil { + t.Fatal(err) + } + if _, err := factory(t.Context(), proto.PromptRequestPayload{}, nil); err != nil { + t.Fatal(err) + } + if env["PARSAR_SERVER_URL"] != "http://localhost:1234" || os.Getenv("PARSAR_SERVER_URL") != "unchanged-process-env" { + t.Fatal("mutated caller or process environment") + } +} diff --git a/apps/parsar-daemon/internal/cli/status.go b/apps/parsar-daemon/internal/cli/status.go new file mode 100644 index 000000000..677fc66b2 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/status.go @@ -0,0 +1,69 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// runStatus prints a one-screen profile summary. Deliberately omits +// runner_credential — that's the wire identity and showing it in +// shell history / CI logs would be a foot-gun. +func runStatus(ctx *runContext, args []string) error { + fs := newFlagSet("status") + profile := fs.String("profile", paths.DefaultProfile, "profile name to inspect") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("status: parse flags: %w", err) + } + if err := paths.ValidateProfile(*profile); err != nil { + return fmt.Errorf("status: %w", err) + } + + dir, err := paths.ProfileDir(*profile) + if err != nil { + return fmt.Errorf("status: %w", err) + } + fmt.Fprintf(ctx.stdout, "profile : %s\n", *profile) + fmt.Fprintf(ctx.stdout, "state dir : %s\n", dir) + + prof, err := auth.Load(*profile) + switch { + case errors.Is(err, auth.ErrNotPaired): + fmt.Fprintln(ctx.stdout, "paired : no legacy profile (use `parsar-daemon connect --url ... --token ...`)") + case err != nil: + fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) + default: + fmt.Fprintln(ctx.stdout, "paired : yes") + fmt.Fprintf(ctx.stdout, "server_url : %s\n", prof.ServerURL) + fmt.Fprintf(ctx.stdout, "runtime_id : %s\n", prof.RuntimeID) + if prof.DeviceName != "" { + fmt.Fprintf(ctx.stdout, "device_name : %s\n", prof.DeviceName) + } + if prof.Hostname != "" { + fmt.Fprintf(ctx.stdout, "hostname : %s\n", prof.Hostname) + } + if !prof.PairedAt.IsZero() { + fmt.Fprintf(ctx.stdout, "paired_at : %s\n", prof.PairedAt.Format("2006-01-02 15:04:05 MST")) + } + } + + // connect.pid existence is the cheap signal; the full liveness + // check (kill -0) would be more accurate but a bare existence + // check is honest enough for the "paired but not connected" + // diagnosis. + pidPath, err := paths.PIDFile(*profile) + if err != nil { + return fmt.Errorf("status: resolve pid path: %w", err) + } + if _, err := os.Stat(pidPath); errors.Is(err, os.ErrNotExist) { + fmt.Fprintln(ctx.stdout, "background : not started (no connect.pid)") + } else if err != nil { + fmt.Fprintf(ctx.stdout, "background : ERROR — %v\n", err) + } else { + fmt.Fprintf(ctx.stdout, "background : pidfile present at %s\n", pidPath) + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/stop.go b/apps/parsar-daemon/internal/cli/stop.go new file mode 100644 index 000000000..dc180537b --- /dev/null +++ b/apps/parsar-daemon/internal/cli/stop.go @@ -0,0 +1,56 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// runStop sends SIGTERM to the pid in ~/.parsar/parsar-daemon// +// connect.pid, escalates to SIGKILL after killTimeout, then removes +// the pidfile. Idempotent: missing / stale pidfile cleans up and +// exits 0 — the user's goal is "no background daemon" and that holds +// either way. +func runStop(ctx *runContext, args []string) error { + fs := newFlagSet("stop") + profile := fs.String("profile", paths.DefaultProfile, "profile name to stop") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("stop: parse flags: %w", err) + } + if err := paths.ValidateProfile(*profile); err != nil { + return fmt.Errorf("stop: %w", err) + } + + pidPath, err := paths.PIDFile(*profile) + if err != nil { + return fmt.Errorf("stop: %w", err) + } + + pid, err := daemonize.ReadPIDFile(pidPath) + switch { + case errors.Is(err, os.ErrNotExist): + fmt.Fprintln(ctx.stdout, "parsar-daemon: no background daemon running (no pidfile)") + return nil + case errors.Is(err, daemonize.ErrStaleOrCorrupt): + fmt.Fprintf(ctx.stdout, "parsar-daemon: stale pidfile detected (%v); removing\n", err) + if rmErr := daemonize.RemovePIDFile(pidPath); rmErr != nil { + return fmt.Errorf("stop: %w", rmErr) + } + return nil + case err != nil: + return fmt.Errorf("stop: read pidfile: %w", err) + } + + fmt.Fprintf(ctx.stdout, "parsar-daemon: sending SIGTERM to pid=%d\n", pid) + if err := daemonize.SignalAndWait(pid, killTimeout); err != nil { + return fmt.Errorf("stop: signal: %w", err) + } + if err := daemonize.RemovePIDFile(pidPath); err != nil { + return fmt.Errorf("stop: remove pidfile: %w", err) + } + fmt.Fprintln(ctx.stdout, "parsar-daemon: stopped") + return nil +} diff --git a/apps/parsar-daemon/internal/daemonize/fork.go b/apps/parsar-daemon/internal/daemonize/fork.go new file mode 100644 index 000000000..6e5529cf3 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork.go @@ -0,0 +1,125 @@ +// Package daemonize gives `parsar-daemon connect -b` a no-cgo way to +// detach from the controlling terminal on macOS + Linux. Strategy is +// re-exec-the-binary rather than POSIX double-fork: the parent opens +// connect.log + connect.pid, then starts a fresh copy of its own +// argv with stdio redirected to the log file and a sentinel env var +// set so the child skips the fork branch. Setsid puts the child into +// its own session so closing the user's shell doesn't kill the daemon. +// +// Re-exec (not raw fork) because Go's runtime is not fork-safe — the +// goroutine scheduler holds locks the child can't release without +// exec. exec.Cmd.Start does fork+exec, which is the safe combination. +package daemonize + +import ( + "errors" + "fmt" + "os" + "os/exec" + "syscall" +) + +// BackgroundSentinelEnv is set by the parent on the child's +// environment so the child knows it's the post-fork incarnation and +// must NOT itself try to re-fork. runConnect inspects via +// IsBackgroundChild. +const BackgroundSentinelEnv = "PARSAR_DAEMON_BACKGROUND_CHILD" + +// IsBackgroundChild reports whether this process was spawned by a +// `connect -b` re-exec. runConnect skips the fork branch when true, +// otherwise the child would spawn grandchildren forever. +func IsBackgroundChild() bool { + return os.Getenv(BackgroundSentinelEnv) == "1" +} + +// ReExecOptions controls how Spawn launches the background child. +type ReExecOptions struct { + // LogPath is the absolute log file path. Child stdin is + // /dev/null; stdout+stderr are appended to this file (0o600). + LogPath string + + // PIDPath is the absolute pidfile path. The parent writes the + // child's PID here before returning; existing files are replaced + // atomically. + PIDPath string + + // ExtraEnv is appended to the child's environment in addition to + // parent environ + BackgroundSentinelEnv. + ExtraEnv []string +} + +// Spawn re-execs the current binary in the background. argv is the +// new process's full argv including argv[0]. Spawn does NOT scrub +// `-b` — BackgroundSentinelEnv is what tells the child to skip the +// fork. +// +// On error the partially-opened log file is closed and a best-effort +// pidfile cleanup runs so a half-spawn doesn't leave stale state. +func Spawn(argv []string, opts ReExecOptions) (int, error) { + if len(argv) == 0 { + return 0, errors.New("daemonize.Spawn: empty argv") + } + if opts.LogPath == "" || opts.PIDPath == "" { + return 0, errors.New("daemonize.Spawn: LogPath and PIDPath required") + } + + logFile, err := os.OpenFile(opts.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return 0, fmt.Errorf("daemonize: open log %s: %w", opts.LogPath, err) + } + defer logFile.Close() // child gets its own dup via cmd.Stdout/Stderr + + devNull, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0) + if err != nil { + return 0, fmt.Errorf("daemonize: open /dev/null: %w", err) + } + defer devNull.Close() + + // Prefer the absolute path the parent was invoked with so a child + // started from `./bin/parsar-daemon` doesn't re-exec a different + // binary on PATH. + exe, err := os.Executable() + if err != nil { + // Fall back to argv[0] if /proc/self/exe or + // _NSGetExecutablePath fail. Worst case: child re-execs + // whatever's on PATH under the same name — still parsar-daemon + // in practice. + exe = argv[0] + } + + env := append([]string(nil), os.Environ()...) + env = append(env, BackgroundSentinelEnv+"=1") + env = append(env, opts.ExtraEnv...) + + cmd := exec.Command(exe, argv[1:]...) + cmd.Env = env + cmd.Stdin = devNull + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, // new session → no controlling tty + } + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("daemonize: start child: %w", err) + } + + pid := cmd.Process.Pid + + // Release rather than Wait — don't take zombie reaping duties. + // Init takes over once the parent exits. + if err := cmd.Process.Release(); err != nil { + // Non-fatal; child is already running. Log to the caller's + // chain so it shows up in stderr but doesn't fail the spawn. + fmt.Fprintf(os.Stderr, "daemonize: warning: release child: %v\n", err) + } + + if err := WritePIDFile(opts.PIDPath, pid); err != nil { + // Kill the child so we don't leave a daemon the user can't + // `stop` without ps-grepping. + _ = syscall.Kill(pid, syscall.SIGTERM) + return 0, fmt.Errorf("daemonize: write pidfile (child killed): %w", err) + } + + return pid, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/fork_test.go b/apps/parsar-daemon/internal/daemonize/fork_test.go new file mode 100644 index 000000000..c36fdeaad --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_test.go @@ -0,0 +1,95 @@ +package daemonize + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "child.log") + pidPath := filepath.Join(dir, "child.pid") + + // argv[0] is ignored (Spawn uses os.Executable()); argv[1:] + // becomes child args. Placeholder subcommand so flag parsing + // wouldn't choke — runSpawnTestChild short-circuits anyway. + pid, err := Spawn( + []string{"parsar-daemon", "child-mode"}, + ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + ExtraEnv: []string{spawnTestChildEnv + "=1"}, + }, + ) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer func() { + _ = syscall.Kill(pid, syscall.SIGTERM) + }() + + if pid <= 0 { + t.Fatalf("Spawn returned non-positive pid %d", pid) + } + + gotPID, err := ReadPIDFile(pidPath) + if err != nil { + t.Fatalf("ReadPIDFile: %v", err) + } + if gotPID != pid { + t.Fatalf("pidfile pid = %d, want %d", gotPID, pid) + } + + // Poll for log contents — child writes markers immediately but + // stdio is kernel-buffered. + deadline := time.Now().Add(3 * time.Second) + var logBody []byte + for time.Now().Before(deadline) { + logBody, err = os.ReadFile(logPath) + if err == nil && strings.Contains(string(logBody), "spawn-test-child:stdout") && + strings.Contains(string(logBody), "spawn-test-child:stderr") { + break + } + time.Sleep(50 * time.Millisecond) + } + if !strings.Contains(string(logBody), "spawn-test-child:stdout") { + t.Errorf("log missing stdout marker; got %q", string(logBody)) + } + if !strings.Contains(string(logBody), "spawn-test-child:stderr") { + t.Errorf("log missing stderr marker; got %q", string(logBody)) + } + + // SignalAndWait should now drop the child cleanly. + if err := SignalAndWait(pid, 2*time.Second); err != nil { + t.Fatalf("SignalAndWait: %v", err) + } +} + +func TestSpawnRejectsEmptyArgv(t *testing.T) { + _, err := Spawn(nil, ReExecOptions{LogPath: "/tmp/x", PIDPath: "/tmp/y"}) + if err == nil { + t.Fatalf("Spawn(nil) succeeded; want error") + } +} + +func TestSpawnRejectsMissingPaths(t *testing.T) { + _, err := Spawn([]string{"parsar-daemon"}, ReExecOptions{}) + if err == nil { + t.Fatalf("Spawn(no paths) succeeded; want error") + } +} + +func TestIsBackgroundChildHonoursEnv(t *testing.T) { + t.Setenv(BackgroundSentinelEnv, "1") + if !IsBackgroundChild() { + t.Fatalf("IsBackgroundChild = false with env=1, want true") + } + t.Setenv(BackgroundSentinelEnv, "") + if IsBackgroundChild() { + t.Fatalf("IsBackgroundChild = true with env unset, want false") + } +} diff --git a/apps/parsar-daemon/internal/daemonize/helpers_test.go b/apps/parsar-daemon/internal/daemonize/helpers_test.go new file mode 100644 index 000000000..782669286 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/helpers_test.go @@ -0,0 +1,109 @@ +package daemonize + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "strconv" + "syscall" + "testing" + "time" +) + +// spawnTestChildEnv flips the test binary into "child" mode for fork +// tests: TestMain writes a marker, blocks until SIGTERM/SIGINT, exits. +// Lets Spawn re-exec the test binary as the child. +const spawnTestChildEnv = "PARSAR_DAEMON_SPAWN_TEST_CHILD" + +func TestMain(m *testing.M) { + if os.Getenv(spawnTestChildEnv) == "1" { + runSpawnTestChild() + return + } + os.Exit(m.Run()) +} + +func runSpawnTestChild() { + fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") + fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") + + // Honour SIGTERM so the parent test can clean us up. Fallback + // deadline so a stranded child doesn't survive a crashing test. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + select { + case <-sigCh: + case <-time.After(15 * time.Second): + } + os.Exit(0) +} + +// startSleepingChild launches a child that sleeps for dur and exits. +// dur=0 → `sh -c true` for testing "signal a process that's already +// gone" paths. sh because we need a real kernel PID with /proc entry. +func startSleepingChild(t *testing.T, dur time.Duration) *exec.Cmd { + t.Helper() + var cmd *exec.Cmd + if dur <= 0 { + cmd = exec.Command("sh", "-c", "true") + } else { + // Convert to whole seconds when possible, sub-second via sleep + // arg with decimal (Linux/macOS sh both accept "sleep 0.05"). + secs := dur.Seconds() + cmd = exec.Command("sh", "-c", "sleep "+strconv.FormatFloat(secs, 'f', 3, 64)) + } + if err := cmd.Start(); err != nil { + t.Fatalf("startSleepingChild: %v", err) + } + return cmd +} + +// startTrapChild launches a child that ignores SIGTERM, forcing the +// SIGKILL escalation path. Waits for READY on stdout so a fast caller +// doesn't race the trap install. +func startTrapChild(t *testing.T) *exec.Cmd { + t.Helper() + cmd := exec.Command("sh", "-c", "trap '' TERM; echo READY; sleep 30") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("startTrapChild StdoutPipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("startTrapChild Start: %v", err) + } + // Read up to READY with a hard deadline so a broken shell can't + // hang the test. + readyCh := make(chan error, 1) + go func() { + buf := make([]byte, 16) + var got []byte + for { + n, err := stdout.Read(buf) + if n > 0 { + got = append(got, buf[:n]...) + if len(got) >= 5 && string(got[:5]) == "READY" { + readyCh <- nil + return + } + } + if err != nil { + readyCh <- err + return + } + } + }() + select { + case err := <-readyCh: + if err != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + t.Fatalf("startTrapChild waiting READY: %v", err) + } + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + t.Fatalf("startTrapChild: timed out waiting for READY") + } + return cmd +} diff --git a/apps/parsar-daemon/internal/daemonize/logfile.go b/apps/parsar-daemon/internal/daemonize/logfile.go new file mode 100644 index 000000000..09ac413e9 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/logfile.go @@ -0,0 +1,170 @@ +package daemonize + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// TailOptions configures Tail. +type TailOptions struct { + // LastLines is the number of trailing lines to print before any + // follow logic kicks in. 0 → print nothing historical, jump + // straight to follow. + LastLines int + + // Follow keeps the tail open after printing LastLines, polling + // for new bytes appended by a still-running daemon. false → + // return immediately once the historical chunk is flushed. + Follow bool + + // PollInterval is the cadence at which Tail re-stats the file + // looking for growth. Zero → 500ms. + PollInterval time.Duration +} + +// Tail prints the last opts.LastLines lines of path to w, optionally +// following the file (poll-on-stat) until ctxDone fires. Designed for +// human eyes — not a high-throughput log aggregator. +// +// Pass a nil ctxDone when Follow is false. +func Tail(path string, opts TailOptions, ctxDone <-chan struct{}, w io.Writer) error { + if path == "" { + return errors.New("daemonize.Tail: empty path") + } + if opts.PollInterval <= 0 { + opts.PollInterval = 500 * time.Millisecond + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("daemonize.Tail: open %s: %w", path, err) + } + defer f.Close() + + startOffset, err := lastLinesOffset(f, opts.LastLines) + if err != nil { + return fmt.Errorf("daemonize.Tail: seek tail: %w", err) + } + if _, err := f.Seek(startOffset, io.SeekStart); err != nil { + return fmt.Errorf("daemonize.Tail: seek: %w", err) + } + + // Stream the historical chunk through bufio for nice line-buffered + // behaviour rather than byte-by-byte writes. + if _, err := io.Copy(w, f); err != nil { + return fmt.Errorf("daemonize.Tail: read historical: %w", err) + } + + if !opts.Follow { + return nil + } + + pollTicker := time.NewTicker(opts.PollInterval) + defer pollTicker.Stop() + + for { + select { + case <-ctxDone: + return nil + case <-pollTicker.C: + if _, err := io.Copy(w, f); err != nil { + return fmt.Errorf("daemonize.Tail: follow read: %w", err) + } + } + } +} + +// lastLinesOffset returns the byte offset in f from which reading to +// EOF yields the trailing n newline-terminated lines. Returns 0 when +// n <= 0, file has fewer than n lines, or seeking back isn't usable. +func lastLinesOffset(f *os.File, n int) (int64, error) { + if n <= 0 { + // Tail from end so follow-mode only shows fresh bytes. + end, err := f.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + return end, nil + } + + stat, err := f.Stat() + if err != nil { + return 0, err + } + size := stat.Size() + if size == 0 { + return 0, nil + } + + // Read backwards in 8 KiB chunks counting newlines. Cap at 1 MiB + // so a pathological 1 GiB logfile doesn't lock up the process. + const chunk = 8 * 1024 + const maxScan = 1 * 1024 * 1024 + pos := size + newlines := 0 + buf := make([]byte, chunk) + scanned := int64(0) + for pos > 0 && scanned < maxScan { + readSize := min(int64(chunk), pos) + pos -= readSize + scanned += readSize + if _, err := f.ReadAt(buf[:readSize], pos); err != nil && !errors.Is(err, io.EOF) { + return 0, err + } + // Walk back-to-front; we want the offset just AFTER the + // (n+1)th newline so the historical print starts on a line + // boundary. + for i := readSize - 1; i >= 0; i-- { + if buf[i] != '\n' { + continue + } + newlines++ + if newlines > n { + return pos + i + 1, nil + } + } + } + return 0, nil +} + +// EnsureLogFile creates the log file (0o600) if missing so a +// `parsar-daemon logs` before the first `connect -b` gets "0 bytes" +// instead of "no such file". +func EnsureLogFile(path string) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("daemonize.EnsureLogFile: %w", err) + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("daemonize.EnsureLogFile: %w", err) + } + return f.Close() +} + +// MustWriteLine appends one line to path with 0o600 mode, adding a +// trailing newline if missing. Returns errors despite the name — +// kept short because it's used in startup hot paths. +func MustWriteLine(path string, line string) error { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + bw := bufio.NewWriter(f) + if _, err := bw.WriteString(line); err != nil { + return err + } + if len(line) == 0 || line[len(line)-1] != '\n' { + if _, err := bw.WriteString("\n"); err != nil { + return err + } + } + return bw.Flush() +} diff --git a/apps/parsar-daemon/internal/daemonize/logfile_test.go b/apps/parsar-daemon/internal/daemonize/logfile_test.go new file mode 100644 index 000000000..0c4f80187 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/logfile_test.go @@ -0,0 +1,240 @@ +package daemonize + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tail.log") + var b strings.Builder + for i := 1; i <= 10; i++ { + b.WriteString("line-") + b.WriteString(itoa(i)) + b.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + var buf bytes.Buffer + if err := Tail(path, TailOptions{LastLines: 3, Follow: false}, nil, &buf); err != nil { + t.Fatalf("Tail: %v", err) + } + got := buf.String() + want := "line-8\nline-9\nline-10\n" + if got != want { + t.Fatalf("Tail content = %q, want %q", got, want) + } +} + +func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tail.log") + if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + var buf bytes.Buffer + if err := Tail(path, TailOptions{LastLines: 0, Follow: false}, nil, &buf); err != nil { + t.Fatalf("Tail: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("Tail wrote %q with LastLines=0, want empty", buf.String()) + } +} + +func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tail.log") + if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + var buf bytes.Buffer + if err := Tail(path, TailOptions{LastLines: 1000, Follow: false}, nil, &buf); err != nil { + t.Fatalf("Tail: %v", err) + } + if buf.String() != "a\nb\nc\n" { + t.Fatalf("Tail got %q, want full file", buf.String()) + } +} + +func TestTailFollowPicksUpAppendedBytes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tail.log") + if err := os.WriteFile(path, []byte("initial\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + // Thread-safe writer so the appender goroutine's writes and the + // main goroutine's reads don't race the data race detector. + w := &safeBuf{} + done := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = Tail(path, TailOptions{ + LastLines: 100, + Follow: true, + PollInterval: 50 * time.Millisecond, + }, done, w) + }() + + // Give Tail time to flush the historical chunk and enter poll loop. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(w.String(), "initial\n") { + break + } + time.Sleep(20 * time.Millisecond) + } + + // Append fresh bytes — Tail's next poll tick should pick them up. + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("append open: %v", err) + } + if _, err := f.WriteString("follow-1\nfollow-2\n"); err != nil { + t.Fatalf("append write: %v", err) + } + _ = f.Close() + + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(w.String(), "follow-2\n") { + break + } + time.Sleep(20 * time.Millisecond) + } + + close(done) + wg.Wait() + + out := w.String() + if !strings.Contains(out, "initial\n") { + t.Errorf("missing historical chunk; got %q", out) + } + if !strings.Contains(out, "follow-1\n") || !strings.Contains(out, "follow-2\n") { + t.Errorf("missing appended bytes; got %q", out) + } +} + +func TestTailErrorsOnMissingFile(t *testing.T) { + var buf bytes.Buffer + err := Tail(filepath.Join(t.TempDir(), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) + if err == nil { + t.Fatalf("Tail on missing file succeeded") + } +} + +func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fresh.log") + if err := EnsureLogFile(path); err != nil { + t.Fatalf("EnsureLogFile: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Size() != 0 { + t.Errorf("size = %d, want 0", info.Size()) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want 0600", info.Mode().Perm()) + } +} + +func TestEnsureLogFileIdempotentOnExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exists.log") + if err := os.WriteFile(path, []byte("preexisting\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := EnsureLogFile(path); err != nil { + t.Fatalf("EnsureLogFile: %v", err) + } + body, _ := os.ReadFile(path) + if string(body) != "preexisting\n" { + t.Errorf("body = %q, want preexisting preserved", string(body)) + } +} + +func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { + // Regression: first-ever `parsar-daemon connect -b` on a host without + // ~/.parsar/parsar-daemon// used to fail with ENOENT — + // O_CREATE only creates the file leaf. + dir := t.TempDir() + path := filepath.Join(dir, "missing", "deeper", "fresh.log") + if err := EnsureLogFile(path); err != nil { + t.Fatalf("EnsureLogFile on missing parent: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("stat after EnsureLogFile: %v", err) + } +} + +func TestMustWriteLineAppendsTrailingNewline(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ml.log") + if err := MustWriteLine(path, "no-newline"); err != nil { + t.Fatalf("MustWriteLine: %v", err) + } + if err := MustWriteLine(path, "has-newline\n"); err != nil { + t.Fatalf("MustWriteLine: %v", err) + } + body, _ := os.ReadFile(path) + if string(body) != "no-newline\nhas-newline\n" { + t.Errorf("body = %q", string(body)) + } +} + +// safeBuf wraps bytes.Buffer with a mutex so concurrent reads/writes +// during Tail's poll loop don't race. +type safeBuf struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (s *safeBuf) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *safeBuf) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.String() +} + +// itoa avoids pulling strconv into this package's test deps. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/apps/parsar-daemon/internal/daemonize/pidfile.go b/apps/parsar-daemon/internal/daemonize/pidfile.go new file mode 100644 index 000000000..87d6542e8 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/pidfile.go @@ -0,0 +1,123 @@ +package daemonize + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" +) + +// ErrStaleOrCorrupt is returned by ReadPIDFile when the file exists +// but its contents don't look like a live PID. Wraps the underlying +// parse error or syscall.ESRCH for errors.Is. +var ErrStaleOrCorrupt = errors.New("daemonize: pidfile stale or corrupt") + +// ErrNotRunning is returned by IsAlive when the PID has no process. +var ErrNotRunning = errors.New("daemonize: process not running") + +// WritePIDFile writes pid to path atomically. The temp file lives in +// the same directory so the rename is on the same filesystem +// (required by os.Rename for atomicity). 0o600 matches the profile +// dir's privacy. +func WritePIDFile(path string, pid int) error { + if path == "" { + return errors.New("daemonize.WritePIDFile: empty path") + } + if pid <= 0 { + return fmt.Errorf("daemonize.WritePIDFile: invalid pid %d", pid) + } + tmp := path + ".tmp" + body := []byte(strconv.Itoa(pid) + "\n") + if err := os.WriteFile(tmp, body, 0o600); err != nil { + return fmt.Errorf("daemonize: write tmp pidfile: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("daemonize: rename pidfile: %w", err) + } + return nil +} + +// ReadPIDFile reads, parses, and liveness-checks the pidfile. +// Missing file returns os.ErrNotExist verbatim; a file whose pid has +// no live process returns ErrStaleOrCorrupt wrapping ErrNotRunning. +func ReadPIDFile(path string) (int, error) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, err + } + pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw))) + if parseErr != nil || pid <= 0 { + return 0, fmt.Errorf("%w: %q", ErrStaleOrCorrupt, strings.TrimSpace(string(raw))) + } + if err := IsAlive(pid); err != nil { + return pid, fmt.Errorf("%w: pid=%d: %w", ErrStaleOrCorrupt, pid, err) + } + return pid, nil +} + +// IsAlive returns nil if pid corresponds to a process this user can +// signal. Wraps ErrNotRunning when the process is gone; other errors +// (e.g. EPERM) returned verbatim. +func IsAlive(pid int) error { + if pid <= 0 { + return fmt.Errorf("daemonize.IsAlive: invalid pid %d", pid) + } + // signal 0 is the POSIX "does this process exist?" probe: + // nil → exists, signalable + // ESRCH → no such process + // EPERM → exists but owned by another user + if err := syscall.Kill(pid, syscall.Signal(0)); err != nil { + if errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("%w (pid=%d)", ErrNotRunning, pid) + } + return err + } + return nil +} + +// RemovePIDFile deletes the pidfile. Missing files aren't an error so +// `parsar-daemon stop` is idempotent. +func RemovePIDFile(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("daemonize: remove pidfile: %w", err) + } + return nil +} + +// SignalAndWait SIGTERMs pid, polls for exit on 100ms cadence up to +// timeout, then SIGKILLs if still alive. Caller is responsible for +// removing the pidfile after success. +func SignalAndWait(pid int, timeout time.Duration) error { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + return fmt.Errorf("daemonize: SIGTERM pid=%d: %w", pid, err) + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := IsAlive(pid); err != nil { + if errors.Is(err, ErrNotRunning) { + return nil + } + return err + } + time.Sleep(100 * time.Millisecond) + } + + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + return fmt.Errorf("daemonize: SIGKILL pid=%d: %w", pid, err) + } + // Grace window for the kernel to reap before any subsequent + // IsAlive call. + time.Sleep(50 * time.Millisecond) + return nil +} diff --git a/apps/parsar-daemon/internal/daemonize/pidfile_test.go b/apps/parsar-daemon/internal/daemonize/pidfile_test.go new file mode 100644 index 000000000..1a8906b58 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/pidfile_test.go @@ -0,0 +1,168 @@ +package daemonize + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestWriteAndReadPIDFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "connect.pid") + pid := os.Getpid() + + if err := WritePIDFile(path, pid); err != nil { + t.Fatalf("WritePIDFile: %v", err) + } + + got, err := ReadPIDFile(path) + if err != nil { + t.Fatalf("ReadPIDFile: %v", err) + } + if got != pid { + t.Fatalf("ReadPIDFile = %d, want %d", got, pid) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("pidfile mode = %o, want 0600", info.Mode().Perm()) + } +} + +func TestReadPIDFileMissingReturnsErrNotExist(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nope.pid") + _, err := ReadPIDFile(path) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("err = %v, want os.ErrNotExist", err) + } +} + +func TestReadPIDFileCorruptReturnsStaleOrCorrupt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.pid") + if err := os.WriteFile(path, []byte("not a number\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + _, err := ReadPIDFile(path) + if !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatalf("err = %v, want ErrStaleOrCorrupt", err) + } +} + +func TestReadPIDFileStaleReturnsStaleOrCorrupt(t *testing.T) { + // Pick a PID almost certainly dead. WritePIDFile rejects 0 so we + // write manually. + dir := t.TempDir() + path := filepath.Join(dir, "stale.pid") + if err := os.WriteFile(path, []byte("99999999\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { + // Skip if 99999999 happens to exist. + t.Logf("ReadPIDFile = %v (PID 99999999 may exist on this host); skipping", err) + t.SkipNow() + } +} + +func TestIsAliveTrueForOurselves(t *testing.T) { + if err := IsAlive(os.Getpid()); err != nil { + t.Fatalf("IsAlive(self) = %v, want nil", err) + } +} + +func TestIsAliveFalseForDeadPID(t *testing.T) { + err := IsAlive(99999999) + if err == nil { + t.Skip("PID 99999999 unexpectedly exists; skipping") + } + if !errors.Is(err, ErrNotRunning) { + t.Fatalf("err = %v, want ErrNotRunning", err) + } +} + +func TestRemovePIDFileIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x.pid") + if err := WritePIDFile(path, os.Getpid()); err != nil { + t.Fatalf("write: %v", err) + } + if err := RemovePIDFile(path); err != nil { + t.Fatalf("first remove: %v", err) + } + // Second remove on missing file should be a no-op. + if err := RemovePIDFile(path); err != nil { + t.Fatalf("second remove: %v", err) + } +} + +func TestSignalAndWaitKillsChild(t *testing.T) { + // Launch a sleeping subshell so we have a real PID to signal. + cmd := startSleepingChild(t, 30*time.Second) + pid := cmd.Process.Pid + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + if err := SignalAndWait(pid, 2*time.Second); err != nil { + t.Fatalf("SignalAndWait: %v", err) + } + // In production, init reaps the child so IsAlive returns + // ErrNotRunning. In tests we're the parent so the child becomes + // a zombie — Wait() reaps it. + if _, err := cmd.Process.Wait(); err != nil { + t.Fatalf("Wait after SignalAndWait: %v", err) + } + if err := IsAlive(pid); err == nil { + t.Fatalf("child still alive after SignalAndWait+Wait") + } else if !errors.Is(err, ErrNotRunning) { + t.Fatalf("IsAlive err = %v, want ErrNotRunning", err) + } +} + +func TestSignalAndWaitEscalatesToSIGKILL(t *testing.T) { + // Child traps SIGTERM and refuses to exit. 300ms timeout so the + // escalation path fires quickly. + cmd := startTrapChild(t) + pid := cmd.Process.Pid + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + start := time.Now() + if err := SignalAndWait(pid, 300*time.Millisecond); err != nil { + t.Fatalf("SignalAndWait: %v", err) + } + if d := time.Since(start); d < 250*time.Millisecond { + t.Errorf("escalation happened too early (took %s); SIGTERM grace period not honoured", d) + } + // Reap so the kernel reuses the PID. ProcessState confirms SIGKILL. + state, err := cmd.Process.Wait() + if err != nil { + t.Fatalf("Wait after escalation: %v", err) + } + ws, ok := state.Sys().(syscall.WaitStatus) + if !ok || !ws.Signaled() || ws.Signal() != syscall.SIGKILL { + t.Fatalf("child exit = %v, want killed by SIGKILL", state) + } +} + +func TestSignalAndWaitNoopOnAlreadyDead(t *testing.T) { + cmd := startSleepingChild(t, 0) + pid := cmd.Process.Pid + // Wait for child to actually be gone before signalling so we + // test the ESRCH branch rather than a race. + _, _ = cmd.Process.Wait() + + if err := SignalAndWait(pid, time.Second); err != nil { + t.Fatalf("SignalAndWait on dead pid: %v, want nil", err) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/cancellation.go b/apps/parsar-daemon/internal/dispatch/cancellation.go new file mode 100644 index 000000000..eeedf9e9f --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/cancellation.go @@ -0,0 +1,81 @@ +package dispatch + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type cancellationOutcomeProvider interface { + CancellationOutcome() proto.DonePayload +} + +func (r *Router) releaseCompletedSession(state *sessionState) error { + r.mu.Lock() + state.retain = false + r.mu.Unlock() + receiptErr := r.finishSteering(state) + err := state.session.Cancel(context.Background()) + state.ctxCancel() + return errors.Join(receiptErr, err) +} + +func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { + var request proto.PromptCancelPayload + if err := env.DecodePayload(&request); err != nil { + return err + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + state := r.sessions[env.ID] + if state != nil { + state.retain = false + } + var cancelSession func(context.Context) error + if state != nil && state.preparedHandoff != nil { + handoff := state.preparedHandoff + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + if request.DeliveryID != "" { + r.shutdownWG.Add(1) + go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) + } + r.mu.Unlock() + return nil + } + if state != nil && state.session != nil { + cancelSession = state.session.Cancel + } + r.mu.Unlock() + ack := proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"} + if state != nil && cancelSession == nil { + ack.ErrorCode = "not_ready" + } else if cancelSession != nil { + if err := cancelSession(ctx); err != nil { + r.log.WarnContext(ctx, "session.Cancel failed", "run_id", env.ID, "err", err) + ack.ErrorCode = "cancel_failed" + } else { + ack.Applied, ack.ErrorCode = true, "" + if provider, ok := state.session.(cancellationOutcomeProvider); ok { + outcome := provider.CancellationOutcome() + ack.Outcome = &outcome + } + } + state.ctxCancel() + } + return r.sendCancellationAck(ctx, env, ack) +} + +func (r *Router) sendCancellationAck(ctx context.Context, env proto.Envelope, ack proto.InteractionDecisionAckPayload) error { + if ack.DeliveryID == "" { + return nil + } + reply, err := proto.NewEnvelopeWithTrace(proto.TypeInteractionDecisionAck, env.ID, ack, env.Trace) + if err != nil { + return err + } + return r.sender.Send(ctx, reply) +} diff --git a/apps/parsar-daemon/internal/dispatch/cancellation_test.go b/apps/parsar-daemon/internal/dispatch/cancellation_test.go new file mode 100644 index 000000000..7513d979b --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/cancellation_test.go @@ -0,0 +1,124 @@ +package dispatch_test + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type cancelReceiptSession struct { + *fakeSession + entered chan struct{} + release chan struct{} + err error +} + +func (s *cancelReceiptSession) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} +} + +func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + }) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "release", proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) + <-sess.entered + if len(h.sender.snapshot()) != 0 { + t.Fatal("completion acknowledged before native writer was released") + } + close(sess.release) + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "release completion") + frames := h.sender.snapshot() + if len(frames) != 1 || frames[0].Type != proto.TypeDone || sess.cancels() != 1 { + t.Fatal("completion or native release missing") + } +} + +func (s *cancelReceiptSession) Cancel(ctx context.Context) error { + if s.entered != nil { + close(s.entered) + <-s.release + s.entered = nil + } + _ = s.fakeSession.Cancel(ctx) + return s.err +} + +func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { + for _, fails := range []bool{false, true} { + t.Run(map[bool]string{false: "applied", true: "rejected"}[fails], func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} + if fails { + sess.err = errors.New("adapter could not cancel") + } + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + }) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})) + }() + <-sess.entered + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + t.Fatal("cancellation acknowledged before adapter returned") + } + } + close(sess.release) + if err := <-done; err != nil { + t.Fatal(err) + } + found := false + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + found = true + var ack proto.InteractionDecisionAckPayload + _ = env.DecodePayload(&ack) + if ack.Applied == fails || ack.DeliveryID != "cancel-1" { + t.Fatalf("wrong receipt: %+v", ack) + } + if !fails && (ack.Outcome == nil || ack.Outcome.Metadata[proto.DoneMetaAgentSessionID] != "native-cancelled") { + t.Fatal("cancellation receipt lost native identity") + } + } + } + if !found { + t.Fatal("missing cancellation receipt") + } + }) + } +} + +func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatal(err) + } + sess := <-h.gotSess + sess.closeOutOnCancel = true + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "legacy", proto.PromptCancelPayload{})); err != nil { + t.Fatal(err) + } + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + t.Fatal("legacy cancellation emitted new receipt") + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities.go b/apps/parsar-daemon/internal/dispatch/capabilities.go new file mode 100644 index 000000000..c06c6d00e --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/capabilities.go @@ -0,0 +1,12 @@ +package dispatch + +import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + +func (r *Router) availableCapabilities(kind string) proto.AgentKindCapabilities { + for _, info := range r.registry.SupportedAgentKinds() { + if info.Kind == kind && info.Available { + return info.Capabilities + } + } + return proto.AgentKindCapabilities{} +} diff --git a/apps/parsar-daemon/internal/dispatch/environment.go b/apps/parsar-daemon/internal/dispatch/environment.go new file mode 100644 index 000000000..bcc5acfd5 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/environment.go @@ -0,0 +1,25 @@ +package dispatch + +import ( + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if req.LocalEnvironment != nil && (req.RemoteEnvironment != nil || req.DisableExecutionEnvironment || !caps.LocalEnvironment) { + return errors.New("engine does not support this local Environment configuration") + } + if req.RemoteEnvironment != nil { + if req.DisableExecutionEnvironment { + return errors.New("remote environment conflicts with execution environment none") + } + if !caps.RemoteEnvironment { + return errors.New("engine does not support a remote execution environment") + } + } + if req.DisableExecutionEnvironment && !caps.EnvironmentNone { + return errors.New("engine does not support execution environment none") + } + return validateMCPHTTP(req, caps) +} diff --git a/apps/parsar-daemon/internal/dispatch/environment_test.go b/apps/parsar-daemon/internal/dispatch/environment_test.go new file mode 100644 index 000000000..824dffd78 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/environment_test.go @@ -0,0 +1,72 @@ +package dispatch_test + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.Register("claude_code", func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "claude_code", DisableExecutionEnvironment: true})) + if err == nil || called { + t.Fatal("unsupported engine was started", err) + } + frames := h.sender.snapshot() + if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal(frames) + } +} + +func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { + for _, available := range []bool{false, true} { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, errors.New("controlled factory stop") + }) + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk", DisableExecutionEnvironment: true})) + if called != available { + t.Fatalf("factory called=%t, available=%t", called, available) + } + } +} + +func TestRemoteEnvironmentRequiresAvailableCapability(t *testing.T) { + for _, mode := range []string{"unsupported", "unavailable", "none conflict", "supported"} { + t.Run(mode, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", + Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: mode != "unsupported"}}, + func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + called = true + if req.RemoteEnvironment == nil || req.RemoteEnvironment.ID != "environment-test" { + t.Error("remote descriptor lost before factory") + } + return nil, errors.New("controlled factory stop") + }) + req := proto.PromptRequestPayload{AgentKind: "codex", RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment-test"}, DisableExecutionEnvironment: mode == "none conflict"} + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "remote", req)) + if called != (mode == "supported") { + t.Fatalf("unexpected factory call for %s", mode) + } + frames := h.sender.snapshot() + if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal("missing terminal error frames") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/export_test.go b/apps/parsar-daemon/internal/dispatch/export_test.go new file mode 100644 index 000000000..4c5e2a361 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/export_test.go @@ -0,0 +1,38 @@ +package dispatch + +func (r *Router) PreparationOwnershipForTest(handle string) (bool, bool) { + r.mu.Lock() + defer r.mu.Unlock() + p := r.preparations[handle] + return p != nil && p.owns, p != nil && p.busy +} + +// Test-only re-exports so router_test.go (package dispatch_test) can +// peek into internal state without widening the public surface. + +// AskIndexLenForTest returns the number of asks still indexed at the +// router level. Used to assert cleanup paths. +func (r *Router) AskIndexLenForTest() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.askIndex) +} + +// PendingAsksLenForTest reports how many asks the named run still has +// outstanding. Returns -1 if the run is unknown. +func (r *Router) PendingAsksLenForTest(runID string) int { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.sessions[runID] + if !ok { + return -1 + } + return len(s.pendingAsks) +} + +func (r *Router) SteeringClosedForTest(runID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + state := r.sessions[runID] + return state != nil && state.steeringClosed +} diff --git a/apps/parsar-daemon/internal/dispatch/functions.go b/apps/parsar-daemon/internal/dispatch/functions.go new file mode 100644 index 000000000..9e74393d7 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/functions.go @@ -0,0 +1,65 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) error { + var result proto.FunctionResultPayload + if err := env.DecodePayload(&result); err != nil { + return err + } + if env.ID == "" || result.CallID == "" || result.DeliveryID == "" { + return errors.New("function result requires run, call and delivery identities") + } + if err := result.ValidateContent(); err != nil { + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "invalid_result", err.Error()) + } + decision := result + decision.DeliveryID = "" + encoded, err := json.Marshal(decision) + if err != nil { + return err + } + fingerprint := sha256.Sum256(encoded) + // Scope receipt replay to both identities, even when native call IDs repeat across Runs. + kind := proto.TypeFunctionResult + "\x00" + result.CallID + if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, result.DeliveryID, kind, fingerprint); handled { + return err + } + r.mu.Lock() + state := r.sessions[env.ID] + session, finishOperation, ready := r.preparedOperationLocked(state) + var submitter agent.FunctionResultSubmitter + if ready { + submitter, _ = session.(agent.FunctionResultSubmitter) + } + r.mu.Unlock() + if state != nil && !ready { + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "not_ready", "function call is waiting for the native session") + } + if finishOperation != nil { + defer finishOperation() + var stop context.CancelFunc + ctx, stop = r.shutdownContext(ctx) + defer stop() + } + if submitter == nil { + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "not_pending", "function call is no longer pending") + } + if err := submitter.SubmitFunctionResult(ctx, result); err != nil { + code := "runtime_error" + if errors.Is(err, agent.ErrUnknownFunctionCall) { + code = "not_pending" + } + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, code, "function result was not applied") + } + r.rememberAppliedInteractionDecision(env.ID, kind, fingerprint) + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, true, "", "") +} diff --git a/apps/parsar-daemon/internal/dispatch/functions_native_test.go b/apps/parsar-daemon/internal/dispatch/functions_native_test.go new file mode 100644 index 000000000..d391a3194 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/functions_native_test.go @@ -0,0 +1,188 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type nativeFunctionSender chan proto.Envelope + +func (s nativeFunctionSender) Send(ctx context.Context, e proto.Envelope) error { + select { + case s <- e: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestNativeFunctionBridge(t *testing.T) { + root := os.Getenv("PARSAR_NATIVE_PROOF_DIR") + if root == "" { + t.Skip("explicit native Codex binary and proof directory required") + } + home, err := os.MkdirTemp(root, "daemon-functions-") + if err != nil { + t.Fatal(err) + } + t.Setenv("PARSAR_HOME", home) + var count atomic.Int32 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + n := count.Add(1) + raw, _ := json.MarshalIndent(body, "", " ") + _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("request-%d.json", n)), raw, 0600) + var item map[string]any + if n%2 == 1 { + if !strings.Contains(string(raw), "lookup_ticket") { + t.Error("tool was not registered") + } + item = map[string]any{"id": fmt.Sprintf("fc_%d", n), "type": "function_call", "call_id": fmt.Sprintf("call_%d", n), "name": "lookup_ticket", "arguments": `{"ticket":"42"}`, "status": "completed"} + } else { + + var request struct { + Input []struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output json.RawMessage `json:"output"` + } `json:"input"` + } + if err := json.Unmarshal(raw, &request); err != nil { + t.Error(err) + } + found := false + for _, entry := range request.Input { + if entry.Type != "function_call_output" || entry.CallID != fmt.Sprintf("call_%d", n-1) { + continue + } + found = true + var parts []proto.FunctionResultContent + if err := json.Unmarshal(entry.Output, &parts); err != nil { + t.Error(err) + continue + } + expected := functionResultContent("TICKET-RESULT") + if !reflect.DeepEqual(parts, expected) { + t.Errorf("native result lost text/image content or order: %s", entry.Output) + } + } + if !found { + t.Error("native model did not receive function result") + } + item = map[string]any{"id": fmt.Sprintf("msg_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "FUNCTION-OK", "annotations": []any{}}}} + } + w.Header().Set("Content-Type", "text/event-stream") + send := func(kind string, data map[string]any) { + data["type"] = kind + b, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, b) + w.(http.Flusher).Flush() + } + send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "status": "in_progress", "output": []any{}}}) + send("response.output_item.added", map[string]any{"output_index": 0, "item": item}) + send("response.output_item.done", map[string]any{"output_index": 0, "item": item}) + send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": "gpt-5.5", "output": []any{item}}}) + })) + defer model.Close() + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{FunctionTools: true, EnvironmentNone: true}}, codex.Factory) + sender := make(nativeFunctionSender, 256) + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + await := func(kind string) proto.Envelope { + t.Helper() + for { + select { + case env := <-sender: + if env.Type == proto.TypeError { + t.Fatalf("native error: %s", env.Payload) + } + if env.Type == kind { + return env + } + case <-ctx.Done(): + t.Fatalf("waiting for %s; evidence %s", kind, home) + } + } + } + nativeID := "" + for index := 0; index < 3; index++ { + run := fmt.Sprintf("run-%d", index) + request := proto.PromptRequestPayload{AgentKind: "codex", Prompt: "Look up ticket 42.", RunID: run, AgentStateKey: "native-functions", AgentSessionID: nativeID, StrictResume: true, ReleaseOnCompletion: true, DisableExecutionEnvironment: true, ObserveTools: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}, + AgentOptions: map[string]any{"model": "gpt-5.5", "codex_provider": map[string]any{"base_url": model.URL + "/v1", "bearer_token": "synthetic-local-token"}}} + env, _ := proto.NewEnvelope(proto.TypePromptRequest, run, request) + if err := router.Handle(ctx, env); err != nil { + t.Fatal(err) + } + call := await(proto.TypeFunctionCall) + var payload proto.FunctionCallPayload + if err := call.DecodePayload(&payload); err != nil || call.ID != run || payload.Name != "lookup_ticket" { + t.Fatal(call, err) + } + if index == 2 { + cancelFrame, _ := proto.NewEnvelope(proto.TypePromptCancel, run, proto.PromptCancelPayload{DeliveryID: "cancel"}) + if err := router.Handle(ctx, cancelFrame); err != nil { + t.Fatal(err) + } + ack := await(proto.TypeInteractionDecisionAck) + var receipt proto.InteractionDecisionAckPayload + _ = ack.DecodePayload(&receipt) + if !receipt.Applied { + t.Fatal(receipt) + } + late, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: true, Content: functionResultContent("late"), DeliveryID: "late"}) + if err := router.Handle(ctx, late); err != nil { + t.Fatal(err) + } + _ = await(proto.TypeInteractionDecisionAck).DecodePayload(&receipt) + if receipt.Applied || receipt.ErrorCode != "not_pending" { + t.Fatal(receipt) + } + break + } + result, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: index == 0, Content: functionResultContent("TICKET-RESULT"), DeliveryID: "result"}) + if err := router.Handle(ctx, result); err != nil { + t.Fatal(err) + } + ack := await(proto.TypeInteractionDecisionAck) + var receipt proto.InteractionDecisionAckPayload + _ = ack.DecodePayload(&receipt) + if !receipt.Applied { + t.Fatal(receipt) + } + done := await(proto.TypeDone) + var output proto.DonePayload + _ = done.DecodePayload(&output) + id, _ := output.Metadata[proto.DoneMetaAgentSessionID].(string) + if output.Content != "FUNCTION-OK" || id == "" || (nativeID != "" && id != nativeID) { + t.Fatal(output) + } + nativeID = id + } + t.Logf("Native function success/failure, fresh-process resume, cancellation and late-result rejection passed; evidence %s", home) +} diff --git a/apps/parsar-daemon/internal/dispatch/functions_test.go b/apps/parsar-daemon/internal/dispatch/functions_test.go new file mode 100644 index 000000000..ef840965a --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/functions_test.go @@ -0,0 +1,161 @@ +package dispatch_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type functionSession struct { + *fakeSession + mu sync.Mutex + calls int +} + +func (s *functionSession) SubmitFunctionResult(_ context.Context, p proto.FunctionResultPayload) error { + s.mu.Lock() + defer s.mu.Unlock() + if p.CallID != "call" || s.calls != 0 { + return agent.ErrUnknownFunctionCall + } + s.calls++ + return nil +} +func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { + reg := agent.NewRegistry() + sender := &recSender{} + sessions := map[string]*functionSession{} + reg.RegisterKind(proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: proto.AgentKindCapabilities{FunctionTools: true}}, func(ctx context.Context, p proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + s := &functionSession{fakeSession: &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}} + sessions[p.RunID] = s + return s, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + for _, id := range []string{"one", "two"} { + env, _ := proto.NewEnvelope(proto.TypePromptRequest, id, proto.PromptRequestPayload{AgentKind: "function-test", Prompt: "lookup", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { + t.Helper() + env, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: call, Success: true, Content: functionResultContent(text), DeliveryID: delivery}) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + last := frames[len(frames)-1] + var ack proto.InteractionDecisionAckPayload + if err := last.DecodePayload(&ack); err != nil || last.Type != proto.TypeInteractionDecisionAck || last.ID != run || ack.DeliveryID != delivery { + t.Fatal(last, err) + } + return ack + } + if a := submit("missing", "call", "answer", "a"); a.Applied || a.ErrorCode != "not_pending" { + t.Fatal(a) + } + if a := submit("one", "missing", "answer", "b"); a.Applied || a.ErrorCode != "not_pending" { + t.Fatal(a) + } + + invalid, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", DeliveryID: "invalid", Content: []proto.FunctionResultContent{{Type: "input_audio"}}}) + if err := router.Handle(t.Context(), invalid); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + var invalidAck proto.InteractionDecisionAckPayload + _ = frames[len(frames)-1].DecodePayload(&invalidAck) + if invalidAck.Applied || invalidAck.ErrorCode != "invalid_result" { + t.Fatal(invalidAck) + } + // A lost receipt may be retried without writing the native result twice. + sender.mu.Lock() + sender.failNow = true + sender.mu.Unlock() + first, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "lost"}) + if err := router.Handle(t.Context(), first); err == nil { + t.Fatal("receipt send failure was hidden") + } + if a := submit("one", "call", "answer", "retry"); !a.Applied { + t.Fatal(a) + } + if a := submit("one", "call", "changed", "conflict"); a.Applied || a.ErrorCode != "decision_conflict" { + t.Fatal(a) + } + + for _, mutation := range []string{"image", "order", "success"} { + result := proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: mutation} + switch mutation { + case "image": + other := "https://example.com/other.png" + result.Content[1].ImageURL = &other + case "order": + result.Content[0], result.Content[1] = result.Content[1], result.Content[0] + case "success": + result.Success = false + } + env, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", result) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + var ack proto.InteractionDecisionAckPayload + _ = frames[len(frames)-1].DecodePayload(&ack) + if ack.Applied || ack.ErrorCode != "decision_conflict" { + t.Fatal(mutation, ack) + } + } + if a := submit("two", "call", "second answer", "other-run"); !a.Applied { + t.Fatal(a) + } + for _, s := range sessions { + s.mu.Lock() + count := s.calls + s.mu.Unlock() + if count != 1 { + t.Fatal(count) + } + } +} + +func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { + reg := agent.NewRegistry() + called := false + reg.Register("unsupported", func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + sender := &recSender{} + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + defer router.Shutdown(context.Background()) + env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + if err := router.Handle(t.Context(), env); err == nil || called { + t.Fatal("unsupported engine silently ignored tools", err) + } +} + +func functionResultContent(text string) []proto.FunctionResultContent { + picture := image.NewRGBA(image.Rect(0, 0, 1, 1)) + picture.Set(0, 0, color.RGBA{R: 255, A: 255}) + var encoded bytes.Buffer + if err := png.Encode(&encoded, picture); err != nil { + panic(err) + } + imageURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()) + after := "AFTER-IMAGE" + return []proto.FunctionResultContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &imageURL}, {Type: "input_text", Text: &after}} +} diff --git a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go new file mode 100644 index 000000000..c4ead6f33 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go @@ -0,0 +1,319 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (r *Router) handlePermissionDecision(ctx context.Context, env proto.Envelope) error { + if env.ID == "" { + return errors.New("dispatch: permission_decision missing perm id (Envelope.ID empty)") + } + var payload proto.PermissionDecisionPayload + if err := env.DecodePayload(&payload); err != nil { + return fmt.Errorf("dispatch: decode permission_decision: %w", err) + } + if payload.DeliveryID == "" { + return errors.New("dispatch: permission_decision missing delivery_id") + } + fingerprint, err := interactionDecisionFingerprint(payload) + if err != nil { + return fmt.Errorf("dispatch: fingerprint permission_decision: %w", err) + } + if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, payload.DeliveryID, proto.TypePermissionDecision, fingerprint); handled { + return err + } + + r.mu.Lock() + runID, known := r.permIndex[env.ID] + var state *sessionState + var session agent.Session + var finishOperation func() + if known { + state = r.sessions[runID] + if state != nil { + session, finishOperation, _ = r.preparedOperationLocked(state) + } + } + r.mu.Unlock() + + if !known || state == nil { + // Server's perm timeout / cancel race; common enough that info + // is right. + r.log.InfoContext(ctx, "permission_decision for unknown perm (run gone)", "perm_id", env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", "permission request is no longer pending") + } + if session == nil { + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_ready", "permission request is waiting for the native session") + } + defer finishOperation() + ctx, stop := r.shutdownContext(ctx) + defer stop() + + responder, supported := session.(agent.PermissionResponder) + if !supported { + r.dropPermission(state, env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "unsupported", "runtime does not support permission responses") + } + if err := responder.SubmitPermission(ctx, env.ID, payload); err != nil { + if errors.Is(err, agent.ErrUnknownPermission) { + r.log.InfoContext(ctx, "agent reports unknown perm (race with cancel)", "perm_id", env.ID, "run_id", runID) + r.dropPermission(state, env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", err.Error()) + } + r.log.WarnContext(ctx, "agent rejected permission decision", "perm_id", env.ID, "run_id", runID, "err", err) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "runtime_error", err.Error()) + } + r.dropPermission(state, env.ID) + r.rememberAppliedInteractionDecision(env.ID, proto.TypePermissionDecision, fingerprint) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, true, "", "") +} + +func (r *Router) dropPermission(s *sessionState, permissionID string) { + r.mu.Lock() + delete(r.permIndex, permissionID) + delete(s.pendingIDs, permissionID) + r.mu.Unlock() +} + +// handlePromptForUserChoiceDecision is the ask-side twin of +// handlePermissionDecision. The server forwards the human's answer +// here; we look up the owning session via askIndex and ask the agent +// to write a matching tool_result back into its CLI. +// +// On both success and ErrUnknownAsk we drop the ask from askIndex / +// pendingAsks so a stale retry can't waste cycles. Timer-fired cancels +// inside the session don't currently call back into the router, so +// those entries linger until cleanupSession — acceptable because they +// can't double-fire (the session's own pendingAskTable.Take already +// guards that); cleanupSession removes the routing entry when the run +// stream closes, even if the underlying CLI remains in the idle pool. +func (r *Router) handlePromptForUserChoiceDecision(ctx context.Context, env proto.Envelope) error { + if env.ID == "" { + return errors.New("dispatch: prompt_for_user_choice_decision missing ask id (Envelope.ID empty)") + } + var payload proto.PromptForUserChoiceDecisionPayload + if err := env.DecodePayload(&payload); err != nil { + return fmt.Errorf("dispatch: decode prompt_for_user_choice_decision: %w", err) + } + if payload.DeliveryID == "" { + return errors.New("dispatch: prompt_for_user_choice_decision missing delivery_id") + } + fingerprint, err := interactionDecisionFingerprint(payload) + if err != nil { + return fmt.Errorf("dispatch: fingerprint prompt_for_user_choice_decision: %w", err) + } + if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, payload.DeliveryID, proto.TypePromptForUserChoiceDecision, fingerprint); handled { + return err + } + + r.mu.Lock() + runID, known := r.askIndex[env.ID] + var state *sessionState + var session agent.Session + var finishOperation func() + if known { + state = r.sessions[runID] + if state != nil { + session, finishOperation, _ = r.preparedOperationLocked(state) + } + } + r.mu.Unlock() + + if !known || state == nil { + r.log.InfoContext(ctx, "prompt_for_user_choice_decision for unknown ask (run gone)", "ask_id", env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", "user-input request is no longer pending") + } + if session == nil { + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_ready", "user-input request is waiting for the native session") + } + defer finishOperation() + ctx, stop := r.shutdownContext(ctx) + defer stop() + + responder, supported := session.(agent.UserChoiceResponder) + if !supported { + r.dropAsk(state, env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "unsupported", "runtime does not support user-choice responses") + } + err = responder.SubmitPromptForUserChoice(ctx, env.ID, payload) + if err != nil { + if errors.Is(err, agent.ErrUnknownAsk) { + r.log.InfoContext(ctx, "agent reports unknown ask (race with cancel)", "ask_id", env.ID, "run_id", runID) + r.dropAsk(state, env.ID) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", err.Error()) + } + // Keep the routing entry for transient runtime failures. Codex, for + // example, restores its pending request when a JSON-RPC reply write + // fails, so dropping the ask here would turn a retryable error into a + // permanent not_pending response on the next attempt. + r.log.WarnContext(ctx, "agent rejected user-input decision", "ask_id", env.ID, "run_id", runID, "err", err) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "runtime_error", err.Error()) + } + r.dropAsk(state, env.ID) + r.rememberAppliedInteractionDecision(env.ID, proto.TypePromptForUserChoiceDecision, fingerprint) + return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, true, "", "") +} + +func (r *Router) replayAppliedInteractionDecision(ctx context.Context, requestID, deliveryID, kind string, fingerprint [32]byte) (bool, error) { + key := appliedInteractionDecisionKey(requestID, kind) + r.mu.Lock() + applied, ok := r.applied[key] + r.mu.Unlock() + if !ok { + return false, nil + } + if applied.requestID != requestID || applied.kind != kind || applied.fingerprint != fingerprint { + return true, r.sendInteractionDecisionAck(ctx, requestID, deliveryID, false, "decision_conflict", "request was already applied with a different decision") + } + return true, r.sendInteractionDecisionAck(ctx, requestID, deliveryID, true, "", "") +} + +func (r *Router) rememberAppliedInteractionDecision(requestID, kind string, fingerprint [32]byte) { + now := time.Now().UTC() + key := appliedInteractionDecisionKey(requestID, kind) + r.mu.Lock() + if len(r.applied) >= 1024 { + cutoff := now.Add(-time.Hour) + for id, entry := range r.applied { + if entry.recordedAt.Before(cutoff) { + delete(r.applied, id) + } + } + } + if len(r.applied) >= 1024 { + for id := range r.applied { + delete(r.applied, id) + break + } + } + r.applied[key] = appliedInteractionDecision{ + requestID: requestID, kind: kind, fingerprint: fingerprint, recordedAt: now, + } + r.mu.Unlock() +} + +func appliedInteractionDecisionKey(requestID, kind string) string { + return kind + "\x00" + requestID +} + +// interactionDecisionFingerprint excludes the transport delivery id. Each +// retry gets a fresh delivery id so a late ack cannot satisfy a newer waiter, +// while the request plus decision content remains stable for daemon replay. +func interactionDecisionFingerprint(payload any) ([32]byte, error) { + switch decision := payload.(type) { + case proto.PermissionDecisionPayload: + decision.DeliveryID = "" + encoded, err := json.Marshal(decision) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(encoded), nil + case proto.PromptForUserChoiceDecisionPayload: + decision.DeliveryID = "" + encoded, err := json.Marshal(decision) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(encoded), nil + default: + return [32]byte{}, fmt.Errorf("unsupported interaction decision %T", payload) + } +} + +func (r *Router) sendInteractionDecisionAck(ctx context.Context, requestID, deliveryID string, applied bool, errorCode, message string) error { + env, err := proto.NewEnvelope(proto.TypeInteractionDecisionAck, requestID, proto.InteractionDecisionAckPayload{ + DeliveryID: deliveryID, + Applied: applied, + ErrorCode: errorCode, + Error: message, + }) + if err != nil { + return fmt.Errorf("dispatch: build interaction decision ack: %w", err) + } + if err := r.sender.Send(ctx, env); err != nil { + return fmt.Errorf("dispatch: send interaction decision ack: %w", err) + } + return nil +} + +// dropAsk clears askID from both the router-level askIndex and the +// session's pendingAsks set. Safe to call with an askID that's already +// gone — both deletes are no-ops then. +func (r *Router) dropAsk(s *sessionState, askID string) { + r.mu.Lock() + delete(r.askIndex, askID) + delete(s.pendingAsks, askID) + r.mu.Unlock() +} + +// indexPermissionFrame records interaction identities before forwarding them. +// Prepared output may arrive before successful Session publication; decisions +// remain retryable until state.session becomes available. +func (r *Router) indexPermissionFrame(s *sessionState, env proto.Envelope) { + switch env.Type { + case proto.TypePermissionRequest: + var p proto.PermissionRequestPayload + if err := env.DecodePayload(&p); err != nil { + return + } + requestID := strings.TrimSpace(p.RequestID) + if requestID == "" { + requestID = strings.TrimSpace(env.ID) + } + if requestID == "" { + return + } + r.mu.Lock() + if r.interactionRouteOpenLocked(s) { + r.permIndex[requestID] = s.runID + s.pendingIDs[requestID] = struct{}{} + } + r.mu.Unlock() + case proto.TypePermissionCancel: + if env.ID == "" { + return + } + r.mu.Lock() + delete(r.permIndex, env.ID) + delete(s.pendingIDs, env.ID) + r.mu.Unlock() + case proto.TypePromptForUserChoice: + // env.ID is the run id (so the server-side dispatch can fan + // this frame to the run's subscriber); the ask id rides on + // the payload. Decode just enough to seed the index. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err != nil || p.AskID == "" { + return + } + r.mu.Lock() + if r.interactionRouteOpenLocked(s) { + r.askIndex[p.AskID] = s.runID + s.pendingAsks[p.AskID] = struct{}{} + } + r.mu.Unlock() + } +} + +func (r *Router) clearInteractionRoutesLocked(s *sessionState) { + for permissionID := range s.pendingIDs { + delete(r.permIndex, permissionID) + delete(s.pendingIDs, permissionID) + } + for askID := range s.pendingAsks { + delete(r.askIndex, askID) + delete(s.pendingAsks, askID) + } +} + +// drain consumes everything left on ch until the agent closes it. +// Events are dropped — by the time we're draining, either transport +// is dead or the router is shutting down. diff --git a/apps/parsar-daemon/internal/dispatch/local_directory.go b/apps/parsar-daemon/internal/dispatch/local_directory.go new file mode 100644 index 000000000..d9abde09b --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/local_directory.go @@ -0,0 +1,20 @@ +package dispatch + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type localDirectoryPreparation struct{} + +func prepareLocalDirectory(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + return localDirectoryPreparation{}, nil +} + +func (localDirectoryPreparation) Start(context.Context, string, string, chan<- proto.Envelope) (agent.Session, error) { + return nil, agent.ErrWorkspaceReadUnsupported +} + +func (localDirectoryPreparation) Close() error { return nil } diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go new file mode 100644 index 000000000..b4827e506 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -0,0 +1,72 @@ +package dispatch_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { + workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n"), 0o700); err != nil { + t.Fatal(err) + } + environment, session := uuid.NewString(), uuid.NewString() + binding, err := localworkspace.New(environment, session, workspace, helper) + if err != nil { + t.Fatal(err) + } + var harnessCalls atomic.Int32 + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + harnessCalls.Add(1) + return nil, errors.New("must not start a model") + }) + reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + harnessCalls.Add(1) + return nil, errors.New("must not prepare a harness") + }) + sender := &recSender{} + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, LocalWorkspace: binding}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "idle", "ready", "") + read := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Operation: "directory", MaxEntries: 10} + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "list", read)) + if got := waitWorkspaceRead(t, sender, "list"); got.Outcome != "completed" || got.Directory == nil { + t.Fatal("idle directory unavailable", got) + } + read.EnvironmentID = uuid.NewString() + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", read)) + if got := waitWorkspaceRead(t, sender, "foreign"); got.Outcome != "rejected" { + t.Fatal("foreign directory accepted", got) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "forbidden", Prompt: "work"})); err == nil { + t.Fatal("read preparation admitted execution") + } + bad := request + bad.AgentStateKey = "agents-api-" + uuid.NewString() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "wrong-session", proto.ExecutionPreparePayload{Configuration: bad})); err == nil { + t.Fatal("wrong Session accepted") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "idle", proto.ExecutionReleasePayload{Handle: ready.Handle})) + waitPreparationStatus(t, sender, "idle", "released", "") + if harnessCalls.Load() != 0 || r.ActiveRuns() != 0 { + t.Fatal("read-only operation reached native execution") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http.go b/apps/parsar-daemon/internal/dispatch/mcp_http.go new file mode 100644 index 000000000..5d7548075 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/mcp_http.go @@ -0,0 +1,44 @@ +package dispatch + +import ( + "errors" + "net/url" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Validate combined placement and authentication before factory selection. +func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if req.MCPHTTPServers == nil { + return nil + } + if req.RemoteEnvironment != nil && (!caps.MCPHTTPTools || !caps.MCPHTTPRemoteEnvironment) { + return errors.New("engine does not support service-side HTTP MCP with a remote environment") + } + for _, server := range *req.MCPHTTPServers { + if server.Required && (!caps.MCPHTTPTools || !caps.MCPHTTPRequired || req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil)) { + return errors.New("engine does not support required service-side HTTP MCP initialization") + } + if server.BearerToken == nil { + continue + } + if !caps.MCPHTTPTools || !caps.MCPHTTPBearerAuth { + return errors.New("engine does not support authenticated HTTP MCP") + } + if req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { + return errors.New("authenticated HTTP MCP requires a supported service-side environment") + } + if req.RemoteEnvironment != nil { + if !caps.RemoteEnvironment || !caps.MCPHTTPRemoteBearerAuth { + return errors.New("engine does not support authenticated HTTP MCP with a remote environment") + } + } else if !caps.EnvironmentNone { + return errors.New("engine does not support authenticated HTTP MCP with environment:none") + } + endpoint, err := url.Parse(server.ServerURL) + if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" { + return errors.New("authenticated HTTP MCP requires HTTPS") + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go new file mode 100644 index 000000000..a5a1177db --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -0,0 +1,166 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { + for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "claude remote", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "remote", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { + t.Run(mode, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + token := "synthetic-private-token" + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} + caps := proto.AgentKindCapabilities{EnvironmentNone: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true} + switch mode { + case "claude", "claude old peer", "claude local", "claude remote": + req.AgentKind = "claude_sdk" + caps.MCPHTTPBearerAuth = mode != "claude old peer" + if mode == "claude local" || mode == "claude remote" { + req.DisableExecutionEnvironment = false + } + if mode == "claude remote" { + req.RemoteEnvironment = &proto.RemoteEnvironment{ID: "remote"} + caps.RemoteEnvironment, caps.MCPHTTPRemoteEnvironment, caps.MCPHTTPRemoteBearerAuth = true, true, true + } + case "required", "required old peer", "optional old peer": + servers[0].Required = mode != "optional old peer" + servers[0].BearerToken = nil + caps.MCPHTTPRequired = mode == "required" + case "no bearer capability", "credential-free", "product": + caps.MCPHTTPBearerAuth = false + case "no MCP capability": + caps.MCPHTTPTools = false + case "no none capability": + caps.EnvironmentNone = false + case "local": + req.DisableExecutionEnvironment = false + case "remote": + req.DisableExecutionEnvironment = false + req.RemoteEnvironment = &proto.RemoteEnvironment{ID: "remote"} + caps.RemoteEnvironment = true + case "other engine": + req.AgentKind = "other" + case "HTTP": + servers[0].ServerURL = "http://tools.example/mcp" + } + if mode == "credential-free" { + servers[0].BearerToken = nil + } + if mode == "product" { + req.MCPHTTPServers, req.DisableExecutionEnvironment = nil, false + } + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, + func(_ context.Context, got proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + called = true + if mode == "required" && !(*got.MCPHTTPServers)[0].Required { + t.Error("required initialization lost before adapter") + } + if (mode == "supported" || mode == "claude") && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != token) { + t.Error("token lost before adapter") + } + return nil, errors.New("controlled factory stop") + }) + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) + if called != (mode == "supported" || mode == "claude" || mode == "claude remote" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { + t.Fatal("wrong factory admission") + } + frames := h.sender.snapshot() + raw, _ := json.Marshal(frames) + if err == nil || strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) || len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal("terminal rejection missing or exposed credential") + } + }) + } +} + +func TestRemoteMCPRejectsBeforePreparationFactory(t *testing.T) { + for _, mode := range []string{"supported", "old peer", "no MCP", "no remote", "bearer old peer", "bearer supported", "bearer no general auth", "bearer none conflict", "bearer HTTP", "other engine", "empty declaration", "no declaration", "required", "required old peer"} { + t.Run(mode, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} + req := preparationRequest() + req.Configuration.AgentKind = "codex" + req.Configuration.MCPHTTPServers = &servers + caps := proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, EnvironmentNone: true, MCPHTTPBearerAuth: true} + switch mode { + case "required", "required old peer": + servers[0].Required = true + caps.MCPHTTPRequired = mode == "required" + case "old peer": + caps.MCPHTTPRemoteEnvironment = false + case "no MCP": + caps.MCPHTTPTools = false + case "no remote": + caps.RemoteEnvironment = false + case "other engine": + req.Configuration.AgentKind = "other" + case "bearer old peer", "bearer supported", "bearer no general auth", "bearer none conflict", "bearer HTTP": + token := "synthetic-private-token" + servers[0].BearerToken = &token + caps.MCPHTTPRemoteBearerAuth = mode != "bearer old peer" + caps.EnvironmentNone = false + if mode == "bearer no general auth" { + caps.MCPHTTPBearerAuth = false + } + if mode == "bearer none conflict" { + req.Configuration.DisableExecutionEnvironment = true + } + if mode == "bearer HTTP" { + servers[0].ServerURL = "http://tools.example/mcp" + } + case "empty declaration": + servers = []proto.MCPHTTPServer{} + caps.MCPHTTPRemoteEnvironment = false + case "no declaration": + req.Configuration.MCPHTTPServers = nil + caps.MCPHTTPRemoteEnvironment = false + } + entered := make(chan struct{}, 1) + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.Configuration.AgentKind, Available: true, Capabilities: caps}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + t.Error("ordinary factory called") + return nil, errors.New("unexpected") + }) + h.reg.RegisterPreparation(req.Configuration.AgentKind, false, func(_ context.Context, got proto.PromptRequestPayload) (agent.Prepared, error) { + if mode == "required" && !(*got.MCPHTTPServers)[0].Required { + t.Error("required initialization lost before preparation") + } + if mode == "bearer supported" && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != "synthetic-private-token") { + t.Error("remote bearer lost before preparation") + } + entered <- struct{}{} + return nil, errors.New("controlled stop") + }) + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "remote-mcp", req)) + allowed := mode == "supported" || mode == "other engine" || mode == "no declaration" || mode == "bearer supported" || mode == "required" + if (err == nil) != allowed { + t.Fatal("wrong preparation admission", err) + } + if allowed { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("factory not called") + } + waitPreparationStatus(t, h.sender, "remote-mcp", "failed", "") + } else { + select { + case <-entered: + t.Fatal("rejected request reached factory") + default: + } + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/optional_interactions_test.go b/apps/parsar-daemon/internal/dispatch/optional_interactions_test.go new file mode 100644 index 000000000..8cd80565d --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/optional_interactions_test.go @@ -0,0 +1,46 @@ +package dispatch_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Wrapping only Cancel proves that no responder stubs are required for a Session. +type lifecycleOnly struct{ cancel func(context.Context) error } + +func (s lifecycleOnly) Cancel(ctx context.Context) error { return s.cancel(ctx) } + +func TestOptionalInteractionResponders(t *testing.T) { + for _, ask := range []bool{false, true} { + t.Run(map[bool]string{false: "permission", true: "user choice"}[ask], func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + var output chan<- proto.Envelope + h.reg.Register("minimal", func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + output = out + s := &fakeSession{out: out, closeOutOnCancel: true} + return lifecycleOnly{cancel: s.Cancel}, nil + }) + if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "minimal"})); err != nil { + t.Fatal(err) + } + event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) + decision := mustEnv(t, proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) + if ask { + event = mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "interaction"}) + decision = mustEnv(t, proto.TypePromptForUserChoiceDecision, "interaction", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "decision"}) + } + // An inconsistent adapter emitted an interaction it cannot answer: reject it, + // never acknowledge application or call a fabricated responder. + output <- event + waitFor(t, func() bool { return len(h.sender.snapshot()) > 0 }, "interaction indexed") + if err := h.router.Handle(t.Context(), decision); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, h.sender, "decision", false, "unsupported") + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/output.go b/apps/parsar-daemon/internal/dispatch/output.go new file mode 100644 index 000000000..f619ff08b --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/output.go @@ -0,0 +1,103 @@ +package dispatch + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// pump forwards every Envelope the session writes onto out to the +// upstream sender, then cleans up when the session closes out. +func (r *Router) pump(s *sessionState) { + defer r.shutdownWG.Done() + defer r.cleanupSession(s) + _ = r.forwardSessionOutput(s, true) +} + +// wait is false only after a failed Start and its cancellation have returned: +// no Session owns the output sink, so only already queued frames can be read. +func (r *Router) forwardSessionOutput(s *sessionState, wait bool) error { + // Logging-only ctx carrying the run's trace; sends use their own + // ctx tied to shutdownCh. + pumpCtx := context.Background() + if s.traceparent != "" { + if carrier, err := obslog.ParseTraceparent(s.traceparent); err == nil { + pumpCtx = obslog.WithTrace(pumpCtx, carrier) + } + } + r.log.InfoContext(pumpCtx, "pump: started", "run_id", s.runID) + + // Long-lived send ctx — must keep forwarding even after the + // session's ctx is cancelled (session might emit a final "done" + // in response to cancel). Stops on out close or router shutdown. + for { + if !wait && len(s.out) == 0 { + return nil + } + select { + case env, ok := <-s.out: + if !ok { + r.log.InfoContext(pumpCtx, "pump: out channel closed", "run_id", s.runID) + return nil + } + if s.session != nil { + r.indexPermissionFrame(s, env) + } + if env.Type == proto.TypeDone && s.releaseOnCompletion { + if err := r.releaseCompletedSession(s); err != nil { + sendCtx, stop := r.shutdownContext(pumpCtx) + r.emitTerminalError(sendCtx, s.runID, "failed to release completed executor") + stop() + continue + } + } + r.log.InfoContext(pumpCtx, "pump: forwarding envelope", "run_id", s.runID, "type", env.Type, "env_id", env.ID) + // Stamp the run's trace onto outbound frames so the + // gateway attributes daemon-emitted lines to the same + // trace_id as the original prompt_request. + if env.Trace == "" && s.traceparent != "" { + env.Trace = s.traceparent + } + // Short-ish send ctx that respects router shutdown — if + // the transport is wedged we don't want to block forever. + sendCtx, cancel := context.WithCancel(context.Background()) + stopOnShutdown := make(chan struct{}) + go func() { + select { + case <-r.shutdownCh: + cancel() + case <-stopOnShutdown: + } + }() + err := r.sender.Send(sendCtx, env) + close(stopOnShutdown) + cancel() + if err != nil { + // Sender failed — log, ask the session to wind down, + // but KEEP draining out so the agent's goroutines + // don't block on a full channel. + r.log.ErrorContext(pumpCtx, "send envelope failed", "type", env.Type, "run_id", env.ID, "err", err) + r.mu.Lock() + s.retain = false + r.mu.Unlock() + s.ctxCancel() + if wait { + r.drain(s.out) + } + return err + } + case <-r.shutdownCh: + // Router shutdown — cancel + drain so the session's + // goroutines unblock and close out cleanly. + r.mu.Lock() + s.retain = false + r.mu.Unlock() + s.ctxCancel() + if wait { + r.drain(s.out) + } + return ErrRouterClosed + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go new file mode 100644 index 000000000..fa34fd04a --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation.go @@ -0,0 +1,294 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const preparationCapacity = 4 +const preparationRecords = 64 + +// All mutable fields are protected by Router.mu. owns includes resources whose +// cancellation is underway; a slow close cannot bypass the capacity bound. +type preparationState struct { + requestID string + trace string + fingerprint [32]byte + startFingerprint [32]byte + status proto.PreparationStatusPayload + deadline time.Time + timer *time.Timer + ctx context.Context + cancel context.CancelFunc + prepared agent.Prepared + stateKey string + environmentID string + busy bool + owns bool + closeErr error + workspaceReadOnly bool + handoff *preparedHandoff +} + +func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) error { + var input proto.ExecutionPreparePayload + if env.DecodePayload(&input) != nil || strings.TrimSpace(env.ID) == "" { + return r.rejectPreparation(env, "invalid_request") + } + req := input.Configuration + caps := r.availableCapabilities(req.AgentKind) + prepare, err := r.registry.ResolvePreparation(req.AgentKind) + if err != nil || !caps.Preparation { + return r.rejectPreparation(env, "unsupported_preparation") + } + if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation || !proto.ValidWorkspaceReadPreparation(req)) { + return r.rejectPreparation(env, "unsupported_read_preparation") + } + if req, err = r.localWorkspace.Configure(req); err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + if req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.WorkspaceAuthoring || len(req.Attachments) != 0 || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { + return r.rejectPreparation(env, "invalid_configuration") + } + if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools) { + return r.rejectPreparation(env, "unsupported_configuration") + } + if req.LocalEnvironment != nil && req.WorkspaceReadOnly { + prepare = prepareLocalDirectory + } + encoded, err := json.Marshal(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + fingerprint := sha256.Sum256(encoded) + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if (r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") + } + r.prunePreparationsLocked() + if old := r.preparationRequests[env.ID]; old != nil { + status := old.status + matches := old.fingerprint == fingerprint + r.mu.Unlock() + if !matches { + return r.rejectPreparation(env, "request_conflict") + } + r.publishPreparation(old, status) + return nil + } + owned := 0 + for _, p := range r.preparations { + if p.owns { + owned++ + } + } + if owned >= preparationCapacity || len(r.preparations) >= preparationRecords { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) + p := &preparationState{requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} + r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.prepareExecution(p, req, prepare) + return nil +} + +func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { + defer r.shutdownWG.Done() + if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } + var prepared agent.Prepared + var err error + if p.ctx.Err() == nil { + prepared, err = prepare(p.ctx, req) + } else { + err = p.ctx.Err() + } + if err == nil && prepared != nil && !p.workspaceReadOnly { + if _, ok := prepared.(agent.PreparedCancellation); !ok { + err = errors.New("executable preparation requires cross-transfer cancellation") + } + } + r.mu.Lock() + p.busy = false + p.prepared = prepared + ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed + if ready { + p.status.State, p.status.Revision = "ready", p.status.Revision+1 + } else if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + status := p.status + if !ready { + p.busy = true + p.cancel() + p.timer.Stop() + } + r.mu.Unlock() + if !ready { + r.closePreparationResource(p) + if p.workspaceReadOnly { + return + } + r.mu.Lock() + status = p.status + r.mu.Unlock() + } + if !r.sendPreparation(p.requestID, p.trace, status) && ready { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } +} + +func (r *Router) handleExecutionRelease(_ context.Context, env proto.Envelope) error { + var input proto.ExecutionReleasePayload + if env.DecodePayload(&input) != nil || input.Handle == "" { + return r.rejectPreparation(env, "invalid_release") + } + r.mu.Lock() + p := r.preparations[input.Handle] + valid := p != nil && p.requestID == env.ID + r.mu.Unlock() + if !valid { + return r.rejectPreparation(env, "unknown_preparation") + } + r.releasePreparation(p, "released", "", true, true) + return nil +} + +func (r *Router) releasePreparation(p *preparationState, state, code string, publish, retryHandoff bool) { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return + } + if p.handoff != nil { + if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { + if state == "expired" && p.handoff.published { + r.mu.Unlock() + return + } + switch p.status.State { + case "preparing", "ready", "starting", "started": + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.timer.Stop() + } + r.claimPreparedReleaseLocked(active, true, "", retryHandoff) + status := p.status + r.mu.Unlock() + if publish { + r.publishPreparation(p, status) + } + return + } + } + closeResource := false + switch p.status.State { + case "preparing", "ready", "starting": + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.cancel() + p.timer.Stop() + } + if p.owns && !p.busy { + if p.workspaceReadOnly && state == "released" && p.status.ErrorCode == "cleanup_unconfirmed" { + p.status.State, p.status.ErrorCode, p.status.Revision = state, "", p.status.Revision+1 + } + p.busy = true + closeResource = true + r.shutdownWG.Add(1) + } + status := p.status + settled := !p.owns && !p.busy + r.mu.Unlock() + if closeResource { + go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() + } + if publish && (!p.workspaceReadOnly || settled) { + r.publishPreparation(p, status) + } +} + +func (r *Router) prunePreparationsLocked() { + var oldest *preparationState + for handle, p := range r.preparations { + if p.owns { + continue + } + if time.Now().After(p.deadline) { + delete(r.preparations, handle) + delete(r.preparationRequests, p.requestID) + } else if oldest == nil || p.deadline.Before(oldest.deadline) { + oldest = p + } + } + if len(r.preparations) >= preparationRecords && oldest != nil { + delete(r.preparations, oldest.status.Handle) + delete(r.preparationRequests, oldest.requestID) + } +} + +func (r *Router) publishPreparation(p *preparationState, status proto.PreparationStatusPayload) { + r.mu.Lock() + if p.workspaceReadOnly { + if status.Revision != p.status.Revision || (p.owns && (p.busy || status.State == "released" || status.State == "expired") && status.State != "preparing" && status.State != "ready") { + r.mu.Unlock() + return + } + } + if r.closed { + r.mu.Unlock() + return + } + r.shutdownWG.Add(1) + r.mu.Unlock() + go func() { + defer r.shutdownWG.Done() + // A failed terminal notification must not restart incomplete cleanup. + if !r.sendPreparation(p.requestID, p.trace, status) && (!p.workspaceReadOnly || status.State == "preparing" || status.State == "ready") { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } + }() +} + +func (r *Router) sendPreparation(requestID, trace string, status proto.PreparationStatusPayload) bool { + return r.sendPreparationUntil(requestID, trace, status, time.Now().Add(5*time.Second)) +} + +func (r *Router) sendPreparationUntil(requestID, trace string, status proto.PreparationStatusPayload, deadline time.Time) bool { + ctx, stop := r.shutdownContext(context.Background()) + defer stop() + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + env, err := proto.NewEnvelopeWithTrace(proto.TypePreparationStatus, requestID, status, trace) + return err == nil && r.sender.Send(ctx, env) == nil +} + +func (r *Router) rejectPreparation(env proto.Envelope, code string) error { + r.mu.Lock() + if !r.closed { + r.shutdownWG.Add(1) + go func() { + defer r.shutdownWG.Done() + r.sendPreparation(env.ID, env.Trace, proto.PreparationStatusPayload{State: "rejected", ErrorCode: code, Operation: env.Type}) + }() + } + r.mu.Unlock() + return errors.New("dispatch: " + code) +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel.go b/apps/parsar-daemon/internal/dispatch/preparation_cancel.go new file mode 100644 index 000000000..95cb3a23f --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_cancel.go @@ -0,0 +1,56 @@ +package dispatch + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const preparedCancelTimeout = 10 * time.Second + +func (r *Router) sendPreparedCancellation(state *sessionState, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt, env proto.Envelope, deliveryID string) { + defer r.shutdownWG.Done() + timer := time.NewTimer(preparedCancelTimeout) + defer timer.Stop() + ack := proto.InteractionDecisionAckPayload{ErrorCode: "cancel_timeout"} + select { + case <-attempt.done: + r.mu.Lock() + releaseErr := attempt.err + r.mu.Unlock() + if releaseErr != nil { + ack.ErrorCode = "cancel_failed" + break + } + select { + case <-release.settled: + r.mu.Lock() + outputErr, interrupted, outcome := handoff.outputErr, handoff.shutdownInterrupted, release.outcome + r.mu.Unlock() + switch { + case outputErr != nil || interrupted: + ack.ErrorCode = "cancel_output_unavailable" + case outcome == nil: + ack.ErrorCode = "cancel_outcome_unavailable" + default: + ack.Applied, ack.ErrorCode, ack.Outcome = true, "", outcome + } + case <-timer.C: + case <-r.shutdownCh: + return + } + case <-timer.C: + // The caller deadline never changes release ownership or capacity. + case <-r.shutdownCh: + return + } + ack.DeliveryID = deliveryID + ctx, stop := r.shutdownContext(context.Background()) + defer stop() + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := r.sendCancellationAck(ctx, env, ack); err != nil { + r.log.WarnContext(ctx, "prepared cancellation receipt failed", "run_id", state.runID, "err", err) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go new file mode 100644 index 000000000..e9e6f6271 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go @@ -0,0 +1,339 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type cancellationPreparation struct { + *controlledPreparation + cancel func(context.Context) error + outcome proto.DonePayload + calls atomic.Int32 +} + +func (p *cancellationPreparation) Cancel(ctx context.Context) error { + p.calls.Add(1) + return p.cancel(ctx) +} + +func (p *cancellationPreparation) CancellationOutcome() proto.DonePayload { return p.outcome } + +type nonCancellablePreparation struct { + closed chan struct{} + once sync.Once +} + +func (*nonCancellablePreparation) Start(context.Context, string, string, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("must not start") +} + +func (p *nonCancellablePreparation) Close() error { + p.once.Do(func() { close(p.closed) }) + return nil +} + +func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recSender) proto.PreparationStatusPayload { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "request", "ready", "") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "input"})); err != nil { + t.Fatal(err) + } + return ready +} + +func cancellationAcks(sender *recSender) []proto.InteractionDecisionAckPayload { + var acks []proto.InteractionDecisionAckPayload + for _, frame := range sender.snapshot() { + if frame.Type == proto.TypeInteractionDecisionAck && frame.ID == "run" { + var ack proto.InteractionDecisionAckPayload + _ = frame.DecodePayload(&ack) + acks = append(acks, ack) + } + } + return acks +} + +type cancellationOutputSender struct { + *recSender + entered, release chan struct{} + fail bool +} + +func (s cancellationOutputSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypeUsage { + close(s.entered) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + if s.fail { + return errors.New("controlled output delivery failure") + } + } + return s.recSender.Send(ctx, env) +} + +func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { + sender := cancellationOutputSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + startEntered, startReturn := make(chan struct{}), make(chan struct{}) + cancelEntered := make(chan struct{}) + cleanupEntered, cleanupReturn := make(chan struct{}), make(chan struct{}) + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, outcome: proto.DonePayload{ + Content: "observed", Usage: proto.Usage{Tokens: &proto.TokenUsage{InputTokens: 7, OutputTokens: 3, TotalTokens: 10}}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "observed-native"}, + }} + var session *fakeSession + p.start = func(_ context.Context, id, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true, + postCancelEnvelopes: []proto.Envelope{mustEnv(t, proto.TypeDone, id, p.outcome)}} + out <- mustEnv(t, proto.TypeDelta, id, proto.DeltaPayload{Delta: "observed"}) + out <- mustEnv(t, proto.TypePermissionRequest, id, proto.PermissionRequestPayload{RequestID: "permission"}) + out <- mustEnv(t, proto.TypePromptForUserChoice, id, proto.PromptForUserChoicePayload{AskID: "ask"}) + out <- mustEnv(t, proto.TypeUsage, id, proto.UsagePayload{Usage: p.outcome.Usage}) + close(startEntered) + <-startReturn + return session, nil + } + p.cancel = func(ctx context.Context) error { + close(cancelEntered) + if err := session.Cancel(ctx); err != nil { + return err + } + close(cleanupEntered) + <-cleanupReturn + return nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender.recSender) + <-startEntered + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + <-cancelEntered + if len(cancellationAcks(sender.recSender)) != 0 { + t.Fatal("receipt preceded Start handoff") + } + close(startReturn) + <-sender.entered + if len(cancellationAcks(sender.recSender)) != 0 { + t.Fatal("receipt preceded output delivery") + } + // Another receipt may wait, but it must not repeat native cancellation. + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "second-delivery"})) + close(sender.release) + <-cleanupEntered + if len(cancellationAcks(sender.recSender)) != 0 || r.ActiveRuns() != 1 { + t.Fatal("cleanup released ownership early") + } + for _, decision := range []proto.Envelope{ + mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: "permission-reply", Approved: true}), + mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "ask-reply", Answers: []string{"yes"}}), + } { + if err := r.Handle(t.Context(), decision); err != nil { + t.Fatal(err) + } + } + assertDecisionAck(t, sender.recSender, "permission-reply", false, "not_pending") + assertDecisionAck(t, sender.recSender, "ask-reply", false, "not_pending") + close(cleanupReturn) + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 && r.ActiveRuns() == 0 }, "prepared cancellation receipts") + if p.calls.Load() != 1 || session.cancels() != 1 { + t.Fatal("native cancellation was repeated") + } + for _, ack := range cancellationAcks(sender.recSender) { + if !ack.Applied || ack.ErrorCode != "" || ack.Outcome == nil || !reflect.DeepEqual(*ack.Outcome, p.outcome) { + t.Fatalf("observed outcome lost: %+v", ack) + } + } + got := sender.typesFor("run") + want := []string{proto.TypeDelta, proto.TypePermissionRequest, proto.TypePromptForUserChoice, proto.TypeUsage, proto.TypeDone, proto.TypeInteractionDecisionAck, proto.TypeInteractionDecisionAck} + if !reflect.DeepEqual(got, want) { + t.Fatalf("output/receipt order = %v", got) + } +} + +func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) { + for _, boundary := range []string{"start_failure", "release", "expiry", "shutdown"} { + t.Run(boundary, func(t *testing.T) { + sender := &recSender{} + entered, cancelled, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var cancelOnce sync.Once + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.cancel = func(context.Context) error { + cancelOnce.Do(func() { close(cancelled) }) + return p.Close() + } + p.start = func(_ context.Context, _, _ string, _ chan<- proto.Envelope) (agent.Session, error) { + close(entered) + <-cancelled + <-allowReturn + return nil, context.Canceled + } + timeout := time.Minute + if boundary == "expiry" { + timeout = 100 * time.Millisecond + } + r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + ready := startCancellationPreparation(t, r, sender) + <-entered + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + var shutdown chan error + switch boundary { + case "release": + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) + waitPreparationStatus(t, sender, "request", "released", "") + case "expiry": + waitPreparationStatus(t, sender, "request", "expired", "") + case "shutdown": + shutdown = make(chan error, 1) + go func() { shutdown <- r.Shutdown(t.Context()) }() + } + close(allowReturn) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "failed Start cleanup") + if shutdown != nil { + if err := <-shutdown; err != nil { + t.Fatal(err) + } + } else { + waitFor(t, func() bool { return len(cancellationAcks(sender)) == 1 }, "unstarted cancellation receipt") + ack := cancellationAcks(sender)[0] + if !ack.Applied || ack.Outcome == nil || !reflect.DeepEqual(*ack.Outcome, proto.DonePayload{}) { + t.Fatalf("unknown outcome was invented or unavailable: %+v", ack) + } + } + waitPreparationClosed(t, p.controlledPreparation) + if p.calls.Load() != 1 || p.starts.Load() != 1 { + t.Fatal("cancellation replayed work") + } + }) + } +} + +func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { + for _, failure := range []string{"cancel_failed", "cancel_output_unavailable"} { + t.Run(failure, func(t *testing.T) { + sender := cancellationOutputSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{}), fail: failure == "cancel_output_unavailable"} + close(sender.release) + entered, allowReturn := make(chan struct{}), make(chan struct{}) + var session *fakeSession + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, id, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + out <- mustEnv(t, proto.TypeUsage, id, proto.UsagePayload{Usage: proto.Usage{InputTokens: 3}}) + close(entered) + <-allowReturn + return session, errors.New("controlled late Start failure") + } + p.cancel = func(ctx context.Context) error { + if failure == "cancel_failed" && p.calls.Load() == 1 { + return errors.New("controlled native failure") + } + return session.Cancel(ctx) + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender.recSender) + <-entered + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + close(allowReturn) + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed cancellation") + ack := cancellationAcks(sender.recSender)[0] + if ack.Applied || ack.Outcome != nil || ack.ErrorCode != failure { + t.Fatalf("uncertain cancellation accepted: %+v", ack) + } + if failure == "cancel_failed" { + if r.ActiveRuns() != 1 || session.cancels() != 0 { + t.Fatal("failed attempt released ownership or used a fallback target") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})) + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 && r.ActiveRuns() == 0 }, "same-target cancellation retry") + if retry := cancellationAcks(sender.recSender)[1]; !retry.Applied || retry.ErrorCode != "" || session.cancels() != 1 || p.calls.Load() != 2 { + t.Fatalf("same target was not retried exactly once: ack=%+v calls=%d session=%d", retry, p.calls.Load(), session.cancels()) + } + } else if r.ActiveRuns() != 0 || session.cancels() != 1 { + t.Fatal("successful cleanup did not settle output failure") + } + }) + } +} + +func TestExecutablePreparationRequiresCrossTransferCancellation(t *testing.T) { + sender := &recSender{} + p := &nonCancellablePreparation{closed: make(chan struct{})} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, sender, "request", "failed", "") + select { + case <-p.closed: + case <-time.After(time.Second): + t.Fatal("unsupported executable preparation was not closed") + } + for _, frame := range sender.snapshot() { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "ready" { + t.Fatal("unsupported executable preparation became ready") + } + } +} + +func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) { + sender := &recSender{} + entered, allowReturn := make(chan struct{}), make(chan struct{}) + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.cancel = func(context.Context) error { return p.Close() } + p.start = func(ctx context.Context, _, _ string, _ chan<- proto.Envelope) (agent.Session, error) { + close(entered) + <-allowReturn + return nil, ctx.Err() + } + var count atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + if count.Add(1) == 1 { + return p, nil + } + return &controlledPreparation{closed: make(chan struct{})}, nil + }) + startCancellationPreparation(t, r, sender) + <-entered + for i := 0; i < 3; i++ { + id := fmt.Sprint(i) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) + waitPreparationStatus(t, sender, id, "ready", "") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + deadline := time.Now().Add(12 * time.Second) + for len(cancellationAcks(sender)) == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + acks := cancellationAcks(sender) + if len(acks) != 1 || acks[0].Applied || acks[0].Outcome != nil || acks[0].ErrorCode != "cancel_timeout" || r.ActiveRuns() != 1 { + t.Fatal("timeout claimed settlement or lost ownership", acks) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", preparationRequest())); err == nil { + t.Fatal("timed-out cancellation returned capacity early") + } + close(allowReturn) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "late cancelled Start cleanup") + if len(cancellationAcks(sender)) != 1 { + t.Fatal("late settlement emitted a second receipt") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest())); err != nil { + t.Fatal("completed cleanup retained capacity", err) + } + waitPreparationStatus(t, sender, "replacement", "ready", "") +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go new file mode 100644 index 000000000..2c3cd176b --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go @@ -0,0 +1,53 @@ +package dispatch + +func (r *Router) closePreparationResource(p *preparationState) error { + // Only the operation that owns busy calls this; other paths cancel its owner. + var err error + if p.prepared != nil { + err = p.prepared.Close() + } + r.mu.Lock() + p.busy, p.closeErr = false, err + if err == nil { + p.prepared, p.owns = nil, false + } + if p.workspaceReadOnly { + p.status.Revision++ + if err != nil { + p.status.State, p.status.ErrorCode = "failed", "cleanup_unconfirmed" + } + } + status := p.status + r.mu.Unlock() + if p.workspaceReadOnly { + r.publishPreparation(p, status) + } + if err != nil { + r.log.Warn("preparation cleanup incomplete", "handle", p.status.Handle) + } + return err +} + +func (r *Router) closePendingPreparationsLocked() []*preparationState { + var closeNow []*preparationState + for _, p := range r.preparations { + p.timer.Stop() + if !p.owns { + continue + } + if p.handoff != nil { + continue + } + p.cancel() + switch p.status.State { + case "preparing", "ready", "starting": + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 + } + if !p.busy { + p.busy = true + r.shutdownWG.Add(1) + closeNow = append(closeNow, p) + } + } + return closeNow +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go new file mode 100644 index 000000000..515bbc197 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go @@ -0,0 +1,289 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type retryablePreparation struct { + controlledPreparation + calls atomic.Int32 + close func(int32) error +} + +func (p *retryablePreparation) Close() error { return p.close(p.calls.Add(1)) } + +type cancellingRetryablePreparation struct{ *retryablePreparation } + +func (p *cancellingRetryablePreparation) Cancel(context.Context) error { return p.Close() } +func (p *cancellingRetryablePreparation) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{} +} + +func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { + entered, cancelled := make(chan struct{}), make(chan struct{}) + p := &cancellingRetryablePreparation{&retryablePreparation{close: func(call int32) error { + if call == 1 { + return errors.New("cleanup incomplete") + } + close(cancelled) + return nil + }}} + p.start = func(_ context.Context, _, _ string, _ chan<- proto.Envelope) (agent.Session, error) { + close(entered) + <-cancelled + return nil, context.Canceled + } + sender := &recSender{} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + ready := startCancellationPreparation(t, r, sender) + <-entered + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + waitFor(t, func() bool { return len(cancellationAcks(sender)) == 1 }, "failed cleanup receipt") + ack := cancellationAcks(sender)[0] + if ack.Applied || ack.ErrorCode != "cancel_failed" || ack.Outcome != nil { + t.Fatalf("cleanup failure reported as applied: %+v", ack) + } + if owned, _ := r.PreparationOwnershipForTest(ready.Handle); !owned { + t.Fatal("cancel receipt discarded unsettled preparation") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) + waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(ready.Handle); return !owned }, "retained cancellation cleanup") +} + +func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { + want := errors.New("prepared cleanup incomplete") + sender := &recSender{} + var session *fakeSession + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return session, nil + } + p.cancel = func(ctx context.Context) error { + if p.calls.Load() == 1 { + return want + } + return session.Cancel(ctx) + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender) + waitPreparationStatus(t, sender, "request", "started", "") + if err := r.Shutdown(t.Context()); !errors.Is(err, want) { + t.Fatalf("first shutdown lost native failure: %v", err) + } + if r.ActiveRuns() != 1 || p.calls.Load() != 1 || session.cancels() != 0 { + t.Fatal("failed shutdown released ownership or changed release target") + } + select { + case <-p.closed: + t.Fatal("failed prepared cancellation fell back to Close") + default: + } + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + if r.ActiveRuns() != 0 || p.calls.Load() != 2 || session.cancels() != 1 { + t.Fatalf("shutdown did not retry the same target once: active=%d target=%d session=%d", r.ActiveRuns(), p.calls.Load(), session.cancels()) + } +} + +func TestPreparationCloseFailureRetainsCapacityAndRetries(t *testing.T) { + sender := &recSender{} + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + p := &retryablePreparation{close: func(call int32) error { + if call == 1 { + return errors.New("cleanup incomplete") + } + if call == 2 { + close(entered) + <-release + } + return nil + }} + var factories atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + if factories.Add(1) == 1 { + return p, nil + } + return &controlledPreparation{closed: make(chan struct{})}, nil + }) + t.Cleanup(unblock) + var handle string + for i := range 4 { + id := fmt.Sprint(i) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) + ready := waitPreparationStatus(t, sender, id, "ready", "") + if i == 0 { + handle = ready.Handle + } + } + request := mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: handle}) + _ = r.Handle(t.Context(), request) + waitFor(t, func() bool { _, busy := r.PreparationOwnershipForTest(handle); return p.calls.Load() == 1 && !busy }, "failed cleanup return") + if owned, _ := r.PreparationOwnershipForTest(handle); !owned { + t.Fatal("failed cleanup discarded ownership") + } + replacement := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) + if err := r.Handle(t.Context(), replacement); err == nil { + t.Fatal("failed cleanup released capacity") + } + start := proto.ExecutionStartPayload{Handle: handle, RunID: "run", Prompt: "do not execute"} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", start)); err == nil { + t.Fatal("failed cleanup allowed Start") + } + _ = r.Handle(t.Context(), request) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("release did not retry retained resource") + } + _ = r.Handle(t.Context(), request) + if p.calls.Load() != 2 { + t.Fatal("release started overlapping cleanup") + } + unblock() + waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(handle); return !owned }, "successful cleanup") + if err := r.Handle(t.Context(), replacement); err != nil { + t.Fatalf("settled cleanup retained capacity: %v", err) + } + waitPreparationStatus(t, sender, "replacement", "ready", "") + if p.starts.Load() != 0 { + t.Fatal("cleanup restarted native execution") + } +} + +func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { + want := errors.New("native cleanup incomplete") + p := &retryablePreparation{close: func(call int32) error { + if call == 1 { + return want + } + return nil + }} + sender := &recSender{} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + handle := waitPreparationStatus(t, sender, "request", "ready", "").Handle + if err := r.Shutdown(t.Context()); !errors.Is(err, want) { + t.Fatalf("shutdown lost cleanup error: %v", err) + } + if owned, _ := r.PreparationOwnershipForTest(handle); !owned { + t.Fatal("shutdown discarded unsettled resource") + } + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + if owned, _ := r.PreparationOwnershipForTest(handle); owned || p.calls.Load() != 2 { + t.Fatalf("retry did not settle original resource: owned=%t calls=%d", owned, p.calls.Load()) + } +} + +func TestShutdownTimeoutAndConcurrentRetryWaitForCleanup(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + p := &retryablePreparation{close: func(int32) error { close(entered); <-release; return nil }} + sender := &recSender{} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + t.Cleanup(unblock) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + waitPreparationStatus(t, sender, "request", "ready", "") + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + if err := r.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("shutdown while cleanup blocked: %v", err) + } + <-entered + results := make(chan error, 4) + for range cap(results) { + go func() { results <- r.Shutdown(t.Context()) }() + } + select { + case err := <-results: + t.Fatalf("retry returned before cleanup: %v", err) + case <-time.After(50 * time.Millisecond): + } + unblock() + for range cap(results) { + select { + case err := <-results: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("shutdown wait did not finish") + } + } + if p.calls.Load() != 1 { + t.Fatal("shutdown repeated in-flight cleanup") + } +} + +func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { + sender := &recSender{} + session := &fakeSession{closeOutOnCancel: true} + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + return session, nil + } + p.cancel = func(ctx context.Context) error { + if p.calls.Load() == 1 { + return errors.New("cleanup incomplete") + } + return session.Cancel(ctx) + } + var factories atomic.Int32 + r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + if factories.Add(1) == 1 { + return p, nil + } + return &controlledPreparation{closed: make(chan struct{})}, nil + }) + ready := startCancellationPreparation(t, r, sender) + waitPreparationStatus(t, sender, "request", "started", "") + waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(ready.Handle); return !owned }, "publication transfer") + time.Sleep(time.Until(time.UnixMilli(ready.ExpiresAt)) + 20*time.Millisecond) + if p.calls.Load() != 0 { + t.Fatal("preparation expiry cancelled a published Run") + } + // A later admission retires the expired preparation record. The Run still + // owns the exact cancellation target independently of that old handle. + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest())); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, sender, "replacement", "ready", "") + for i := range 2 { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: fmt.Sprint("cancel-", i)})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return len(cancellationAcks(sender)) == i+1 }, "Run cancellation receipt") + if i == 0 { + if ack := cancellationAcks(sender)[0]; ack.Applied || ack.ErrorCode != "cancel_failed" || r.ActiveRuns() != 1 { + t.Fatalf("failed cleanup lost retained Run: %+v", ack) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})); err == nil { + t.Fatal("retired preparation handle was restored") + } + } + } + if p.calls.Load() != 2 || session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatal("Run cancellation did not retry and settle the same target") + } + select { + case <-p.closed: + t.Fatal("Run cancellation fell back to preparation Close") + default: + } +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/parsar-daemon/internal/dispatch/preparation_start.go new file mode 100644 index 000000000..95c05204f --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_start.go @@ -0,0 +1,194 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) error { + var input proto.ExecutionStartPayload + if env.DecodePayload(&input) != nil || input.Handle == "" || strings.TrimSpace(input.RunID) == "" || strings.TrimSpace(input.Prompt) == "" { + return r.rejectPreparation(env, "invalid_start") + } + encoded, _ := json.Marshal(input) + fingerprint := sha256.Sum256(encoded) + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if r.workspaceExport != nil { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") + } + p := r.preparations[input.Handle] + if p == nil || p.requestID != env.ID { + r.mu.Unlock() + return r.rejectPreparation(env, "unknown_preparation") + } + if p.workspaceReadOnly { + r.mu.Unlock() + return r.rejectPreparation(env, "read_only_preparation") + } + if p.status.State == "starting" || p.status.State == "started" { + matches, status := p.startFingerprint == fingerprint, p.status + if matches { + if state := r.sessions[input.RunID]; state != nil && state.preparedHandoff != nil && + (state.session == nil || state.preparedHandoff.release != nil) { + r.mu.Unlock() + return nil + } + } + r.mu.Unlock() + if !matches { + return r.rejectPreparation(env, "start_conflict") + } + r.publishPreparation(p, status) + return nil + } + if p.status.State != "ready" || p.ctx.Err() != nil { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_not_ready") + } + if !time.Now().Before(p.deadline) { + r.mu.Unlock() + r.releasePreparation(p, "expired", "", true, true) + return nil + } + if r.sessions[input.RunID] != nil { + r.mu.Unlock() + return r.rejectPreparation(env, "run_conflict") + } + target, ok := p.prepared.(agent.PreparedCancellation) + if !ok { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_not_ready") + } + p.status.State, p.status.RunID, p.status.Revision = "starting", input.RunID, p.status.Revision+1 + p.startFingerprint, p.busy = fingerprint, true + state := &sessionState{ + runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, + out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, + pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), + traceparent: env.Trace, releaseOnCompletion: true, + } + state.preparedHandoff = newPreparedHandoff(p, target) + p.handoff = state.preparedHandoff + r.sessions[input.RunID] = state + status := p.status + // Track the Start owner and output consumer before either can publish. + r.shutdownWG.Add(2) + r.mu.Unlock() + go r.startPreparedExecution(p, state, input, status) + return nil +} + +func (r *Router) startPreparedExecution(p *preparationState, state *sessionState, input proto.ExecutionStartPayload, starting proto.PreparationStatusPayload) { + defer r.shutdownWG.Done() + handoff := state.preparedHandoff + go r.forwardPreparedOutput(state) + <-handoff.outputReady + + if !r.sendPreparation(p.requestID, p.trace, starting) { + r.mu.Lock() + handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) + if p.status.State == "starting" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 + } + p.timer.Stop() + r.claimPreparedReleaseLocked(state, true, "", false) + r.mu.Unlock() + close(state.out) + close(handoff.startDone) + return + } + + // A release admitted before this point must not cause native work to start. + r.mu.Lock() + blocked := handoff.release != nil && handoff.release.aborted() + r.mu.Unlock() + var session agent.Session + var startErr error + if blocked { + startErr = context.Canceled + } else { + session, startErr = handoff.target.Start(p.ctx, input.RunID, input.Prompt, state.out) + } + + r.mu.Lock() + release := handoff.release + notAborted := release == nil || !release.aborted() + started := startErr == nil && session != nil && p.status.State == "starting" && p.ctx.Err() == nil && notAborted + if started { + p.status.State, p.status.ErrorCode, p.status.Revision = "started", "", p.status.Revision+1 + } else { + p.timer.Stop() + if p.status.State == "starting" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 + } + r.claimPreparedReleaseLocked(state, true, "prepared execution could not start", false) + } + status := p.status + // Keep release behind successful or failed status publication. This also + // orders an early native Done after the started status. + if started { + handoff.operations.RLock() + } + r.mu.Unlock() + + // A nil Session leaves output ownership with the Router. A non-nil Session + // owns the close even when Start also returned an error. + if session == nil { + close(state.out) + } + deadline := time.Now().Add(5 * time.Second) + if started && p.deadline.Before(deadline) { + deadline = p.deadline + } + delivered := r.sendPreparationUntil(p.requestID, p.trace, status, deadline) + + r.mu.Lock() + if started && !time.Now().Before(p.deadline) { + if p.status.State == "started" { + p.status.State, p.status.ErrorCode, p.status.Revision = "expired", "", p.status.Revision+1 + } + r.claimPreparedReleaseLocked(state, true, "", false) + } + if started && handoff.release != nil && handoff.release.aborted() && p.status.State == "started" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 + } + if !delivered { + handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) + if started && p.status.State == "started" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 + } + r.claimPreparedReleaseLocked(state, true, "", false) + } else if started && p.status.State == "started" && + (handoff.release == nil || !handoff.release.aborted()) { + handoff.published = true + p.timer.Stop() + } + if handoff.published { + // Publication transfers resource tracking even when natural completion + // has already closed input admission. + if handoff.release == nil { + state.session = session + } + p.prepared = nil + p.owns = false + p.busy = false + p.handoff = nil + } + close(handoff.startDone) + r.mu.Unlock() + if started { + handoff.operations.RUnlock() + } +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go new file mode 100644 index 000000000..bfe5bb923 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -0,0 +1,409 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type controlledPreparation struct { + closed chan struct{} + once sync.Once + mu sync.Mutex + session agent.Session + starts atomic.Int32 + start func(context.Context, string, string, chan<- proto.Envelope) (agent.Session, error) + closeHook func() +} + +func (p *controlledPreparation) Close() error { + p.once.Do(func() { + if p.closeHook != nil { + p.closeHook() + } + close(p.closed) + }) + return nil +} +func (p *controlledPreparation) Start(ctx context.Context, id, prompt string, out chan<- proto.Envelope) (agent.Session, error) { + p.starts.Add(1) + session, err := p.start(ctx, id, prompt, out) + if session != nil { + p.mu.Lock() + p.session = session + p.mu.Unlock() + } + return session, err +} + +func (p *controlledPreparation) Cancel(ctx context.Context) error { + p.mu.Lock() + session := p.session + p.mu.Unlock() + if session != nil { + return session.Cancel(ctx) + } + return p.Close() +} + +func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { + p.mu.Lock() + session := p.session + p.mu.Unlock() + if provider, ok := session.(interface{ CancellationOutcome() proto.DonePayload }); ok { + return provider.CancellationOutcome() + } + return proto.DonePayload{} +} + +func preparationRequest() proto.ExecutionPreparePayload { + return proto.ExecutionPreparePayload{Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "execution-session", StrictResume: true, ReleaseOnCompletion: true, RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment"}}} +} + +func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { + t.Helper() + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("ordinary Factory must not be used for preparation") + }) + reg.RegisterPreparation("prepared", true, factory) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return r +} + +func waitPreparationStatus(t *testing.T, sender *recSender, request, state string, differentHandle string) proto.PreparationStatusPayload { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, env := range sender.snapshot() { + var p proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.ID == request && env.DecodePayload(&p) == nil && p.State == state && (differentHandle == "" || p.Handle != differentHandle) { + return p + } + } + time.Sleep(time.Millisecond) + } + t.Fatalf("preparation %s did not reach %s", request, state) + return proto.PreparationStatusPayload{} +} + +func waitPreparationClosed(t *testing.T, p *controlledPreparation) { + t.Helper() + select { + case <-p.closed: + case <-time.After(3 * time.Second): + t.Fatal("native preparation leaked") + } +} + +func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { + sender := &recSender{} + p := &controlledPreparation{closed: make(chan struct{})} + entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) + r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if req.RunID != "" || req.Prompt != "" { + t.Error("run input reached preparation") + } + entered <- ctx + <-allowReturn + return p, nil + }) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { + t.Fatal(err) + } + accepted := waitPreparationStatus(t, sender, "request", "preparing", "") + owner := <-entered + if r.ActiveRuns() != 0 { + t.Fatal("preparation created a run") + } + // Receive-loop operations remain available while native initialization blocks. + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "other-run", proto.PromptCancelPayload{})); err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: accepted.Handle})); err != nil { + t.Fatal(err) + } + select { + case <-owner.Done(): + case <-time.After(time.Second): + t.Fatal("release did not cancel owner") + } + close(allowReturn) + waitPreparationClosed(t, p) + if p.starts.Load() != 0 { + t.Fatal("released preparation started work") + } + for _, env := range sender.snapshot() { + if env.Type == proto.TypeError || env.Type == proto.TypeDone { + t.Fatal("preparation emitted run terminal frames") + } + } +} + +func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { + sender := &recSender{} + gotSession := make(chan *fakeSession, 1) + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(ctx context.Context, id, prompt string, out chan<- proto.Envelope) (agent.Session, error) { + if id != "real-run" || prompt != "actual input" { + t.Error("start identity or prompt changed") + } + s := &fakeSession{ctx: ctx, out: out, closeOutOnCancel: true} + gotSession <- s + return s, nil + } + r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "request", "ready", "") + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "real-run", Prompt: "actual input"}) + if err := r.Handle(t.Context(), start); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, sender, "request", "started", "") + session := <-gotSession + if err := r.Handle(t.Context(), start); err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + // The old preparation deadline must not govern the transferred Session. + time.Sleep(100 * time.Millisecond) + if session.ctx.Err() != nil || session.cancels() != 0 || p.starts.Load() != 1 || r.ActiveRuns() != 1 { + t.Fatal("transfer was duplicated or cancelled") + } + select { + case <-p.closed: + t.Fatal("transferred preparation closed") + default: + } + session.out <- mustEnv(t, proto.TypeDone, "real-run", proto.DonePayload{Content: "complete"}) + deadline := time.Now().Add(time.Second) + for r.ActiveRuns() != 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if r.ActiveRuns() != 0 || session.cancels() != 1 { + t.Fatal("normal completion release was bypassed") + } +} + +func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { + sender := &recSender{} + entered, cancelEntered, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) + lateSession := make(chan *fakeSession, 1) + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + close(entered) + <-allowReturn + s := &fakeSession{out: out, closeOutOnCancel: true} + lateSession <- s + return s, nil + } + p.cancel = func(ctx context.Context) error { + close(cancelEntered) + return (<-lateSession).Cancel(ctx) + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + ready := waitPreparationStatus(t, sender, "request", "ready", "") + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "real-run", Prompt: "input"})) + <-entered + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "real-run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + select { + case <-cancelEntered: + case <-time.After(time.Second): + t.Fatal("fixed cancellation target was not called across Start") + } + close(allowReturn) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "late cancelled Session cleanup") + p.mu.Lock() + session := p.session.(*fakeSession) + p.mu.Unlock() + if session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatal("late session resurrected cancelled run") + } + select { + case <-p.controlledPreparation.closed: + t.Fatal("transferred preparation was released a second time") + default: + } + for _, frame := range sender.snapshot() { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { + t.Fatal("cancelled start published active Session") + } + } +} + +func TestPreparationCapacityAndConnectionOwnership(t *testing.T) { + sender := &recSender{} + var count atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + count.Add(1) + return &controlledPreparation{closed: make(chan struct{})}, nil + }) + var handles []string + for i := 0; i < 4; i++ { + id := fmt.Sprint(i) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) + handles = append(handles, waitPreparationStatus(t, sender, id, "ready", "").Handle) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", preparationRequest())); err == nil { + t.Fatal("capacity unbounded") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "0", preparationRequest())); err != nil { + t.Fatal(err) + } + if count.Load() != 4 { + t.Fatal("retry recreated native resource") + } + other := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + t.Error("unexpected new native resource") + return nil, errors.New("unexpected") + }) + if err := other.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", proto.ExecutionStartPayload{Handle: handles[0], RunID: "run", Prompt: "input"})); err == nil { + t.Fatal("another connection consumed handle") + } +} + +func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { + sender := &recSender{} + created := make(chan *controlledPreparation, 2) + r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + p := &controlledPreparation{closed: make(chan struct{})} + created <- p + return p, nil + }) + env := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) + _ = r.Handle(t.Context(), env) + old := waitPreparationStatus(t, sender, "request", "ready", "") + waitPreparationStatus(t, sender, "request", "expired", "") + waitPreparationClosed(t, <-created) + _ = r.Handle(t.Context(), env) + next := waitPreparationStatus(t, sender, "request", "preparing", old.Handle) + if next.Handle == old.Handle || next.ExpiresAt <= old.ExpiresAt { + t.Fatal("replacement reused expired identity") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: old.Handle, RunID: "late", Prompt: "late"})); err == nil { + t.Fatal("old handle started replacement") + } +} + +type failReadySender struct{ *recSender } + +func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { + var status proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil && status.State == "ready" { + return errors.New("controlled send failure") + } + return s.recSender.Send(ctx, env) +} + +func TestPreparationFailedReadyDeliveryClosesResource(t *testing.T) { + p := &controlledPreparation{closed: make(chan struct{})} + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + waitPreparationClosed(t, p) + if r.ActiveRuns() != 0 { + t.Fatal("failed preparation became a run") + } +} + +func TestPreparationCapacityIncludesClosingResources(t *testing.T) { + sender := &recSender{} + entered, unblock := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(unblock) }) } + defer release() + var count atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + p := &controlledPreparation{closed: make(chan struct{})} + if count.Add(1) == 1 { + p.closeHook = func() { close(entered); <-unblock } + } + return p, nil + }) + var first proto.PreparationStatusPayload + for i := 0; i < 4; i++ { + id := fmt.Sprint(i) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) + ready := waitPreparationStatus(t, sender, id, "ready", "") + if i == 0 { + first = ready + } + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: first.Handle})) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("cleanup did not start") + } + request := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) + if err := r.Handle(t.Context(), request); err == nil { + t.Fatal("closing resource returned capacity early") + } + release() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if err := r.Handle(t.Context(), request); err == nil { + waitPreparationStatus(t, sender, "replacement", "ready", "") + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("completed cleanup did not release capacity") +} + +func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { + for name, change := range map[string]func(*proto.PromptRequestPayload){ + "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, + "input": func(p *proto.PromptRequestPayload) { p.Prompt = "input" }, + "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, + "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, + "attachment": func(p *proto.PromptRequestPayload) { p.Attachments = []proto.PromptAttachment{{Kind: "image"}} }, + "local fallback": func(p *proto.PromptRequestPayload) { p.RemoteEnvironment = nil }, + "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, + "release": func(p *proto.PromptRequestPayload) { p.ReleaseOnCompletion = false }, + } { + t.Run(name, func(t *testing.T) { + r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + t.Error("invalid preparation reached native factory") + return nil, errors.New("invalid") + }) + req := preparationRequest() + change(&req.Configuration) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", req)); err == nil { + t.Fatal("invalid preparation accepted") + } + if r.ActiveRuns() != 0 { + t.Fatal("invalid configuration became a Run") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go new file mode 100644 index 000000000..b5416df44 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -0,0 +1,356 @@ +package dispatch + +import ( + "context" + "errors" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +var errPreparedStatusDelivery = errors.New("prepared execution status delivery failed") + +// preparedReleaseAttempt is one serialized call to the handoff's fixed native +// cancellation target. Router.mu protects err until done closes. +type preparedReleaseAttempt struct { + done chan struct{} + err error +} + +// preparedRelease is the permanent terminal claim for a handoff. A failed +// attempt may be retried explicitly, but the target and claim never change. +// Router.mu protects its fields. +type preparedRelease struct { + abort chan struct{} + failure string + attempt *preparedReleaseAttempt + succeeded bool + outcome *proto.DonePayload + settled chan struct{} +} + +func (release *preparedRelease) aborted() bool { + select { + case <-release.abort: + return true + default: + return false + } +} + +// preparedHandoff owns the exact PreparedCancellation from Start admission +// through native cleanup and output settlement. It never falls back to the +// returned Session or Prepared.Close. +type preparedHandoff struct { + preparation *preparationState + target agent.PreparedCancellation + + startDone chan struct{} + outputReady chan struct{} + outputDone chan struct{} + + // Every admitted native mutation holds a read lock through its receipt. + // A release attempt takes the write lock before native cancellation. + operations sync.RWMutex + + published bool // started status committed; protected by Router.mu + release *preparedRelease + terminal *proto.Envelope + outputErr error + shutdownInterrupted bool +} + +func newPreparedHandoff(p *preparationState, target agent.PreparedCancellation) *preparedHandoff { + return &preparedHandoff{ + preparation: p, + target: target, + startDone: make(chan struct{}), + outputReady: make(chan struct{}), + outputDone: make(chan struct{}), + } +} + +// preparedOperationLocked admits one mutation at the same linearization point +// used by release. The returned function must run after the native call, replay +// bookkeeping and receipt send have all finished. Router.mu must be held. +func (r *Router) preparedOperationLocked(state *sessionState) (agent.Session, func(), bool) { + if state == nil || state.session == nil || state.steeringClosed { + return nil, nil, false + } + handoff := state.preparedHandoff + if handoff == nil { + return state.session, func() {}, true + } + if handoff.release != nil { + return nil, nil, false + } + handoff.operations.RLock() + return state.session, handoff.operations.RUnlock, true +} + +func (r *Router) interactionRouteOpenLocked(state *sessionState) bool { + if state == nil || r.closed || state.ctx.Err() != nil || state.steeringClosed { + return false + } + if handoff := state.preparedHandoff; handoff != nil { + return handoff.release == nil + } + return state.session != nil +} + +// claimPreparedReleaseLocked closes admission permanently and returns the +// current native attempt. retry starts a new serialized attempt only after the +// previous one failed. Router.mu must be held. +func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, failure string, retry bool) (*preparedRelease, *preparedReleaseAttempt) { + handoff := state.preparedHandoff + release := handoff.release + if release == nil { + release = &preparedRelease{abort: make(chan struct{}), failure: failure, settled: make(chan struct{})} + handoff.release = release + state.retain = false + state.steeringClosed = true + state.session = nil + r.clearInteractionRoutesLocked(state) + } + if abort && !release.aborted() { + close(release.abort) + } + if release.succeeded { + return release, release.attempt + } else if current := release.attempt; current != nil { + select { + case <-current.done: + if current.err == nil || !retry { + return release, current + } + default: + return release, current + } + } + + attempt := &preparedReleaseAttempt{done: make(chan struct{})} + release.attempt = attempt + p := handoff.preparation + p.busy = true + p.closeErr = nil + r.shutdownWG.Add(1) + go r.runPreparedRelease(state, handoff, release, attempt) + return release, attempt +} + +func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt) { + defer r.shutdownWG.Done() + + // Natural completion must publish started before it can release the native + // resource. Abort is allowed to fence a Start that is still in progress. + select { + case <-handoff.startDone: + case <-release.abort: + } + handoff.operations.Lock() + ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) + nativeErr := handoff.target.Cancel(ctx) + cancel() + var outcome *proto.DonePayload + if nativeErr == nil { + observed := handoff.target.CancellationOutcome() + outcome = &observed + } + handoff.operations.Unlock() + + r.mu.Lock() + attempt.err = nativeErr + if nativeErr != nil { + handoff.preparation.busy = false + handoff.preparation.closeErr = nativeErr + close(attempt.done) + r.mu.Unlock() + return + } + release.succeeded = true + release.outcome = outcome + close(attempt.done) + r.mu.Unlock() + + // Cancel success promises local cleanup and no further output writes. Wait + // for Start and the one output consumer before publishing terminal state. + <-handoff.startDone + <-handoff.outputDone + + r.mu.Lock() + outputErr := handoff.outputErr + terminal := handoff.terminal + closed := r.closed + r.mu.Unlock() + + var terminalErr error + if outputErr == nil && !closed { + terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, release.failure != "" && terminal == nil) + } + r.cleanupSession(state) + + r.mu.Lock() + handoff.outputErr = errors.Join(handoff.outputErr, terminalErr) + p := handoff.preparation + p.busy = false + p.closeErr = terminalErr + p.prepared = nil + p.owns = false + p.handoff = nil + p.cancel() + close(release.settled) + r.mu.Unlock() +} + +func (r *Router) forwardPreparedOutput(state *sessionState) { + defer r.shutdownWG.Done() + handoff := state.preparedHandoff + defer close(handoff.outputDone) + close(handoff.outputReady) + + pumpCtx := context.Background() + if state.traceparent != "" { + if carrier, err := obslog.ParseTraceparent(state.traceparent); err == nil { + pumpCtx = obslog.WithTrace(pumpCtx, carrier) + } + } + r.log.InfoContext(pumpCtx, "pump: started", "run_id", state.runID) + for { + select { + case env, ok := <-state.out: + if !ok { + r.log.InfoContext(pumpCtx, "pump: out channel closed", "run_id", state.runID) + r.mu.Lock() + if handoff.release == nil { + r.claimPreparedReleaseLocked(state, false, "", false) + } + r.mu.Unlock() + return + } + r.mu.Lock() + if handoff.terminal != nil { + r.mu.Unlock() + continue + } + if env.Type == proto.TypeDone { + terminal := env + handoff.terminal = &terminal + r.claimPreparedReleaseLocked(state, false, "", false) + r.mu.Unlock() + continue + } + drainOnly := handoff.outputErr != nil || handoff.shutdownInterrupted || r.closed + r.mu.Unlock() + if drainOnly { + continue + } + switch env.Type { + case proto.TypePermissionRequest, proto.TypePermissionCancel, proto.TypePromptForUserChoice: + r.indexPermissionFrame(state, env) + } + if err := r.sendSessionOutput(pumpCtx, state, env); err != nil { + r.mu.Lock() + if r.closed { + handoff.shutdownInterrupted = true + } else { + handoff.outputErr = errors.Join(handoff.outputErr, err) + } + r.claimPreparedReleaseLocked(state, true, "", false) + r.mu.Unlock() + r.drain(state.out) + return + } + case <-r.shutdownCh: + r.mu.Lock() + handoff.shutdownInterrupted = true + r.claimPreparedReleaseLocked(state, true, "", false) + r.mu.Unlock() + r.drain(state.out) + return + } + } +} + +func (r *Router) sendSessionOutput(pumpCtx context.Context, state *sessionState, env proto.Envelope) error { + if env.Trace == "" && state.traceparent != "" { + env.Trace = state.traceparent + } + r.log.InfoContext(pumpCtx, "pump: forwarding envelope", "run_id", state.runID, "type", env.Type, "env_id", env.ID) + sendCtx, cancel := context.WithCancel(context.Background()) + stopOnShutdown := make(chan struct{}) + go func() { + select { + case <-r.shutdownCh: + cancel() + case <-stopOnShutdown: + } + }() + err := r.sender.Send(sendCtx, env) + close(stopOnShutdown) + cancel() + if err != nil { + r.log.ErrorContext(pumpCtx, "send envelope failed", "type", env.Type, "run_id", env.ID, "err", err) + } + return err +} + +func (r *Router) forwardPreparedTerminal(state *sessionState, failure string, terminal *proto.Envelope, synthesize bool) error { + pumpCtx := context.Background() + if state.traceparent != "" { + if carrier, err := obslog.ParseTraceparent(state.traceparent); err == nil { + pumpCtx = obslog.WithTrace(pumpCtx, carrier) + } + } + if failure != "" { + errEnv, err := proto.NewEnvelopeWithTrace(proto.TypeError, state.runID, proto.ErrorPayload{Error: failure}, state.traceparent) + if err != nil { + return err + } + if err := r.sendSessionOutput(pumpCtx, state, errEnv); err != nil { + return err + } + } + if terminal != nil { + return r.sendSessionOutput(pumpCtx, state, *terminal) + } + if !synthesize { + return nil + } + done, err := proto.NewEnvelopeWithTrace(proto.TypeDone, state.runID, proto.DonePayload{}, state.traceparent) + if err != nil { + return err + } + return r.sendSessionOutput(pumpCtx, state, done) +} + +func (r *Router) awaitPreparedRelease(ctx context.Context, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt) error { + if err := r.awaitPreparedNativeRelease(ctx, release, attempt); err != nil { + return err + } + r.mu.Lock() + err := handoff.outputErr + r.mu.Unlock() + return err +} + +func (r *Router) awaitPreparedNativeRelease(ctx context.Context, release *preparedRelease, attempt *preparedReleaseAttempt) error { + select { + case <-attempt.done: + r.mu.Lock() + err := attempt.err + r.mu.Unlock() + if err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-release.settled: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go new file mode 100644 index 000000000..4529c3d95 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -0,0 +1,334 @@ +package dispatch_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type preparedMutationSession struct { + *fakeSession + cancelEntered chan struct{} + cancelOnce sync.Once + beforeCancel func() + functions atomic.Int32 + steers atomic.Int32 + reads atomic.Int32 +} + +func (s *preparedMutationSession) Cancel(ctx context.Context) error { + if s.beforeCancel != nil { + s.beforeCancel() + } + s.cancelOnce.Do(func() { close(s.cancelEntered) }) + return s.fakeSession.Cancel(ctx) +} + +func (s *preparedMutationSession) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + s.functions.Add(1) + return nil +} + +func (s *preparedMutationSession) Steer(context.Context, proto.PromptSteerPayload) error { + s.steers.Add(1) + return nil +} + +func (s *preparedMutationSession) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { + s.reads.Add(1) + return agent.WorkspaceReadResult{Data: []byte("x")}, nil +} + +type blockingPreparedReceiptSender struct { + *recSender + deliveryID string + inputID string + entered chan struct{} + release chan struct{} + exited chan struct{} + once sync.Once +} + +func (s *blockingPreparedReceiptSender) Send(ctx context.Context, env proto.Envelope) error { + if s.matches(env) { + s.once.Do(func() { close(s.entered) }) + defer close(s.exited) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return s.recSender.Send(ctx, env) +} + +func (s *blockingPreparedReceiptSender) matches(env proto.Envelope) bool { + switch env.Type { + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + return env.DecodePayload(&ack) == nil && ack.DeliveryID == s.deliveryID + case proto.TypePromptSteerAck: + var ack proto.PromptSteerAckPayload + return env.DecodePayload(&ack) == nil && ack.InputID == s.inputID + default: + return false + } +} + +func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { + for _, operation := range []string{"function", "permission", "choice", "steering"} { + t.Run(operation, func(t *testing.T) { + sender := &blockingPreparedReceiptSender{ + recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", + entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{}), + } + session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + return session, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender.recSender) + waitPreparationStatus(t, sender.recSender, "request", "started", "") + + var mutation proto.Envelope + switch operation { + case "function": + mutation = mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: sender.deliveryID}) + case "permission": + session.out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "permission"}) + waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") }, "permission request") + mutation = mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: sender.deliveryID, Approved: true}) + case "choice": + session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) + waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") + mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, Answers: []string{"yes"}}) + case "steering": + mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Text: "continue"}) + } + + returned := make(chan error, 1) + go func() { returned <- r.Handle(t.Context(), mutation) }() + select { + case <-sender.entered: + case <-time.After(2 * time.Second): + t.Fatal("mutation receipt did not block") + } + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "release admission closure") + switch operation { + case "function": + late := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "late", Success: true, Content: functionResultContent("late"), DeliveryID: "late-function"}) + if err := r.Handle(t.Context(), late); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender.recSender, "late-function", false, "not_ready") + case "permission": + late := mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: "late-permission", Approved: true}) + if err := r.Handle(t.Context(), late); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender.recSender, "late-permission", true, "") + newRequest := mustEnv(t, proto.TypePermissionDecision, "new-permission", proto.PermissionDecisionPayload{DeliveryID: "new-permission", Approved: true}) + if err := r.Handle(t.Context(), newRequest); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender.recSender, "new-permission", false, "not_pending") + case "choice": + late := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "late-choice", Answers: []string{"yes"}}) + if err := r.Handle(t.Context(), late); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender.recSender, "late-choice", true, "") + newRequest := mustEnv(t, proto.TypePromptForUserChoiceDecision, "new-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "new-choice", Answers: []string{"yes"}}) + if err := r.Handle(t.Context(), newRequest); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender.recSender, "new-choice", false, "not_pending") + case "steering": + late := mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "late-steering", Text: "late"}) + if err := r.Handle(t.Context(), late); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, sender.recSender, "run", "late-steering"); ack.ErrorCode != "run_inactive" { + t.Fatalf("late steering = %+v", ack) + } + } + select { + case <-session.cancelEntered: + t.Fatal("native release overtook an admitted receipt") + case <-time.After(50 * time.Millisecond): + } + close(sender.release) + select { + case err := <-returned: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("mutation handler did not finish") + } + select { + case <-session.cancelEntered: + case <-time.After(2 * time.Second): + t.Fatal("release did not follow the completed receipt") + } + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "mutation release cleanup") + if session.cancels() != 1 { + t.Fatalf("Session release calls = %d, want 1", session.cancels()) + } + switch operation { + case "function": + if session.functions.Load() != 1 { + t.Fatalf("function native calls = %d, want 1", session.functions.Load()) + } + case "permission": + if len(session.submissions()) != 1 { + t.Fatalf("permission native calls = %d, want 1", len(session.submissions())) + } + case "choice": + session.askMu.Lock() + calls := len(session.askCalls) + session.askMu.Unlock() + if calls != 1 { + t.Fatalf("choice native calls = %d, want 1", calls) + } + case "steering": + if session.steers.Load() != 1 { + t.Fatalf("steering native calls = %d, want 1", session.steers.Load()) + } + } + assertReceiptBeforeDone(t, sender.recSender, operation) + }) + } +} + +func hasFrame(sender *recSender, kind, id string) bool { + for _, frame := range sender.snapshot() { + if frame.Type == kind && frame.ID == id { + return true + } + } + return false +} + +func assertReceiptBeforeDone(t *testing.T, sender *recSender, operation string) { + t.Helper() + receipt, done := -1, -1 + for i, frame := range sender.snapshot() { + if frame.Type == proto.TypeDone && frame.ID == "run" { + done = i + } + if operation == "steering" && frame.Type == proto.TypePromptSteerAck || operation != "steering" && frame.Type == proto.TypeInteractionDecisionAck { + receipt = i + } + } + if receipt < 0 || done < 0 || receipt >= done { + t.Fatalf("receipt/Done order invalid: receipt=%d done=%d frames=%v", receipt, done, sender.typesFor("run")) + } +} + +func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { + for _, operation := range []string{"function", "permission", "choice", "steering"} { + t.Run(operation, func(t *testing.T) { + sender := &blockingPreparedReceiptSender{recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} + defer close(sender.release) + var cancelBeforeReceipt atomic.Bool + session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + return session, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender.recSender) + waitPreparationStatus(t, sender.recSender, "request", "started", "") + + session.beforeCancel = func() { + select { + case <-sender.exited: + default: + cancelBeforeReceipt.Store(true) + } + } + var mutation proto.Envelope + switch operation { + case "function": + mutation = mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: sender.deliveryID}) + case "permission": + session.out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "permission"}) + waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") }, "permission request") + mutation = mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: sender.deliveryID, Approved: true}) + case "choice": + session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) + waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") + mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, Answers: []string{"yes"}}) + case "steering": + mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Text: "continue"}) + } + go func() { _ = r.Handle(t.Context(), mutation) }() + select { + case <-sender.entered: + case <-time.After(2 * time.Second): + t.Fatal("mutation receipt did not block") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if cancelBeforeReceipt.Load() || session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatalf("shutdown crossed receipt barrier: early=%t cancels=%d active=%d", cancelBeforeReceipt.Load(), session.cancels(), r.ActiveRuns()) + } + }) + } +} + +func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { + sender := &recSender{} + emitted, allowReturn := make(chan struct{}), make(chan struct{}) + session := &fakeSession{closeOutOnCancel: true} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(ctx context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + close(emitted) + select { + case <-allowReturn: + return session, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender) + <-emitted + if hasFrame(sender, proto.TypeDone, "run") || session.cancels() != 0 { + t.Fatal("terminal escaped before Start returned") + } + close(allowReturn) + waitFor(t, func() bool { return hasFrame(sender, proto.TypeDone, "run") && r.ActiveRuns() == 0 }, "early terminal settlement") + frames := sender.snapshot() + started, done := -1, -1 + for i, frame := range frames { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { + started = i + } + if frame.Type == proto.TypeDone && frame.ID == "run" { + done = i + } + } + if started < 0 || done < 0 || started >= done || session.cancels() != 1 { + t.Fatalf("started/Done order invalid: started=%d done=%d cancels=%d", started, done, session.cancels()) + } +} + +var _ agent.FunctionResultSubmitter = (*preparedMutationSession)(nil) +var _ agent.Steerer = (*preparedMutationSession)(nil) diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go new file mode 100644 index 000000000..2500004d5 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go @@ -0,0 +1,358 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const preparedBurstFrames = 96 + +func TestPreparedHandoffDrainsBurstBeforeStartReturns(t *testing.T) { + sender := &recSender{} + sent, allowReturn := make(chan struct{}), make(chan struct{}) + session := &fakeSession{closeOutOnCancel: true} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(ctx context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + for sequence := uint64(1); sequence <= preparedBurstFrames; sequence++ { + select { + case out <- mustEnv(t, proto.TypeDelta, "run", proto.DeltaPayload{Delta: "burst", Sequence: sequence}): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + close(sent) + select { + case <-allowReturn: + return session, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender) + select { + case <-sent: + case <-time.After(2 * time.Second): + t.Fatal("prepared output blocked at the 64-frame channel capacity") + } + waitFor(t, func() bool { return preparedDeltaCount(sender, "run") == preparedBurstFrames }, "pre-Start burst forwarding") + assertPreparedDeltaOrder(t, sender, "run", preparedBurstFrames) + close(allowReturn) + waitPreparationStatus(t, sender, "request", "started", "") + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "prepared burst completion") + if session.cancels() != 1 { + t.Fatalf("Session release calls = %d, want 1", session.cancels()) + } + assertPreparedDeltaOrder(t, sender, "run", preparedBurstFrames) +} + +func preparedDeltaCount(sender *recSender, runID string) int { + count := 0 + for _, frame := range sender.snapshot() { + if frame.ID == runID && frame.Type == proto.TypeDelta { + count++ + } + } + return count +} + +func assertPreparedDeltaOrder(t *testing.T, sender *recSender, runID string, want int) { + t.Helper() + seen := 0 + for _, frame := range sender.snapshot() { + if frame.ID != runID || frame.Type != proto.TypeDelta { + continue + } + seen++ + var delta proto.DeltaPayload + if err := frame.DecodePayload(&delta); err != nil || delta.Sequence != uint64(seen) { + t.Fatalf("delta %d = %+v, err=%v", seen, delta, err) + } + } + if seen != want { + t.Fatalf("delta count = %d, want %d", seen, want) + } +} + +type blockingStartedSender struct { + *recSender + entered chan struct{} + release chan struct{} + attempts atomic.Int32 +} + +type blockingStartingSender struct { + *recSender + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (s *blockingStartingSender) Send(ctx context.Context, env proto.Envelope) error { + var status proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil && status.State == "starting" { + s.once.Do(func() { close(s.entered) }) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return s.recSender.Send(ctx, env) +} + +func TestPreparedHandoffAbortBeforeStartAdmissionSkipsNativeStart(t *testing.T) { + sender := &blockingStartingSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(context.Context, string, string, chan<- proto.Envelope) (agent.Session, error) { + t.Fatal("abort that won admission called native Start") + return nil, errors.New("unexpected Start") + } + p.cancel = func(context.Context) error { return p.Close() } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + ready := startCancellationPreparation(t, r, sender.recSender) + select { + case <-sender.entered: + case <-time.After(time.Second): + t.Fatal("starting publication did not block") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return p.calls.Load() == 1 }, "fixed cancellation target") + close(sender.release) + waitFor(t, func() bool { return r.ActiveRuns() == 0 && len(cancellationAcks(sender.recSender)) == 1 }, "pre-Start abort settlement") + if p.starts.Load() != 0 || ready.Handle == "" { + t.Fatalf("native Start calls = %d", p.starts.Load()) + } + ack := cancellationAcks(sender.recSender)[0] + if !ack.Applied || ack.ErrorCode != "" || ack.Outcome == nil { + t.Fatalf("pre-Start cancellation receipt = %+v", ack) + } +} + +func (s *blockingStartedSender) Send(ctx context.Context, env proto.Envelope) error { + var status proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil && status.State == "started" { + if s.attempts.Add(1) == 1 { + close(s.entered) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return s.recSender.Send(ctx, env) +} + +func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testing.T) { + sender := &blockingStartedSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "publication-permission"}) + out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "publication-choice"}) + return session, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + ready := startCancellationPreparation(t, r, sender.recSender) + select { + case <-sender.entered: + case <-time.After(2 * time.Second): + t.Fatal("started status did not reach publication boundary") + } + waitFor(t, func() bool { + return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") && hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") + }, "pre-publication interaction routes") + for _, mutation := range []proto.Envelope{ + mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "publication-function"}), + mustEnv(t, proto.TypePermissionDecision, "publication-permission", proto.PermissionDecisionPayload{DeliveryID: "publication-permission", Approved: true}), + mustEnv(t, proto.TypePromptForUserChoiceDecision, "publication-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "publication-choice", Answers: []string{"yes"}}), + } { + if err := r.Handle(t.Context(), mutation); err != nil { + t.Fatal(err) + } + } + assertDecisionAck(t, sender.recSender, "publication-function", false, "not_ready") + assertDecisionAck(t, sender.recSender, "publication-permission", false, "not_ready") + assertDecisionAck(t, sender.recSender, "publication-choice", false, "not_ready") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "publication-steering", Text: "continue"})); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, sender.recSender, "run", "publication-steering"); ack.ErrorCode != "not_ready" { + t.Fatalf("pre-publication steering = %+v", ack) + } + read := proto.WorkspaceReadPayload{RunID: "run", EnvironmentID: "environment", Path: "file", MaxBytes: 1} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "publication-read", read)); err != nil { + t.Fatal(err) + } + var readResult proto.WorkspaceReadResultPayload + frames := sender.snapshot() + if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeWorkspaceReadResult || frames[len(frames)-1].DecodePayload(&readResult) != nil || readResult.ErrorCode != "resource_unavailable" { + t.Fatalf("pre-publication workspace read = %+v", frames) + } + session.askMu.Lock() + askCalls := len(session.askCalls) + session.askMu.Unlock() + if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 || len(session.submissions()) != 0 || askCalls != 0 { + t.Fatal("private Session accepted work before started publication") + } + duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "input"}) + if err := r.Handle(t.Context(), duplicate); err != nil { + t.Fatal(err) + } + if p.starts.Load() != 1 || sender.attempts.Load() != 1 { + t.Fatalf("duplicate Start re-executed work: starts=%d publications=%d", p.starts.Load(), sender.attempts.Load()) + } + close(sender.release) + waitPreparationStatus(t, sender.recSender, "request", "started", "") + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "duplicate Start cleanup") +} + +func TestPreparedHandoffUnsupportedFunctionReleasesOperationBarrier(t *testing.T) { + sender := &recSender{} + session := &fakeSession{closeOutOnCancel: true} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + return session, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender) + waitPreparationStatus(t, sender, "request", "started", "") + result := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "unsupported", Success: true, Content: functionResultContent("answer"), DeliveryID: "unsupported"}) + if err := r.Handle(t.Context(), result); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, sender, "unsupported", false, "not_pending") + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "release after unsupported function") + if session.cancels() != 1 { + t.Fatalf("native release calls = %d, want 1", session.cancels()) + } +} + +func TestPreparedHandoffEarlyDoneStillAllowsExplicitAbort(t *testing.T) { + sender := &recSender{} + cancelled := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(cancelled) }) } + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) + <-cancelled + return nil, context.Canceled + } + p.cancel = func(context.Context) error { unblock(); return nil } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + defer unblock() + startCancellationPreparation(t, r, sender) + waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done release claim") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "abort"})); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(300 * time.Millisecond) + for p.calls.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if p.calls.Load() == 0 { + t.Error("explicit abort never reaches native Cancel after early Done: natural release is still waiting for Start") + } +} + +func TestPreparedHandoffExpiresDuringStartedPublication(t *testing.T) { + sender := &blockingStartedSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + session := &fakeSession{closeOutOnCancel: true} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + return session, nil + } + r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + startCancellationPreparation(t, r, sender.recSender) + <-sender.entered + time.Sleep(250 * time.Millisecond) + if session.cancels() == 0 { + t.Error("preparation deadline did not cancel an unpublished Session") + } + close(sender.release) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "expired handoff cleanup") + if attempts := sender.attempts.Load(); attempts != 1 { + t.Fatalf("expired preparation republished provisional started: attempts=%d", attempts) + } + for _, frame := range sender.snapshot() { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { + t.Fatal("expired preparation published started") + } + } +} + +func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { + sender := &recSender{} + allowReturn := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(allowReturn) }) } + defer unblock() + session := &fakeSession{closeOutOnCancel: true} + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _, _ string, out chan<- proto.Envelope) (agent.Session, error) { + session.out = out + out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) + <-allowReturn + return session, nil + } + p.cancel = func(ctx context.Context) error { + if p.calls.Load() == 1 { + return errors.New("cleanup incomplete") + } + return session.Cancel(ctx) + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + ready := startCancellationPreparation(t, r, sender) + waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done claim") + unblock() + waitPreparationStatus(t, sender, "request", "started", "") + waitFor(t, func() bool { + _, busy := r.PreparationOwnershipForTest(ready.Handle) + return p.calls.Load() == 1 && !busy + }, "failed natural cleanup") + if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { + t.Fatal("published Run retained preparation capacity after early Done") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + count := 0 + for _, frame := range sender.snapshot() { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { + count++ + } + } + return count == 2 + }, "published preparation release response") + if p.calls.Load() != 1 { + t.Fatal("old preparation handle retried native cancellation") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return len(cancellationAcks(sender)) == 1 && r.ActiveRuns() == 0 }, "Run-owned retry settlement") + if p.calls.Load() != 2 || session.cancels() != 1 || !cancellationAcks(sender)[0].Applied { + t.Fatal("explicit Run cancellation did not retry the retained native target") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prompt.go b/apps/parsar-daemon/internal/dispatch/prompt.go new file mode 100644 index 000000000..58e2d4dcd --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/prompt.go @@ -0,0 +1,134 @@ +package dispatch + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelope) error { + r.log.InfoContext(callerCtx, "handlePromptRequest: decoding payload", "env_id", env.ID, "env_type", env.Type) + var req proto.PromptRequestPayload + if err := env.DecodePayload(&req); err != nil { + r.log.ErrorContext(callerCtx, "handlePromptRequest: decode failed", "env_id", env.ID, "err", err) + return fmt.Errorf("dispatch: decode prompt_request: %w", err) + } + // Envelope.ID is the run id; payload mirrors it but envelope wins. + runID := env.ID + if runID == "" { + runID = req.RunID + } + if runID == "" { + r.log.ErrorContext(callerCtx, "handlePromptRequest: missing run id") + return errors.New("dispatch: prompt_request missing run id (Envelope.ID and Payload.RunID both empty)") + } + req.RunID = runID + var err error + if req, err = r.localWorkspace.Configure(req); err != nil { + r.emitTerminalError(callerCtx, runID, err.Error()) + return err + } + if req.AgentKind == "" { + r.log.ErrorContext(callerCtx, "handlePromptRequest: missing agent_kind", "run_id", runID) + return errors.New("dispatch: prompt_request missing agent_kind") + } + if req.WorkspaceReadOnly { + err := errors.New("read-only preparation cannot accept a prompt") + r.emitTerminalError(callerCtx, runID, err.Error()) + return err + } + if len(req.FunctionTools) > 0 && !r.availableCapabilities(req.AgentKind).FunctionTools { + err := errors.New("engine does not support function tools") + r.emitTerminalError(callerCtx, runID, err.Error()) + return err + } + if err := validateExecutionEnvironment(req, r.availableCapabilities(req.AgentKind)); err != nil { + r.emitTerminalError(callerCtx, runID, err.Error()) + return err + } + r.log.InfoContext(callerCtx, "handlePromptRequest: decoded", + "run_id", runID, "agent_kind", req.AgentKind, + "work_dir", req.WorkDir, "prompt_len", len(req.Prompt), + "has_agent_options", req.AgentOptions != nil, + "agent_session_id", req.AgentSessionID, + "agent_state_key", req.AgentStateKey) + + factory, err := r.registry.Resolve(req.AgentKind) + if err != nil { + r.log.ErrorContext(callerCtx, "handlePromptRequest: registry.Resolve failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) + // Synthesize error+done so the server-side stream closes + // cleanly instead of waiting for a done that never comes. + r.emitTerminalError(callerCtx, runID, fmt.Sprintf("unsupported agent_kind %q on this daemon", req.AgentKind)) + return err + } + r.log.InfoContext(callerCtx, "handlePromptRequest: factory resolved", "run_id", runID, "agent_kind", req.AgentKind) + + // Lock-protect duplicate-run check + insert so two prompt_requests + // with the same RunID can't both start sessions. + r.mu.Lock() + if r.closed { + r.mu.Unlock() + r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) + return ErrRouterClosed + } + if r.workspaceWrite != nil || r.workspaceExport != nil { + r.mu.Unlock() + err := errors.New("local workspace has an unsettled write") + r.emitTerminalError(callerCtx, runID, err.Error()) + return err + } + if _, dup := r.sessions[runID]; dup { + r.mu.Unlock() + r.log.WarnContext(callerCtx, "ignoring duplicate prompt_request", "run_id", runID) + return nil + } + stateKey := sessionStateKey(req) + r.touchIdleLocked(stateKey) + sessionCtx, sessionCancel := context.WithCancel(context.Background()) + // Re-attach the inbound trace so every log under this run shows + // the same trace_id as the prompt_request that started it. + if carrier, ok := obslog.TraceFromContext(callerCtx); ok { + sessionCtx = obslog.WithTrace(sessionCtx, carrier) + } + out := make(chan proto.Envelope, 64) + state := &sessionState{ + runID: runID, + environmentID: req.EnvironmentID(), + stateKey: stateKey, + out: out, + ctx: sessionCtx, + ctxCancel: sessionCancel, + pendingIDs: make(map[string]struct{}), + pendingAsks: make(map[string]struct{}), + traceparent: env.Trace, + retain: stateKey != "", + releaseOnCompletion: req.ReleaseOnCompletion, + } + r.sessions[runID] = state + r.mu.Unlock() + + r.log.InfoContext(callerCtx, "handlePromptRequest: calling factory", "run_id", runID) + sess, err := factory(sessionCtx, req, out) + if err != nil { + r.log.ErrorContext(callerCtx, "handlePromptRequest: factory call failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) + // Roll back the registration, cancel ctx, surface error+done + // so the server doesn't hang on a phantom run. + r.mu.Lock() + delete(r.sessions, runID) + r.mu.Unlock() + sessionCancel() + r.emitTerminalError(callerCtx, runID, fmt.Sprintf("agent factory failed: %v", err)) + return fmt.Errorf("dispatch: factory %q: %w", req.AgentKind, err) + } + r.log.InfoContext(callerCtx, "handlePromptRequest: session created, starting pump", "run_id", runID) + r.mu.Lock() + state.session = sess + r.mu.Unlock() + + r.shutdownWG.Add(1) + go r.pump(state) + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/receipt_order_test.go b/apps/parsar-daemon/internal/dispatch/receipt_order_test.go new file mode 100644 index 000000000..8c0801d13 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/receipt_order_test.go @@ -0,0 +1,174 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type blockedReceiptSender struct { + recSender + once sync.Once + entered chan struct{} + release chan struct{} + exited chan struct{} + fail bool +} + +func (s *blockedReceiptSender) Send(ctx context.Context, env proto.Envelope) error { + blocked := false + if env.Type == proto.TypePromptSteerAck { + s.once.Do(func() { blocked = true }) + } + if blocked { + close(s.entered) + defer close(s.exited) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + if s.fail { + return errors.New("receipt transport failed") + } + } + return s.recSender.Send(ctx, env) +} + +func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { + for _, mode := range []string{"consumed", "unknown", "send_failure"} { + t.Run(mode, func(t *testing.T) { + sender := &blockedReceiptSender{entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{}), fail: mode == "send_failure"} + registry := agent.NewRegistry() + var session *fakeSession + var calls atomic.Int32 + registry.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { + calls.Add(1) + if mode == "unknown" { + return errors.New("native outcome unknown") + } + return nil + }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + var release sync.Once + defer release.Do(func() { close(sender.release) }) + handle := func(kind string, payload any) { + t.Helper() + if err := router.Handle(context.Background(), mustEnv(t, kind, "ordered", payload)); err != nil { + t.Fatal(err) + } + } + handle(proto.TypePromptRequest, proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true}) + input := proto.PromptSteerPayload{InputID: "input-1", Text: "original"} + handle(proto.TypePromptSteer, input) + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "ordered", proto.DonePayload{Content: "finished"}) + waitFor(t, func() bool { return router.SteeringClosedForTest("ordered") }, "closed steering admission") + if session.cancels() != 0 || len(sender.snapshot()) != 0 { + t.Fatal("native release or Done overtook receipt") + } + if mode != "send_failure" { + handle(proto.TypePromptSteer, input) + frames := sender.snapshot() + var ack proto.PromptSteerAckPayload + if len(frames) != 1 || frames[0].DecodePayload(&ack) != nil || ack.Accepted != (mode == "consumed") { + t.Fatalf("cached receipt lost: %+v", ack) + } + if mode == "unknown" && ack.ErrorCode != "outcome_unknown" { + t.Fatal("uncertainty lost") + } + input.Text = "changed" + handle(proto.TypePromptSteer, input) + input.InputID = "new" + handle(proto.TypePromptSteer, input) + frames = sender.snapshot() + for i, want := range []string{"input_conflict", "run_inactive"} { + if frames[i+1].DecodePayload(&ack) != nil || ack.ErrorCode != want { + t.Fatalf("receipt %d: %+v", i, ack) + } + } + } + release.Do(func() { close(sender.release) }) + waitFor(t, func() bool { return router.ActiveRuns() == 0 }, "durable completion") + frames := sender.snapshot() + if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeDone || calls.Load() != 1 || session.cancels() != 1 { + t.Fatalf("invalid terminal order/calls: %+v, calls=%d cancels=%d", frames, calls.Load(), session.cancels()) + } + if mode == "send_failure" && len(frames) != 1 { + t.Fatal("failed send fabricated an applied receipt") + } + }) + } +} + +func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { + for _, phase := range []string{"native", "receipt_send"} { + t.Run(phase, func(t *testing.T) { + sender := &blockedReceiptSender{entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} + registry := agent.NewRegistry() + entered, exited := make(chan struct{}), make(chan struct{}) + var session *fakeSession + registry.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(ctx context.Context, _ proto.PromptSteerPayload) error { + close(entered) + defer close(exited) + if phase == "native" { + <-ctx.Done() + return ctx.Err() + } + return nil + }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptSteer, "shutdown", proto.PromptSteerPayload{InputID: "one", Text: "text"})); err != nil { + t.Fatal(err) + } + <-entered + if phase == "receipt_send" { + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "shutdown", proto.DonePayload{}) + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "completion barrier") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = router.Shutdown(ctx); err != nil { + t.Fatal(err) + } + select { + case <-exited: + default: + t.Fatal("native receipt worker leaked") + } + select { + case <-sender.exited: + default: + t.Fatal("receipt send worker leaked") + } + if router.ActiveRuns() != 0 { + t.Fatal("run remained after shutdown") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go b/apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go new file mode 100644 index 000000000..9379b6954 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go @@ -0,0 +1,78 @@ +package dispatch_test + +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" + "time" +) + +type shutdownAllSendsBlockSender struct { + recSender + entered chan struct{} + terminal chan context.Context + rescue chan struct{} +} + +func (s *shutdownAllSendsBlockSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypePromptSteerAck { + close(s.entered) + select { + case <-ctx.Done(): + <-time.After(50 * time.Millisecond) + return ctx.Err() + case <-s.rescue: + return errors.New("test cleanup") + } + } + if env.Type == proto.TypeError { + s.terminal <- ctx + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.rescue: + return errors.New("test cleanup") + } + } + return s.recSender.Send(ctx, env) +} + +func TestShutdownCancelsCompletionErrorSend(t *testing.T) { + sender := &shutdownAllSendsBlockSender{entered: make(chan struct{}), terminal: make(chan context.Context, 1), rescue: make(chan struct{})} + registry := agent.NewRegistry() + var session *fakeSession + registry.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { return nil }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown-terminal", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptSteer, "shutdown-terminal", proto.PromptSteerPayload{InputID: "one", Text: "text"})); err != nil { + t.Fatal(err) + } + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "shutdown-terminal", proto.DonePayload{}) + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown-terminal") }, "completion barrier") + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + shutdownErr := router.Shutdown(ctx) + active := router.ActiveRuns() + select { + case terminalCtx := <-sender.terminal: + t.Logf("error send context: Done is nil=%t, Err=%v", terminalCtx.Done() == nil, terminalCtx.Err()) + default: + } + close(sender.rescue) + waitFor(t, func() bool { return router.ActiveRuns() == 0 }, "test cleanup") + if shutdownErr != nil || active != 0 { + t.Fatalf("cooperating receipt sender canceled, but shutdown failed: err=%v active_runs=%d", shutdownErr, active) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go new file mode 100644 index 000000000..468a4d45e --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -0,0 +1,315 @@ +// Package dispatch wires inbound WebSocket frames to the agent layer. +// It owns one Session per active RunID, a per-session pump goroutine +// that forwards the agent's events to the transport, and a +// permission_id → run_id index so permission_decision frames route +// back to the right session. +// +// Concurrency: Handle is safe for one goroutine (typically the read +// loop). Each session runs its own goroutine. Internal state is +// mutex-protected. +package dispatch + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// Sender is the subset of transport.Conn the dispatcher needs. Tests +// supply a fake; production wires this to *transport.Conn.Send. +type Sender interface { + Send(ctx context.Context, env proto.Envelope) error +} + +// Router maps inbound Envelopes to agent sessions. +type Router struct { + registry *agent.Registry + sender Sender + log *slog.Logger + + mu sync.Mutex + sessions map[string]*sessionState // RunID → state + idle map[string]map[*sessionState]struct{} + permIndex map[string]string // permID → RunID + askIndex map[string]string // askID → RunID + applied map[string]appliedInteractionDecision + shutdownAttempt *shutdownAttempt + shutdownCh chan struct{} // closed by Shutdown + shutdownWG sync.WaitGroup // waits for all pump goroutines + idleTimeout time.Duration + closed bool + preparations map[string]*preparationState + preparationRequests map[string]*preparationState + preparationTimeout time.Duration + workspaceWrite *workspaceUpload + workspaceExport *workspaceExport + workspaceReads map[string]struct{} + localWorkspace *localworkspace.Binding +} + +type appliedInteractionDecision struct { + requestID string + kind string + fingerprint [32]byte + recordedAt time.Time +} + +// sessionState is the dispatcher's per-run bookkeeping. The agent +// owns the close of out; the dispatcher cancels ctxCancel to wind +// down. traceparent captures the prompt_request's W3C trace so every +// outbound frame stamps env.Trace with the same value, completing +// frontend → server → daemon → agent → server attribution. +type sessionState struct { + runID string + environmentID string + stateKey string + session agent.Session + out chan proto.Envelope + ctx context.Context + ctxCancel context.CancelFunc + pendingIDs map[string]struct{} + pendingAsks map[string]struct{} + traceparent string + idleTimer *time.Timer + idleLease uint64 + retain bool + releaseOnCompletion bool + steering map[string]steeringReceipt + steerBusy bool + steeringClosed bool + steeringDone chan struct{} + preparedHandoff *preparedHandoff +} + +// Config is the constructor input. Registry and Sender are required; +// Log is optional (defaults to slog.Default()). +type Config struct { + Registry *agent.Registry + Sender Sender + Log *slog.Logger + IdleTimeout time.Duration + PreparationTimeout time.Duration + LocalWorkspace *localworkspace.Binding +} + +const defaultIdleTimeout = time.Hour + +// New returns a Router ready to Handle inbound frames. +func New(cfg Config) (*Router, error) { + if cfg.Registry == nil { + return nil, errors.New("dispatch.New: Registry is required") + } + if cfg.Sender == nil { + return nil, errors.New("dispatch.New: Sender is required") + } + log := cfg.Log + if log == nil { + log = obslog.Bg() + } + log = log.With("component", "dispatch") + idleTimeout := cfg.IdleTimeout + if idleTimeout <= 0 { + idleTimeout = defaultIdleTimeout + } + preparationTimeout := cfg.PreparationTimeout + if preparationTimeout <= 0 || preparationTimeout > 5*time.Minute { + preparationTimeout = 5 * time.Minute + } + return &Router{ + registry: cfg.Registry, + sender: cfg.Sender, + log: log, + sessions: make(map[string]*sessionState), + idle: make(map[string]map[*sessionState]struct{}), + permIndex: make(map[string]string), + askIndex: make(map[string]string), + applied: make(map[string]appliedInteractionDecision), + shutdownCh: make(chan struct{}), + idleTimeout: idleTimeout, + preparations: make(map[string]*preparationState), + preparationRequests: make(map[string]*preparationState), + preparationTimeout: preparationTimeout, + localWorkspace: cfg.LocalWorkspace, + }, nil +} + +// Handle dispatches one inbound Envelope. Errors are returned for +// programmer-visible problems (bad shape, registry miss); transient +// session-level failures are logged and swallowed. +// +// Adopts env.Trace into ctx so every downstream log under it inherits +// the same trace_id, making a single grep cover both sides. +func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + r.mu.Unlock() + + ctx = adoptEnvelopeTrace(ctx, env) + + switch env.Type { + case proto.TypeWorkspaceExport: + return r.handleWorkspaceExport(ctx, env) + case proto.TypeWorkspaceWrite: + return r.handleWorkspaceWrite(ctx, env) + case proto.TypeWorkspaceRead: + return r.handleWorkspaceRead(ctx, env) + case proto.TypeExecutionPrepare: + return r.handleExecutionPrepare(ctx, env) + case proto.TypeExecutionStart: + return r.handleExecutionStart(ctx, env) + case proto.TypeExecutionRelease: + return r.handleExecutionRelease(ctx, env) + case proto.TypePromptRequest: + return r.handlePromptRequest(ctx, env) + case proto.TypePromptCancel: + return r.handlePromptCancel(ctx, env) + case proto.TypeFunctionResult: + return r.handleFunctionResult(ctx, env) + case proto.TypePromptSteer: + return r.handlePromptSteer(ctx, env) + case proto.TypePermissionDecision: + return r.handlePermissionDecision(ctx, env) + case proto.TypePromptForUserChoiceDecision: + return r.handlePromptForUserChoiceDecision(ctx, env) + case proto.TypeDeviceShutdown: + return r.handleDeviceShutdown(ctx, env) + default: + // Unknown types are logged and dropped — keeps the daemon + // forward-compatible with server-side additions. + r.log.WarnContext(ctx, "dropping unknown envelope type", "type", env.Type, "id", env.ID) + return nil + } +} + +// adoptEnvelopeTrace returns a ctx carrying env.Trace's carrier. On +// empty / unparseable trace we mint a fresh one rather than propagate +// a bad trace_id back to the server. +func adoptEnvelopeTrace(ctx context.Context, env proto.Envelope) context.Context { + if env.Trace != "" { + if carrier, err := obslog.ParseTraceparent(env.Trace); err == nil { + return obslog.WithTrace(ctx, carrier) + } + } + ctx, _ = obslog.StartBackgroundTrace(ctx, "daemon.envelope") + return ctx +} + +// ActiveRuns returns the in-flight run count. Wired into the heartbeat +// payload supplier. +func (r *Router) ActiveRuns() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.sessions) +} + +var ErrRouterClosed = errors.New("dispatch: router closed") + +// --------------------------------------------------------------------- +// per-type handlers +// --------------------------------------------------------------------- + +func (r *Router) drain(ch <-chan proto.Envelope) { + for range ch { + } +} + +// cleanupSession removes the session from registry maps. Called from +// pump's defer so it runs exactly once. +func (r *Router) cleanupSession(s *sessionState) { + r.mu.Lock() + delete(r.sessions, s.runID) + for permID := range s.pendingIDs { + delete(r.permIndex, permID) + } + for askID := range s.pendingAsks { + delete(r.askIndex, askID) + } + if !s.retain || s.session == nil || s.stateKey == "" || r.closed { + r.mu.Unlock() + return + } + states := r.idle[s.stateKey] + if states == nil { + states = make(map[*sessionState]struct{}) + r.idle[s.stateKey] = states + } + states[s] = struct{}{} + r.scheduleIdleLocked(s) + r.mu.Unlock() +} + +func sessionStateKey(req proto.PromptRequestPayload) string { + return req.AgentStateKey +} + +func (r *Router) touchIdleLocked(stateKey string) { + if stateKey == "" { + return + } + for state := range r.idle[stateKey] { + r.scheduleIdleLocked(state) + } +} + +func (r *Router) scheduleIdleLocked(state *sessionState) { + if state.idleTimer != nil { + state.idleTimer.Stop() + } + state.idleLease++ + lease := state.idleLease + state.idleTimer = time.AfterFunc(r.idleTimeout, func() { + r.expireIdle(state, lease) + }) +} + +func (r *Router) expireIdle(state *sessionState, lease uint64) { + r.mu.Lock() + states := r.idle[state.stateKey] + if _, ok := states[state]; !ok || state.idleLease != lease { + r.mu.Unlock() + return + } + delete(states, state) + if len(states) == 0 { + delete(r.idle, state.stateKey) + } + state.retain = false + r.mu.Unlock() + + state.ctxCancel() + if err := state.session.Cancel(context.Background()); err != nil { + r.log.Warn("idle session cancel failed", "run_id", state.runID, "state_key", state.stateKey, "err", err) + } +} + +// emitTerminalError synthesises error + done for a run that couldn't +// even be started. Stamps env.Trace from ctx so the gateway can +// attribute these frames to the same trace_id. +func (r *Router) emitTerminalError(ctx context.Context, runID, msg string) { + traceparent := "" + if carrier, ok := obslog.TraceFromContext(ctx); ok { + traceparent = carrier.String() + } + errEnv, err := proto.NewEnvelopeWithTrace(proto.TypeError, runID, proto.ErrorPayload{Error: msg}, traceparent) + if err == nil { + if sendErr := r.sender.Send(ctx, errEnv); sendErr != nil { + r.log.ErrorContext(ctx, "emit terminal error frame failed", "run_id", runID, "err", sendErr) + } + } + doneEnv, err := proto.NewEnvelopeWithTrace(proto.TypeDone, runID, proto.DonePayload{}, traceparent) + if err == nil { + if sendErr := r.sender.Send(ctx, doneEnv); sendErr != nil { + r.log.ErrorContext(ctx, "emit terminal done frame failed", "run_id", runID, "err", sendErr) + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/router_test.go b/apps/parsar-daemon/internal/dispatch/router_test.go new file mode 100644 index 000000000..0dd44ef35 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/router_test.go @@ -0,0 +1,691 @@ +package dispatch_test + +import ( + "context" + "errors" + "slices" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// --------------------------------------------------------------------- +// test doubles +// --------------------------------------------------------------------- + +// recSender records every Envelope. failNow makes the next Send fail +// (used to exercise the pump's error path). +type recSender struct { + mu sync.Mutex + frames []proto.Envelope + failNow bool +} + +func (s *recSender) Send(_ context.Context, env proto.Envelope) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failNow { + s.failNow = false + return errors.New("sender broken") + } + s.frames = append(s.frames, env) + return nil +} + +func (s *recSender) snapshot() []proto.Envelope { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]proto.Envelope, len(s.frames)) + copy(out, s.frames) + return out +} + +func (s *recSender) typesFor(runID string) []string { + out := []string{} + for _, f := range s.snapshot() { + if f.ID == runID { + out = append(out, f.Type) + } + } + return out +} + +// fakeSession is a controllable session. The test owns its out +// channel and manipulates outbound traffic / cancel observability. +type fakeSession struct { + cancelCalls int + submitCalls []permCall + askCalls []askCall + submitErr error + askErr error + cancelMu, submitMu sync.Mutex + askMu sync.Mutex + closeOutOnCancel bool + out chan<- proto.Envelope + closeOutOnCancelMu sync.Once + postCancelEnvelopes []proto.Envelope // emitted to out after Cancel fires + ctx context.Context +} + +type permCall struct { + id string + decision proto.PermissionDecisionPayload +} + +type askCall struct { + id string + decision proto.PromptForUserChoiceDecisionPayload +} + +func (s *fakeSession) Cancel(context.Context) error { + s.cancelMu.Lock() + s.cancelCalls++ + s.cancelMu.Unlock() + if s.closeOutOnCancel { + s.closeOutOnCancelMu.Do(func() { + for _, env := range s.postCancelEnvelopes { + s.out <- env + } + close(s.out) + }) + } + return nil +} + +func (s *fakeSession) SubmitPermission(_ context.Context, permID string, dec proto.PermissionDecisionPayload) error { + s.submitMu.Lock() + s.submitCalls = append(s.submitCalls, permCall{id: permID, decision: dec}) + s.submitMu.Unlock() + return s.submitErr +} + +func (s *fakeSession) SubmitPromptForUserChoice(_ context.Context, askID string, dec proto.PromptForUserChoiceDecisionPayload) error { + s.askMu.Lock() + s.askCalls = append(s.askCalls, askCall{id: askID, decision: dec}) + s.askMu.Unlock() + return s.askErr +} + +func (s *fakeSession) cancels() int { + s.cancelMu.Lock() + defer s.cancelMu.Unlock() + return s.cancelCalls +} + +func (s *fakeSession) submissions() []permCall { + s.submitMu.Lock() + defer s.submitMu.Unlock() + out := make([]permCall, len(s.submitCalls)) + copy(out, s.submitCalls) + return out +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- + +// newHarness builds a Router whose registry exposes a single +// claude_code factory that records inputs and exposes the in-flight +// session. +type harness struct { + router *dispatch.Router + sender *recSender + reg *agent.Registry + gotReq chan proto.PromptRequestPayload + gotSess chan *fakeSession +} + +func newHarness(t *testing.T) *harness { + return newHarnessWithIdleTimeout(t, time.Hour) +} + +func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness { + t.Helper() + h := &harness{ + sender: &recSender{}, + reg: agent.NewRegistry(), + gotReq: make(chan proto.PromptRequestPayload, 16), + gotSess: make(chan *fakeSession, 16), + } + h.reg.Register("claude_code", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess := &fakeSession{out: out, ctx: ctx} + h.gotReq <- req + h.gotSess <- sess + return sess, nil + }) + r, err := dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, IdleTimeout: idleTimeout}) + if err != nil { + t.Fatalf("dispatch.New: %v", err) + } + h.router = r + return h +} + +func TestCompletedSessionCancelsAfterIdleTimeout(t *testing.T) { + h := newHarnessWithIdleTimeout(t, 40*time.Millisecond) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_idle", proto.PromptRequestPayload{ + AgentKind: "claude_code", ConversationID: "conv-idle", AgentStateKey: "conv-idle/agent/claude_code", + }) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("Handle prompt_request: %v", err) + } + sess := <-h.gotSess + close(sess.out) + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active run cleanup") + + select { + case <-sess.ctx.Done(): + t.Fatal("completed session cancelled before idle timeout") + case <-time.After(15 * time.Millisecond): + } + waitFor(t, func() bool { return sess.cancels() == 1 }, "idle session cancellation") +} + +func TestNewPromptResetsCompletedSessionIdleTimeout(t *testing.T) { + h := newHarnessWithIdleTimeout(t, 80*time.Millisecond) + defer h.router.Shutdown(context.Background()) + + stateKey := "conv-renew/agent/claude_code" + first := mustEnv(t, proto.TypePromptRequest, "run_first", proto.PromptRequestPayload{ + AgentKind: "claude_code", ConversationID: "conv-renew", AgentStateKey: stateKey, + }) + if err := h.router.Handle(context.Background(), first); err != nil { + t.Fatalf("Handle first prompt: %v", err) + } + firstSession := <-h.gotSess + close(firstSession.out) + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "first run cleanup") + time.Sleep(50 * time.Millisecond) + + second := mustEnv(t, proto.TypePromptRequest, "run_second", proto.PromptRequestPayload{ + AgentKind: "claude_code", ConversationID: "conv-renew", AgentStateKey: stateKey, + }) + if err := h.router.Handle(context.Background(), second); err != nil { + t.Fatalf("Handle second prompt: %v", err) + } + secondSession := <-h.gotSess + + time.Sleep(45 * time.Millisecond) + if firstSession.cancels() != 0 { + t.Fatal("new prompt did not renew the completed session idle timeout") + } + close(secondSession.out) + waitFor(t, func() bool { return firstSession.cancels() == 1 }, "renewed idle session cancellation") +} + +func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { + t.Helper() + env, err := proto.NewEnvelope(typ, id, payload) + if err != nil { + t.Fatalf("NewEnvelope %s: %v", typ, err) + } + return env +} + +// --------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------- + +func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_1", proto.PromptRequestPayload{ + AgentKind: "claude_code", Prompt: "hi", ConversationID: "c1", + }) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("Handle prompt_request: %v", err) + } + + req := <-h.gotReq + if req.RunID != "run_1" || req.AgentKind != "claude_code" || req.Prompt != "hi" { + t.Errorf("factory got %+v, want run_1/claude_code/hi", req) + } + sess := <-h.gotSess + + // Session emits a delta + done; both should reach the sender. + sess.out <- mustEnv(t, proto.TypeDelta, "run_1", proto.DeltaPayload{Delta: "hello", Sequence: 1}) + sess.out <- mustEnv(t, proto.TypeDone, "run_1", proto.DonePayload{Content: "hello"}) + close(sess.out) + + waitForTypes(t, h.sender, "run_1", []string{proto.TypeDelta, proto.TypeDone}) + + // Pump should have removed the session. + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") +} + +func TestHandlePromptRequestRejectsDuplicateRunID(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_dup", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("first Handle: %v", err) + } + <-h.gotReq // drain first + sess := <-h.gotSess + + // Second prompt_request with same RunID should NOT spin up a second factory call. + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("duplicate Handle: %v", err) + } + + select { + case extra := <-h.gotReq: + t.Fatalf("factory invoked twice for duplicate run, second req=%+v", extra) + case <-time.After(50 * time.Millisecond): + } + close(sess.out) +} + +func TestHandlePromptRequestUnsupportedKindEmitsErrorDone(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_x", proto.PromptRequestPayload{AgentKind: "opencode"}) + err := h.router.Handle(context.Background(), env) + if !errors.Is(err, agent.ErrUnsupportedKind) { + t.Errorf("Handle unsupported = %v, want ErrUnsupportedKind", err) + } + got := h.sender.typesFor("run_x") + want := []string{proto.TypeError, proto.TypeDone} + if !slices.Equal(got, want) { + t.Errorf("sender frames for run_x = %v, want %v", got, want) + } +} + +func TestHandlePromptRequestMissingRunIDIsError(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err == nil { + t.Fatal("expected error on missing run id") + } +} + +func TestHandlePromptCancelInvokesSessionCancel(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_2", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + sess.closeOutOnCancel = true + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run_2", nil)); err != nil { + t.Fatalf("prompt_cancel: %v", err) + } + + waitFor(t, func() bool { return sess.cancels() == 1 }, "session.Cancel to fire once") + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "session to be cleaned up") +} + +func TestHandlePromptCancelUnknownRunIsNoop(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "ghost", nil)); err != nil { + t.Errorf("cancel for unknown run = %v, want nil", err) + } +} + +func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_p", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + + // Session emits a permission_request; pump should index it. + permEnv := mustEnv(t, proto.TypePermissionRequest, "run_p", proto.PermissionRequestPayload{ + RequestID: "perm_abcd1234", Tool: "Bash", Title: "rm -rf /", + }) + sess.out <- permEnv + // Wait until sender records — indexing happens before send. + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "permission_request to be forwarded") + + dec := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-1", Approved: true}) + if err := h.router.Handle(context.Background(), dec); err != nil { + t.Fatalf("permission_decision: %v", err) + } + calls := sess.submissions() + if len(calls) != 1 || calls[0].id != "perm_abcd1234" || !calls[0].decision.Approved { + t.Errorf("submissions = %+v, want one approved perm_abcd1234", calls) + } + assertDecisionAck(t, h.sender, "delivery-perm-1", true, "") + retry := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-2", Approved: true}) + if err := h.router.Handle(context.Background(), retry); err != nil { + t.Fatalf("idempotent permission replay: %v", err) + } + if calls := sess.submissions(); len(calls) != 1 { + t.Fatalf("idempotent replay reached agent twice: %+v", calls) + } + assertDecisionAck(t, h.sender, "delivery-perm-2", true, "") + conflict := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-3", Approved: false}) + if err := h.router.Handle(context.Background(), conflict); err != nil { + t.Fatalf("conflicting permission replay: %v", err) + } + assertDecisionAck(t, h.sender, "delivery-perm-3", false, "decision_conflict") + + close(sess.out) +} + +func TestPermissionCancelDeindexes(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_p2", proto.PromptRequestPayload{AgentKind: "claude_code"}) + _ = h.router.Handle(context.Background(), env) + <-h.gotReq + sess := <-h.gotSess + + sess.out <- mustEnv(t, proto.TypePermissionRequest, "run_p2", proto.PermissionRequestPayload{RequestID: "perm_xx", Tool: "Bash"}) + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "perm forwarded") + sess.out <- mustEnv(t, proto.TypePermissionCancel, "perm_xx", nil) + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 2 }, "perm_cancel forwarded") + + // A decision for the cancelled perm should be a no-op (session never sees it). + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePermissionDecision, "perm_xx", proto.PermissionDecisionPayload{DeliveryID: "delivery-cancelled"})); err != nil { + t.Fatalf("decision: %v", err) + } + if calls := sess.submissions(); len(calls) != 0 { + t.Errorf("expected zero submissions after cancel, got %+v", calls) + } + assertDecisionAck(t, h.sender, "delivery-cancelled", false, "not_pending") + + close(sess.out) +} + +func TestPermissionDecisionUnknownPermIsNoop(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePermissionDecision, "perm_unknown", proto.PermissionDecisionPayload{DeliveryID: "delivery-unknown-perm"})); err != nil { + t.Errorf("decision for unknown perm = %v, want nil", err) + } + assertDecisionAck(t, h.sender, "delivery-unknown-perm", false, "not_pending") +} + +func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_ask", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + + // Envelope.ID is the run id (server-side dispatch fans on it); the + // ask id rides on the payload. Daemon's indexPermissionFrame reads + // payload.AskID to seed askIndex. + askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask", proto.PromptForUserChoicePayload{ + AskID: "ask_abcd1234", + Question: "?", + Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}, + ToolUseID: "toolu_42", + }) + sess.out <- askEnv + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + + dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_abcd1234", proto.PromptForUserChoiceDecisionPayload{ + DeliveryID: "delivery-ask-1", Answers: []string{"yes"}, + }) + if err := h.router.Handle(context.Background(), dec); err != nil { + t.Fatalf("prompt_for_user_choice_decision: %v", err) + } + + sess.askMu.Lock() + calls := append([]askCall(nil), sess.askCalls...) + sess.askMu.Unlock() + if len(calls) != 1 || calls[0].id != "ask_abcd1234" { + t.Fatalf("askCalls = %+v, want one ask_abcd1234", calls) + } + if len(calls[0].decision.Answers) != 1 || calls[0].decision.Answers[0] != "yes" { + t.Errorf("answer payload mismatch: %+v", calls[0].decision) + } + assertDecisionAck(t, h.sender, "delivery-ask-1", true, "") + + // Cleanup contract: a successful decision drops the ask from both + // the router-level index and the session's pendingAsks set, so a + // stale retry short-circuits as "run gone". + if got := h.router.AskIndexLenForTest(); got != 0 { + t.Errorf("askIndex len = %d, want 0 after decision", got) + } + if got := h.router.PendingAsksLenForTest("run_ask"); got != 0 { + t.Errorf("pendingAsks len = %d, want 0 after decision", got) + } + + close(sess.out) +} + +// TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown locks in the +// other cleanup branch: when the session returns ErrUnknownAsk (timer +// already consumed the entry), the router still drops the index so a +// retry doesn't loop into Submit again. +func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_ask_u", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + sess.askErr = agent.ErrUnknownAsk + + askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask_u", proto.PromptForUserChoicePayload{ + AskID: "ask_xxxxxxxx", + Question: "?", + Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}, + ToolUseID: "toolu_y", + }) + sess.out <- askEnv + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + + dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_xxxxxxxx", proto.PromptForUserChoiceDecisionPayload{ + DeliveryID: "delivery-ask-gone", Answers: []string{"yes"}, + }) + if err := h.router.Handle(context.Background(), dec); err != nil { + t.Fatalf("prompt_for_user_choice_decision: %v", err) + } + + if got := h.router.AskIndexLenForTest(); got != 0 { + t.Errorf("askIndex len = %d, want 0 after ErrUnknownAsk", got) + } + if got := h.router.PendingAsksLenForTest("run_ask_u"); got != 0 { + t.Errorf("pendingAsks len = %d, want 0 after ErrUnknownAsk", got) + } + assertDecisionAck(t, h.sender, "delivery-ask-gone", false, "not_pending") + + close(sess.out) +} + +func TestPromptForUserChoiceDecisionKeepsIndexOnTransientAgentError(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + env := mustEnv(t, proto.TypePromptRequest, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "claude_code"}) + if err := h.router.Handle(context.Background(), env); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + sess.askErr = errors.New("temporary stdin failure") + + sess.out <- mustEnv(t, proto.TypePromptForUserChoice, "run_ask_retry", proto.PromptForUserChoicePayload{ + AskID: "ask_retry", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Question: "Retry?"}}, + }) + waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + + decision := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_retry", proto.PromptForUserChoiceDecisionPayload{ + DeliveryID: "delivery-ask-retry", Answers: []string{"yes"}, + }) + if err := h.router.Handle(context.Background(), decision); err != nil { + t.Fatalf("first decision: %v", err) + } + assertDecisionAck(t, h.sender, "delivery-ask-retry", false, "runtime_error") + if got := h.router.AskIndexLenForTest(); got != 1 { + t.Fatalf("askIndex len = %d, want 1 after transient error", got) + } + if got := h.router.PendingAsksLenForTest("run_ask_retry"); got != 1 { + t.Fatalf("pendingAsks len = %d, want 1 after transient error", got) + } + + sess.askErr = nil + if err := h.router.Handle(context.Background(), decision); err != nil { + t.Fatalf("retry decision: %v", err) + } + assertDecisionAck(t, h.sender, "delivery-ask-retry", true, "") + if got := h.router.AskIndexLenForTest(); got != 0 { + t.Fatalf("askIndex len = %d, want 0 after successful retry", got) + } + close(sess.out) +} + +func TestPromptForUserChoiceDecisionUnknownAskIsNoop(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_unknown", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "delivery-unknown-ask"})); err != nil { + t.Errorf("decision for unknown ask = %v, want nil", err) + } + assertDecisionAck(t, h.sender, "delivery-unknown-ask", false, "not_pending") +} + +func assertDecisionAck(t *testing.T, sender *recSender, deliveryID string, applied bool, errorCode string) { + t.Helper() + frames := sender.snapshot() + for index := len(frames) - 1; index >= 0; index-- { + if frames[index].Type != proto.TypeInteractionDecisionAck { + continue + } + var ack proto.InteractionDecisionAckPayload + if err := frames[index].DecodePayload(&ack); err != nil { + t.Fatalf("decode decision ack: %v", err) + } + if ack.DeliveryID == deliveryID { + if ack.Applied != applied || ack.ErrorCode != errorCode { + t.Fatalf("decision ack = %+v, want applied=%v error_code=%q", ack, applied, errorCode) + } + return + } + } + t.Fatalf("no decision ack for delivery %q in %+v", deliveryID, frames) +} + +func TestHandleDeviceShutdownCancelsAllSessions(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + for _, rid := range []string{"r1", "r2", "r3"} { + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, rid, proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatalf("start %s: %v", rid, err) + } + <-h.gotReq + } + sessions := make([]*fakeSession, 3) + for i := range sessions { + sessions[i] = <-h.gotSess + sessions[i].closeOutOnCancel = true + } + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeDeviceShutdown, "", proto.DeviceShutdownPayload{Reason: "reap"})); err != nil { + t.Fatalf("device_shutdown: %v", err) + } + for _, s := range sessions { + waitFor(t, func() bool { return s.cancels() == 1 }, "each session.Cancel to fire") + } +} + +func TestHandleUnknownTypeIsNoop(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + if err := h.router.Handle(context.Background(), proto.Envelope{Type: "fancy_new_event"}); err != nil { + t.Errorf("unknown type Handle = %v, want nil", err) + } +} + +func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { + h := newHarness(t) + if err := h.router.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown: %v", err) + } + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "r", proto.PromptRequestPayload{AgentKind: "claude_code"})) + if !errors.Is(err, dispatch.ErrRouterClosed) { + t.Errorf("post-shutdown Handle = %v, want ErrRouterClosed", err) + } +} + +func TestShutdownWaitsForPumpDrain(t *testing.T) { + h := newHarness(t) + + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "rs", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatalf("prompt_request: %v", err) + } + <-h.gotReq + sess := <-h.gotSess + + // Background: emit one frame then close out shortly after + // shutdown is asked for. + go func() { + sess.out <- mustEnv(t, proto.TypeDelta, "rs", proto.DeltaPayload{Delta: "x"}) + time.Sleep(20 * time.Millisecond) + close(sess.out) + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := h.router.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown returned %v before pump drained", err) + } + if h.router.ActiveRuns() != 0 { + t.Errorf("ActiveRuns after Shutdown = %d, want 0", h.router.ActiveRuns()) + } +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- + +func waitForTypes(t *testing.T, s *recSender, runID string, want []string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + got := s.typesFor(runID) + if slices.Equal(got, want) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("never observed types %v for run %s; got %v", want, runID, s.typesFor(runID)) +} + +func waitFor(t *testing.T, cond func() bool, what string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timeout waiting for %s", what) +} diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/parsar-daemon/internal/dispatch/shutdown.go new file mode 100644 index 000000000..a59808ead --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/shutdown.go @@ -0,0 +1,199 @@ +package dispatch + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type shutdownAttempt struct { + done chan struct{} + err error +} + +// Shutdown stops admission and waits for owned cleanup. A later call retries +// only failed cleanup; caller timeouts never abandon or duplicate in-flight work. +func (r *Router) Shutdown(ctx context.Context) error { + r.mu.Lock() + if previous := r.shutdownAttempt; previous != nil { + select { + case <-previous.done: + if previous.err == nil { + r.mu.Unlock() + return nil + } + default: + r.mu.Unlock() + return waitShutdown(ctx, previous) + } + } + + first := !r.closed + var victims []sessionCancellation + for _, state := range r.sessions { + state.retain = false + if state.preparedHandoff != nil { + victims = append(victims, r.sessionCancellationLocked(state, true)) + } else if first { + victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) + } + } + if first { + r.closed = true + for _, states := range r.idle { + for state := range states { + state.retain = false + if state.idleTimer != nil { + state.idleTimer.Stop() + } + victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) + } + } + r.idle = make(map[string]map[*sessionState]struct{}) + // Prepared release claims exist before this signal can interrupt output. + close(r.shutdownCh) + } + preparations := r.closePendingPreparationsLocked() + attempt := &shutdownAttempt{done: make(chan struct{})} + r.shutdownAttempt = attempt + // Keep the WaitGroup non-zero until all cancellation dispatch is complete. + r.shutdownWG.Add(1) + r.mu.Unlock() + + for _, p := range preparations { + go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() + } + go r.runShutdownAttempt(attempt, victims) + return waitShutdown(ctx, attempt) +} + +func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionCancellation) { + var releaseErr error + for _, victim := range victims { + if victim.handoff != nil { + // The cleanup operation has its own fixed native deadline. The + // Shutdown caller's deadline only bounds its wait for this attempt. + if err := r.awaitPreparedNativeRelease(context.Background(), victim.release, victim.attempt); err != nil { + releaseErr = errors.Join(releaseErr, fmt.Errorf("dispatch: prepared run %s: %w", victim.runID, err)) + } + continue + } + victim.ctxCancel() + if victim.session != nil { + if err := victim.session.Cancel(context.Background()); err != nil { + r.log.Warn("session.Cancel failed", "run_id", victim.runID, "err", err) + } + } + } + r.shutdownWG.Done() + if releaseErr != nil { + // A failed prepared release may leave its output consumer blocked. Do + // not wait for all workers; retain the exact target for the next call. + r.finishShutdownAttempt(attempt, releaseErr) + return + } + + r.shutdownWG.Wait() + r.mu.Lock() + if r.workspaceWrite != nil && r.workspaceWrite.uncertain { + attempt.err = errors.Join(attempt.err, errors.New("dispatch: local workspace write remains uncertain")) + } + for _, p := range r.preparations { + if p.owns { + cause := p.closeErr + if cause == nil { + cause = errors.New("cleanup has not settled") + } + attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: preparation %s: %w", p.status.Handle, cause)) + } + } + close(attempt.done) + r.mu.Unlock() +} + +func (r *Router) finishShutdownAttempt(attempt *shutdownAttempt, err error) { + r.mu.Lock() + attempt.err = errors.Join(attempt.err, err) + close(attempt.done) + r.mu.Unlock() +} + +func waitShutdown(ctx context.Context, attempt *shutdownAttempt) error { + select { + case <-attempt.done: + return attempt.err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) error { + var payload proto.DeviceShutdownPayload + _ = env.DecodePayload(&payload) // body optional + r.log.InfoContext(ctx, "device_shutdown received, cancelling runs", "reason", payload.Reason, "active_runs", r.ActiveRuns()) + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + victims := make([]sessionCancellation, 0, len(r.sessions)) + for _, state := range r.sessions { + state.retain = false + victims = append(victims, r.sessionCancellationLocked(state, true)) + } + for _, states := range r.idle { + for state := range states { + state.retain = false + if state.idleTimer != nil { + state.idleTimer.Stop() + } + victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) + } + } + r.idle = make(map[string]map[*sessionState]struct{}) + preparations := r.closePendingPreparationsLocked() + r.mu.Unlock() + for _, p := range preparations { + go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() + } + r.cancelSessions(ctx, victims) + return nil +} + +type sessionCancellation struct { + runID string + ctxCancel context.CancelFunc + session agent.Session + handoff *preparedHandoff + release *preparedRelease + attempt *preparedReleaseAttempt +} + +func (r *Router) sessionCancellationLocked(state *sessionState, retry bool) sessionCancellation { + if state.preparedHandoff != nil { + release, attempt := r.claimPreparedReleaseLocked(state, true, "", retry) + return sessionCancellation{runID: state.runID, handoff: state.preparedHandoff, release: release, attempt: attempt} + } + return sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session} +} + +func (r *Router) cancelSessions(ctx context.Context, sessions []sessionCancellation) { + for _, session := range sessions { + if session.handoff != nil { + if err := r.awaitPreparedRelease(ctx, session.handoff, session.release, session.attempt); err != nil { + r.log.Warn("prepared session release failed", "run_id", session.runID, "err", err) + } + continue + } + session.ctxCancel() + if session.session == nil { + continue + } + if err := session.session.Cancel(ctx); err != nil { + r.log.Warn("session.Cancel failed", "run_id", session.runID, "err", err) + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/steering.go b/apps/parsar-daemon/internal/dispatch/steering.go new file mode 100644 index 000000000..2b5667e07 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/steering.go @@ -0,0 +1,196 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Retain all attempts for the active run; reject overflow rather than evicting +// receipts and risking a duplicate native input. Durable recovery is server-owned. +const ( + maxSteeringInputs = 256 + steeringCallTimeout = 10 * time.Second + steeringSendTimeout = 5 * time.Second +) + +type steeringReceipt struct { + fingerprint [32]byte + ack proto.PromptSteerAckPayload + durable bool +} + +func (r *Router) handlePromptSteer(ctx context.Context, env proto.Envelope) error { + var input proto.PromptSteerPayload + ack := proto.PromptSteerAckPayload{} + if err := env.DecodePayload(&input); err != nil { + ack.ErrorCode, ack.Error = "invalid_input", "Invalid steering payload." + } else { + ack.InputID = input.InputID + if env.ID == "" || strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || strings.TrimSpace(input.Text) == "" { + ack.ErrorCode, ack.Error = "invalid_input", "Run ID, input ID (up to 256 bytes), and non-empty text are required." + } else { + pending := r.queueSteering(ctx, env, input) + if pending == nil { + return nil + } + ack = *pending + } + } + return r.sendSteeringAck(ctx, env, ack) +} + +func (r *Router) sendSteeringAck(ctx context.Context, env proto.Envelope, ack proto.PromptSteerAckPayload) error { + reply, err := proto.NewEnvelopeWithTrace(proto.TypePromptSteerAck, env.ID, ack, env.Trace) + if err != nil { + return err + } + return r.sender.Send(ctx, reply) +} + +func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input proto.PromptSteerPayload) *proto.PromptSteerAckPayload { + ack := proto.PromptSteerAckPayload{InputID: input.InputID} + r.mu.Lock() + defer r.mu.Unlock() + state := r.sessions[env.ID] + if state == nil || r.closed { + ack.ErrorCode, ack.Error = "run_inactive", "The run is no longer active." + return &ack + } + fingerprint := sha256.Sum256([]byte(input.Text)) + if previous, ok := state.steering[input.InputID]; ok { + if previous.fingerprint != fingerprint || previous.durable != input.DurableReceipt { + ack.ErrorCode, ack.Error = "input_conflict", "This input ID was already used with different text." + return &ack + } + if state.steeringClosed || previous.ack.ErrorCode != "not_ready" && previous.ack.ErrorCode != "busy" { + return &previous.ack + } + } else if len(state.steering) >= maxSteeringInputs { + ack.ErrorCode, ack.Error = "input_limit", "The active run has reached its steering input limit." + return &ack + } + if state.steeringClosed { + ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." + return &ack + } + if state.steering == nil { + state.steering = make(map[string]steeringReceipt) + } + ack.ErrorCode, ack.Error = "not_ready", "The run is still starting." + // Bind input identity before any retryable state so changed text cannot + // slip through a startup or in-flight retry. + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + if state.session == nil { + return &ack + } + session := state.session + steerer, ok := session.(agent.Steerer) + if !ok { + ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." + return &ack + } + if input.DurableReceipt { + if _, ok := session.(agent.DurableSteerer); !ok || !state.releaseOnCompletion { + ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported release-on-completion run." + return &ack + } + } + if state.steerBusy { + ack.ErrorCode, ack.Error = "busy", "Another input is awaiting an engine receipt; retry this input later." + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + return &ack + } + session, finishOperation, ready := r.preparedOperationLocked(state) + if !ready { + ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." + return &ack + } + ack.ErrorCode, ack.Error = "in_flight", "This input is awaiting an engine receipt." + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + state.steerBusy = true + finished := make(chan struct{}) + state.steeringDone = finished + r.shutdownWG.Add(1) + go func() { + defer r.shutdownWG.Done() + defer finishOperation() + defer func() { + r.mu.Lock() + state.steerBusy = false + close(finished) + r.mu.Unlock() + }() + ctx, stop := r.shutdownContext(ctx) + defer stop() + var err error + if input.DurableReceipt { + err = r.steerDurably(ctx, state, session, env, input, fingerprint) + } else { + callCtx, cancel := context.WithTimeout(ctx, steeringCallTimeout) + err = steerer.Steer(callCtx, input) + cancel() + } + r.publishSteeringReceipt(ctx, state, env, input, fingerprint, steeringResult(input.InputID, err)) + }() + return nil +} + +func steeringResult(inputID string, err error) proto.PromptSteerAckPayload { + ack := proto.PromptSteerAckPayload{InputID: inputID} + switch { + case errors.Is(err, agent.ErrSteeringNotReady): + ack.ErrorCode, ack.Error = "not_ready", err.Error() + case errors.Is(err, agent.ErrSteeringInactive): + ack.ErrorCode, ack.Error = "run_inactive", err.Error() + case errors.Is(err, agent.ErrSteeringRejected): + ack.ErrorCode, ack.Error = "rejected", err.Error() + case err != nil: + // A timeout or broken connection may follow native acceptance. Preserve + // the uncertainty and never automatically send this input again. + ack.ErrorCode, ack.Error = "outcome_unknown", err.Error() + default: + ack.Accepted = true + } + return ack +} + +// shutdownContext releases cooperating work when the router shuts down. +func (r *Router) shutdownContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + go func() { + select { + case <-r.shutdownCh: + cancel() + case <-ctx.Done(): + } + }() + return ctx, cancel +} + +// Close admission before taking the last worker: its lifetime includes the +// receipt send, so a durable Done cannot close the gateway subscription first. +func (r *Router) finishSteering(state *sessionState) error { + r.mu.Lock() + state.steeringClosed = true + finished := state.steeringDone + r.mu.Unlock() + if finished == nil { + return nil + } + timer := time.NewTimer(steeringCallTimeout + steeringSendTimeout) + defer timer.Stop() + select { + case <-finished: + return nil + case <-r.shutdownCh: + return context.Canceled + case <-timer.C: + return context.DeadlineExceeded + } +} diff --git a/apps/parsar-daemon/internal/dispatch/steering_lifetime.go b/apps/parsar-daemon/internal/dispatch/steering_lifetime.go new file mode 100644 index 000000000..17b8f944c --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/steering_lifetime.go @@ -0,0 +1,44 @@ +package dispatch + +import ( + "context" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.Session, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { + ctx, cancel := context.WithCancel(state.ctx) + defer cancel() + stopShutdown := context.AfterFunc(sendCtx, cancel) + defer stopShutdown() + timer := time.AfterFunc(steeringCallTimeout, cancel) + defer timer.Stop() + var once sync.Once + return session.(agent.DurableSteerer).SteerWithReceipt(ctx, input, func() { + once.Do(func() { + if !timer.Stop() || ctx.Err() != nil { + return + } + ack := proto.PromptSteerAckPayload{InputID: input.InputID, Written: true} + if !r.publishSteeringReceipt(sendCtx, state, env, input, fingerprint, ack) { + cancel() + } + }) + }) +} + +func (r *Router) publishSteeringReceipt(ctx context.Context, state *sessionState, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte, ack proto.PromptSteerAckPayload) bool { + r.mu.Lock() + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + r.mu.Unlock() + sendCtx, cancel := context.WithTimeout(ctx, steeringSendTimeout) + defer cancel() + if err := r.sendSteeringAck(sendCtx, env, ack); err != nil { + r.log.WarnContext(ctx, "steering receipt delivery failed", "run_id", env.ID, "input_id", input.InputID, "err", err) + return false + } + return true +} diff --git a/apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go b/apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go new file mode 100644 index 000000000..24efe1841 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go @@ -0,0 +1,153 @@ +package dispatch_test + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type durableSteeringSession struct { + *steeringSession + phased func(context.Context, proto.PromptSteerPayload, func()) error +} + +func (s *durableSteeringSession) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + return s.phased(ctx, input, written) +} + +func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + var session *fakeSession + var calls atomic.Int32 + release := make(chan struct{}) + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: session}, phased: func(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + calls.Add(1) + written() + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }}, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "durable", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + input := proto.PromptSteerPayload{InputID: "extra", Text: "additional", DurableReceipt: true} + env := mustEnv(t, proto.TypePromptSteer, "durable", input) + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "durable", "extra"); !ack.Written || ack.Accepted || ack.ErrorCode != "" { + t.Fatalf("write phase: %+v", ack) + } + time.Sleep(11 * time.Second) + if len(h.sender.snapshot()) != 1 { + t.Fatal("native wait ended at transport deadline") + } + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "durable", "extra"); !ack.Written || ack.Accepted { + t.Fatalf("cached phase: %+v", ack) + } + close(release) + waitFor(t, func() bool { return len(h.sender.snapshot()) == 3 }, "native acceptance") + if ack := lastSteeringAck(t, h.sender, "durable", "extra"); !ack.Accepted || ack.Written { + t.Fatalf("final phase: %+v", ack) + } + session.out <- mustEnv(t, proto.TypeDone, "durable", proto.DonePayload{}) + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "completion") + frames := h.sender.snapshot() + if calls.Load() != 1 || frames[len(frames)-1].Type != proto.TypeDone { + t.Fatal("replayed input or incorrect completion order") + } +} + +func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { + for _, phase := range []string{"blocked-write", "written"} { + t.Run(phase, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + exited := make(chan struct{}) + h.reg.Register("codex", func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}}, phased: func(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { + defer close(exited) + if phase == "written" { + written() + } + <-ctx.Done() + return ctx.Err() + }}, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Text: "text", DurableReceipt: true})); err != nil { + t.Fatal(err) + } + if phase == "blocked-write" { + select { + case <-exited: + case <-time.After(12 * time.Second): + t.Fatal("blocked write was not bounded") + } + waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "unknown receipt") + if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.Written || ack.Accepted || ack.ErrorCode != "outcome_unknown" { + t.Fatalf("transport uncertainty: %+v", ack) + } + } else { + waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "written phase") + stopCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + if err := h.router.Shutdown(stopCtx); err != nil { + t.Fatal(err) + } + select { + case <-exited: + default: + t.Fatal("native waiter leaked") + } + } + }) + } +} + +func TestDurableSteeringRequiresOptInAndAdapter(t *testing.T) { + for _, supported := range []bool{false, true} { + t.Run(map[bool]string{false: "old-adapter", true: "retained-run"}[supported], func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + h.reg.Register("codex", func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + s := &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { t.Error("unexpected legacy call"); return nil }} + if !supported { + return s, nil + } + return &durableSteeringSession{steeringSession: s, phased: func(context.Context, proto.PromptSteerPayload, func()) error { + t.Error("unexpected phased call") + return nil + }}, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: !supported})); err != nil { + t.Fatal(err) + } + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Text: "text", DurableReceipt: true})); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.ErrorCode != "unsupported" { + t.Fatalf("capability gate: %+v", ack) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/steering_test.go b/apps/parsar-daemon/internal/dispatch/steering_test.go new file mode 100644 index 000000000..d7c01da8d --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/steering_test.go @@ -0,0 +1,266 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type steeringSession struct { + *fakeSession + steer func(context.Context, proto.PromptSteerPayload) error +} + +func (s *steeringSession) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.steer(ctx, input) +} + +func TestSteeringReceiptsAndRetries(t *testing.T) { + for _, engineError := range []error{nil, errors.New("native connection lost after write")} { + name := "accepted" + if engineError != nil { + name = "uncertain" + } + t.Run(name, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls, starts := 0, 0 + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + starts++ + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(ctx context.Context, input proto.PromptSteerPayload) error { + calls++ + if _, ok := ctx.Deadline(); !ok { + t.Error("native request has no deadline") + } + if input.InputID != "input-1" || input.Text != "additional text" { + t.Errorf("input lost: %+v", input) + } + if len(h.sender.snapshot()) != 0 { + t.Error("ack sent before engine accepted input") + } + return engineError + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + input := proto.PromptSteerPayload{InputID: "input-1", Text: "additional text"} + env := mustEnv(t, proto.TypePromptSteer, "run-1", input) + // An ack transport failure must not cause another native invocation. + h.sender.failNow = true + if err := h.router.Handle(ctx, env); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { h.sender.mu.Lock(); defer h.sender.mu.Unlock(); return !h.sender.failNow }, "failed ack send") + for range 2 { + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + ack := lastSteeringAck(t, h.sender, "run-1", "input-1") + if ack.Accepted != (engineError == nil) { + t.Fatalf("receipt: %+v", ack) + } + if engineError != nil && ack.ErrorCode != "outcome_unknown" { + t.Fatalf("uncertainty lost: %+v", ack) + } + } + input.Text = "changed text" + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { + t.Fatalf("conflict: %+v", ack) + } + if calls != 1 || starts != 1 { + t.Fatalf("native calls=%d, runs started=%d", calls, starts) + } + }) + } +} + +func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls := 0 + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + calls++ + if calls == 1 { + return agent.ErrSteeringNotReady + } + return nil + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + input := proto.PromptSteerPayload{InputID: "input-1", Text: "extra"} + env := mustEnv(t, proto.TypePromptSteer, "run-1", input) + for _, expected := range []string{"not_ready", ""} { + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != expected { + t.Fatalf("expected %q: %+v", expected, ack) + } + if expected == "not_ready" { + changed := proto.PromptSteerPayload{InputID: "input-1", Text: "different during startup"} + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", changed)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { + t.Fatalf("startup identity changed: %+v", ack) + } + } + } + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-1", nil)); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "cancel cleanup") + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { + t.Fatalf("inactive: %+v", ack) + } + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatal(err) + } + session := <-h.gotSess + defer close(session.out) + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "unsupported" { + t.Fatalf("unsupported: %+v", ack) + } + input.Text = " \n " + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "invalid_input" { + t.Fatalf("invalid: %+v", ack) + } +} + +func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + entered, release := make(chan struct{}), make(chan struct{}) + defer close(release) + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + close(entered) + <-release + return agent.ErrSteeringRejected + }, + }, nil + }) + ctx := context.Background() + for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "claude_code"}} { + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { + t.Fatal(err) + } + } + other := <-h.gotSess + other.closeOutOnCancel = true + returned := make(chan error, 1) + go func() { + returned <- h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "slow", Text: "extra"})) + }() + select { + case err := <-returned: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("steering blocked dispatch") + } + <-entered + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { + t.Fatal(err) + } + if other.cancels() != 1 { + t.Fatal("other run cancellation blocked") + } +} + +func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { + t.Helper() + frames := sender.snapshot() + if len(frames) == 0 { + t.Fatal("missing ack") + } + env := frames[len(frames)-1] + var ack proto.PromptSteerAckPayload + if env.Type != proto.TypePromptSteerAck || env.ID != runID { + t.Fatalf("incorrect routing: %+v", env) + } + if err := env.DecodePayload(&ack); err != nil { + t.Fatal(err) + } + if ack.InputID != inputID { + t.Fatalf("incorrect input: %+v", ack) + } + return ack +} + +func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls := 0 + h.reg.Register("codex", func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + calls++ + return nil + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + for i := range 257 { + input := proto.PromptSteerPayload{InputID: fmt.Sprintf("input-%d", i), Text: "extra"} + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { + t.Fatal(err) + } + ack := lastSteeringAck(t, h.sender, "run-1", input.InputID) + if i < 256 && !ack.Accepted || i == 256 && ack.ErrorCode != "input_limit" { + t.Fatalf("input %d: %+v", i, ack) + } + } + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "input-0", Text: "extra"})); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-0"); !ack.Accepted || calls != 256 { + t.Fatalf("receipt evicted or input redelivered: %+v, calls=%d", ack, calls) + } +} + +func handleSteeringAndWait(t *testing.T, h *harness, env proto.Envelope) error { + t.Helper() + before := len(h.sender.snapshot()) + if err := h.router.Handle(context.Background(), env); err != nil { + return err + } + waitFor(t, func() bool { return len(h.sender.snapshot()) > before }, "steering ack") + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go new file mode 100644 index 000000000..6dad38d69 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -0,0 +1,82 @@ +package dispatch_test + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type directoryTestReader struct{ calls atomic.Int32 } + +func (r *directoryTestReader) ListWorkspaceDirectory(_ context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { + if path != "" || limit != 2 { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid + } + r.calls.Add(1) + size := int64(3) + return agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{{Name: "file", Kind: "file", SizeBytes: &size}}, Truncated: true}, nil +} + +type directoryPreparation struct { + *controlledPreparation + *directoryTestReader +} +type directorySession struct { + *fakeSession + *directoryTestReader +} + +func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { + sender := &recSender{} + reader := &directoryTestReader{} + p := &directoryPreparation{&controlledPreparation{closed: make(chan struct{})}, reader} + p.start = func(ctx context.Context, _ string, _ string, out chan<- proto.Envelope) (agent.Session, error) { + return &directorySession{&fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, reader}, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: "environment", MaxEntries: 2} + for _, phase := range []string{"idle", "active"} { + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) + result := waitWorkspaceRead(t, sender, phase) + if result.Outcome != "completed" || !result.CloseAcknowledged || result.Directory == nil || !result.Directory.Truncated || len(result.Directory.Entries) != 1 || len(result.Data) != 0 { + t.Fatal(result) + } + bad := request + bad.EnvironmentID = "another-environment" + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase+"-foreign", bad)) + if got := waitWorkspaceRead(t, sender, phase+"-foreign"); got.ErrorCode != "resource_unavailable" { + t.Fatal(got) + } + if phase == "idle" { + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "start"})) + waitPreparationStatus(t, sender, "prepare", "started", "") + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "stale", request)) + if got := waitWorkspaceRead(t, sender, "stale"); got.Outcome != "rejected" { + t.Fatal(got) + } + request.Handle, request.RunID = "", "run" + } + } + for index, bad := range []proto.WorkspaceReadPayload{ + {Operation: "directory", RunID: "run", EnvironmentID: "environment", MaxEntries: 2, MaxBytes: 1}, + {Operation: "directory", RunID: "run", EnvironmentID: "environment", MaxEntries: proto.WorkspaceDirectoryMaxEntries + 1}, + {Operation: "directory", Handle: ready.Handle, RunID: "run", EnvironmentID: "environment", MaxEntries: 2}, + {Operation: "recursive", RunID: "run", EnvironmentID: "environment", MaxEntries: 2}, + } { + id := fmt.Sprintf("invalid-%d", index) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, id, bad)) + if got := waitWorkspaceRead(t, sender, id); got.Outcome != "rejected" || got.ErrorCode != "invalid_request" || got.Directory != nil { + t.Fatal("malformed directory control reached a resource", got) + } + } + if reader.calls.Load() != 2 { + t.Fatal("wrong owner was observed", reader.calls.Load()) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export.go b/apps/parsar-daemon/internal/dispatch/workspace_export.go new file mode 100644 index 000000000..112be4db4 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_export.go @@ -0,0 +1,134 @@ +package dispatch + +import ( + "context" + "errors" + "io" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type workspaceExport struct { + id string + requests chan proto.WorkspaceExportPayload + cancel context.CancelFunc +} + +func (r *Router) handleWorkspaceExport(ctx context.Context, env proto.Envelope) error { + var request proto.WorkspaceExportPayload + if len(env.ID) == 0 || len(env.ID) > proto.WorkspaceReadMaxIDBytes || len(env.Payload) > proto.WorkspaceReadMaxRequestBytes || env.DecodePayload(&request) != nil || !proto.ValidWorkspaceExportRequest(request) { + return errors.New("dispatch: invalid workspace export request") + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + u := r.workspaceExport + if request.Step != "begin" { + if u == nil || u.id != env.ID { + r.mu.Unlock() + return r.sendWorkspaceExport(ctx, env.ID, proto.WorkspaceExportResultPayload{Outcome: "rejected", ErrorCode: "resource_unavailable"}) + } + if request.Step == "cancel" { + u.cancel() + r.mu.Unlock() + return nil + } + select { + case u.requests <- request: + r.mu.Unlock() + return nil + default: + u.cancel() + r.mu.Unlock() + return errors.New("dispatch: workspace export request already pending") + } + } + _, code := r.workspaceResourceLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) + p := r.preparations[request.Handle] + if u != nil || r.workspaceWrite != nil || !r.localWorkspace.CanExport() || code != "" || p == nil || !p.workspaceReadOnly { + r.mu.Unlock() + return r.sendWorkspaceExport(ctx, env.ID, proto.WorkspaceExportResultPayload{Outcome: "rejected", ErrorCode: "resource_unavailable"}) + } + owner, stop := r.shutdownContext(p.ctx) + owner, cancel := context.WithTimeout(owner, 180*time.Second) + u = &workspaceExport{id: env.ID, requests: make(chan proto.WorkspaceExportPayload, 1), cancel: func() { cancel(); stop() }} + u.requests <- request + r.workspaceExport = u + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.runWorkspaceExport(owner, u) + return nil +} + +func (r *Router) runWorkspaceExport(ctx context.Context, u *workspaceExport) { + defer r.shutdownWG.Done() + reader, writer := io.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + err := r.localWorkspace.ExportOutputs(ctx, writer) + _ = writer.CloseWithError(err) + }() + defer func() { + u.cancel() + _ = reader.Close() + <-done + r.mu.Lock() + if r.workspaceExport == u { + r.workspaceExport = nil + } + r.mu.Unlock() + }() + // Closing the reader unblocks a pending pipe read on cancellation or shutdown. + stopRead := context.AfterFunc(ctx, func() { _ = reader.CloseWithError(ctx.Err()) }) + defer stopRead() + var offset int64 + buffer := make([]byte, proto.WorkspaceExportChunkBytes) + for { + select { + case request := <-u.requests: + if request.Offset != offset { + _ = r.sendWorkspaceExport(ctx, u.id, proto.WorkspaceExportResultPayload{Outcome: "failed", Offset: offset, ErrorCode: "invalid_request"}) + return + } + case <-ctx.Done(): + return + } + n, err := reader.Read(buffer) + result := proto.WorkspaceExportResultPayload{Offset: offset} + switch { + case err == io.EOF: + result.Outcome = "completed" + case err != nil || n == 0 || int64(n) > proto.WorkspaceExportMaxBytes-offset: + result.Outcome, result.ErrorCode = "failed", "export_failed" + default: + result.Outcome, result.Data = "chunk", buffer[:n] + offset += int64(n) + } + if result.Outcome == "completed" { + // The next owner may start immediately after receiving completion. + <-done + r.mu.Lock() + if r.workspaceExport == u { + r.workspaceExport = nil + } + r.mu.Unlock() + } + if r.sendWorkspaceExport(ctx, u.id, result) != nil || result.Outcome != "chunk" { + return + } + } +} + +func (r *Router) sendWorkspaceExport(ctx context.Context, id string, result proto.WorkspaceExportResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + env, err := proto.NewEnvelope(proto.TypeWorkspaceExportResult, id, result) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go new file mode 100644 index 000000000..490130f5b --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go @@ -0,0 +1,170 @@ +package dispatch + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +type exportSender struct{ replies chan proto.Envelope } + +func (s exportSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.replies <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { + t.Helper() + workspace, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + helper := filepath.Join(dir, "export") + if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { + t.Fatal(err) + } + environment, session := uuid.NewString(), uuid.NewString() + for key, value := range map[string]string{"PARSAR_RUNTIME_ENVIRONMENT_ID": environment, "PARSAR_RUNTIME_SESSION_ID": session, "PARSAR_RUNTIME_WORKSPACE": workspace, "PARSAR_RUNTIME_DIRECTORY_HELPER": helper, "PARSAR_RUNTIME_EXPORT_HELPER": helper, "PARSAR_RUNTIME_WRITE_HELPER": "", "PARSAR_RUNTIME_STAGING": "", "PARSAR_RUNTIME_NETWORK_ACCESS": ""} { + t.Setenv(key, value) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + sender := exportSender{make(chan proto.Envelope, 8)} + r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, LocalWorkspace: binding}) + if err != nil { + t.Fatal(err) + } + handle := uuid.NewString() + r.preparations[handle] = &preparationState{workspaceReadOnly: true, environmentID: environment, owns: true, ctx: context.Background(), deadline: time.Now().Add(time.Hour), status: proto.PreparationStatusPayload{State: "ready"}} + t.Cleanup(func() { + r.mu.Lock() + delete(r.preparations, handle) + r.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return r, sender, proto.WorkspaceExportPayload{Step: "begin", Handle: handle, EnvironmentID: environment} +} + +func sendExport(t *testing.T, r *Router, id string, p proto.WorkspaceExportPayload) { + t.Helper() + env, err := proto.NewEnvelope(proto.TypeWorkspaceExport, id, p) + if err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } +} +func readExport(t *testing.T, s exportSender) proto.WorkspaceExportResultPayload { + t.Helper() + select { + case env := <-s.replies: + var p proto.WorkspaceExportResultPayload + if env.DecodePayload(&p) != nil { + t.Fatal("invalid reply") + } + return p + case <-time.After(3 * time.Second): + t.Fatal("missing export reply") + } + return proto.WorkspaceExportResultPayload{} +} + +func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing.T) { + r, s, request := exporterRouter(t, "head -c 131089 /dev/zero") + for _, field := range []string{"environment", "handle"} { + bad := request + if field == "environment" { + bad.EnvironmentID = uuid.NewString() + } else { + bad.Handle = uuid.NewString() + } + sendExport(t, r, uuid.NewString(), bad) + if result := readExport(t, s); result.Outcome != "rejected" { + t.Fatal("foreign authority accepted") + } + } + id := uuid.NewString() + sendExport(t, r, id, request) + var offset int64 + for { + p := readExport(t, s) + if p.Offset != offset { + t.Fatal("wrong offset") + } + if p.Outcome == "completed" { + break + } + if p.Outcome != "chunk" || len(p.Data) == 0 || len(p.Data) > proto.WorkspaceExportChunkBytes { + t.Fatal("invalid chunk") + } + for _, b := range p.Data { + if b != 0 { + t.Fatal("wrong byte") + } + } + offset += int64(len(p.Data)) + select { + case <-s.replies: + t.Fatal("export pushed unrequested data") + default: + } + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) + } + if offset != 131089 { + t.Fatal("truncated export", offset) + } +} + +func TestWorkspaceExportFailureAfterBytesCannotComplete(t *testing.T) { + r, s, request := exporterRouter(t, "printf abc; exit 1") + id := uuid.NewString() + sendExport(t, r, id, request) + p := readExport(t, s) + if p.Outcome != "chunk" || string(p.Data) != "abc" { + t.Fatal("missing prefix", p) + } + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: 3}) + if p := readExport(t, s); p.Outcome != "failed" { + t.Fatal("failed process appeared complete", p) + } +} + +func TestWorkspaceExportCancelUnblocksProcessAndReleasesCapacity(t *testing.T) { + r, _, request := exporterRouter(t, "exec sleep 30") + id := uuid.NewString() + sendExport(t, r, id, request) + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "cancel"}) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + r.mu.Lock() + active := r.workspaceExport != nil + r.mu.Unlock() + if !active { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("export cancellation did not release capacity") +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go b/apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go new file mode 100644 index 000000000..a239ed0eb --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go @@ -0,0 +1,81 @@ +package dispatch + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type workspaceFailureBoundary struct { + slog.Handler + once sync.Once + entered, resume chan struct{} +} + +func (h *workspaceFailureBoundary) Handle(ctx context.Context, record slog.Record) error { + h.once.Do(func() { close(h.entered); <-h.resume }) + return h.Handler.Handle(ctx, record) +} + +type workspaceCloseFunc struct { + agent.Prepared + close func() error +} + +func (p workspaceCloseFunc) Close() error { return p.close() } + +func TestReadConstructorFailureCannotPublishReleaseDuringCleanupRetry(t *testing.T) { + boundary := &workspaceFailureBoundary{Handler: slog.NewTextHandler(io.Discard, nil), entered: make(chan struct{}), resume: make(chan struct{})} + sender := make(workspaceStatusSender, 16) + r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: slog.New(boundary)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + timer := time.NewTimer(time.Hour) + defer timer.Stop() + retryEntered, retryResume := make(chan struct{}), make(chan struct{}) + var resumeOnce sync.Once + defer resumeOnce.Do(func() { close(retryResume) }) + calls := 0 + resource := workspaceCloseFunc{close: func() error { + calls++ + if calls == 2 { + close(retryEntered) + <-retryResume + } + return errors.New("cleanup incomplete") + }} + p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, + ctx: ctx, cancel: cancel, timer: timer, deadline: time.Now().Add(time.Hour), + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "preparing"}} + r.shutdownWG.Add(1) + prepared := make(chan struct{}) + go func() { + r.prepareExecution(p, proto.PromptRequestPayload{}, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + return resource, errors.New("construction failed") + }) + close(prepared) + }() + <-boundary.entered + r.releasePreparation(p, "released", "", true, true) + <-retryEntered + close(boundary.resume) + <-prepared + for len(sender) > 0 { + var status proto.PreparationStatusPayload + if (<-sender).DecodePayload(&status) == nil && status.State == "released" { + t.Error("constructor published release while retry cleanup was blocked") + } + } + resumeOnce.Do(func() { close(retryResume) }) + r.shutdownWG.Wait() + if !p.owns || p.busy || p.status.ErrorCode != "cleanup_unconfirmed" || calls != 2 { + t.Fatal("failed retry did not retain cleanup ownership") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go new file mode 100644 index 000000000..5a53d169e --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go @@ -0,0 +1,91 @@ +package dispatch + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +type workspaceStatusSender chan proto.Envelope + +func (s workspaceStatusSender) Send(_ context.Context, envelope proto.Envelope) error { + s <- envelope + return nil +} + +type offlineWorkspaceStatusSender struct{} + +func (offlineWorkspaceStatusSender) Send(context.Context, proto.Envelope) error { + return errors.New("observer disconnected") +} + +type unsettledWorkspacePreparation struct { + agent.Prepared + calls atomic.Int32 + settled atomic.Bool +} + +func (p *unsettledWorkspacePreparation) Close() error { + // Bound a regression so a recursive retry cannot hang the test. + if p.calls.Add(1) >= 100 || p.settled.Load() { + return nil + } + return errors.New("cleanup incomplete") +} + +func TestReadPreparationOfflineStatusDoesNotRetryCleanup(t *testing.T) { + prepared := &unsettledWorkspacePreparation{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + timer := time.NewTimer(time.Hour) + defer timer.Stop() + r := &Router{sender: offlineWorkspaceStatusSender{}, shutdownCh: make(chan struct{}), log: obslog.Bg()} + p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, + ctx: ctx, cancel: cancel, timer: timer, + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} + for attempt := int32(1); attempt <= 2; attempt++ { + r.releasePreparation(p, "released", "", true, true) + r.shutdownWG.Wait() + if got := prepared.calls.Load(); got != attempt { + t.Fatalf("explicit release %d caused %d cleanup attempts", attempt, got) + } + if !p.owns || p.busy || p.prepared != prepared || p.status.ErrorCode != "cleanup_unconfirmed" { + t.Fatal("unconfirmed cleanup lost its resource ownership") + } + } + prepared.settled.Store(true) + r.releasePreparation(p, "released", "", true, true) + r.shutdownWG.Wait() + if prepared.calls.Load() != 3 || p.owns || p.prepared != nil || p.status.State != "released" { + t.Fatal("explicit retry did not settle resource ownership") + } +} + +func TestReadPreparationRetryCannotPublishStaleRelease(t *testing.T) { + sender := make(workspaceStatusSender, 4) + r := &Router{sender: sender, shutdownCh: make(chan struct{})} + p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 2, State: "released"}} + // A prepare retry captures this snapshot while Close is still running. + snapshot := p.status + // Close fails before the retry reaches publication. + p.busy = false + p.status = proto.PreparationStatusPayload{Handle: "reader", Revision: 3, State: "failed", ErrorCode: "cleanup_unconfirmed"} + r.publishPreparation(p, snapshot) + r.shutdownWG.Wait() + if len(sender) != 0 { + t.Fatal("stale release success escaped after failed Close") + } + r.publishPreparation(p, p.status) + r.shutdownWG.Wait() + var failed proto.PreparationStatusPayload + if len(sender) != 1 || (<-sender).DecodePayload(&failed) != nil || failed.State != "failed" { + t.Fatal("confirmed cleanup failure was suppressed") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go b/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go new file mode 100644 index 000000000..49837ad78 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go @@ -0,0 +1,117 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceReadPreparationRejectsStartAndWaitsForClose(t *testing.T) { + sender := &recSender{} + entered, release := make(chan struct{}), make(chan struct{}) + p := &controlledPreparation{closed: make(chan struct{}), closeHook: func() { close(entered); <-release }} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + request := preparationRequest() + request.Configuration.WorkspaceReadOnly = true + prepare := mustEnv(t, proto.TypeExecutionPrepare, "read", request) + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "read", "ready", "") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "read", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "work"})); err == nil || p.starts.Load() != 0 { + t.Fatal("read owner admitted a Run") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + <-entered + // An idempotent prepare retry must not expose a premature terminal status. + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + for _, envelope := range sender.snapshot() { + var status proto.PreparationStatusPayload + if envelope.DecodePayload(&status) == nil && status.State == "released" { + t.Fatal("release acknowledged before native close") + } + } + close(release) + waitPreparationStatus(t, sender, "read", "released", "") + if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { + t.Fatal("settled release retained ownership") + } +} + +func TestReadPreparationConstructionFailureRetainsCapacity(t *testing.T) { + var settled atomic.Bool + sender := &recSender{} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + return &retryablePreparation{close: func(int32) error { + if !settled.Load() { + return errors.New("unreaped child") + } + return nil + }}, errors.New("initialization failed") + }) + defer settled.Store(true) + request := preparationRequest() + request.Configuration.WorkspaceReadOnly = true + for i := range 4 { + id := fmt.Sprint("failed-read-", i) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, request)); err != nil { + t.Fatal(err) + } + failed := waitPreparationStatus(t, sender, id, "failed", "") + if owned, _ := r.PreparationOwnershipForTest(failed.Handle); !owned || failed.ErrorCode != "cleanup_unconfirmed" { + t.Fatal("failed constructor did not retain cleanup ownership") + } + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "fifth", request)) + status := waitPreparationStatus(t, sender, "fifth", "rejected", "") + if status.ErrorCode != "preparation_capacity" { + t.Fatal("unreaped readers exceeded preparation capacity") + } +} + +func TestWorkspaceReadPreparationRetainsFailedCleanup(t *testing.T) { + sender := &recSender{} + p := &retryablePreparation{close: func(call int32) error { + if call == 1 { + return errors.New("controlled cleanup failure") + } + return nil + }} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + request := preparationRequest() + request.Configuration.WorkspaceReadOnly = true + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "read", request)); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "read", "ready", "") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + failed := waitPreparationStatus(t, sender, "read", "failed", "") + if failed.ErrorCode != "cleanup_unconfirmed" { + t.Fatal("cleanup failure hidden") + } + if owned, _ := r.PreparationOwnershipForTest(ready.Handle); !owned { + t.Fatal("uncertain cleanup discarded ownership") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + released := waitPreparationStatus(t, sender, "read", "released", "") + if released.ErrorCode != "" || released.Revision <= failed.Revision { + t.Fatal("successful cleanup retry did not report confirmation") + } + if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { + t.Fatal("confirmed retry retained ownership") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read.go b/apps/parsar-daemon/internal/dispatch/workspace_read.go new file mode 100644 index 000000000..1ad78dc20 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_read.go @@ -0,0 +1,153 @@ +package dispatch + +import ( + "context" + "errors" + "io/fs" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const workspaceReadCapacity = 4 + +func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) error { + // Never echo an unbounded correlation ID onto the shared connection. + if len(env.ID) > proto.WorkspaceReadMaxIDBytes { + return errors.New("dispatch: invalid workspace read ID") + } + var request proto.WorkspaceReadPayload + if len(env.Payload) > proto.WorkspaceReadMaxRequestBytes || env.DecodePayload(&request) != nil || strings.TrimSpace(env.ID) == "" || + !proto.ValidWorkspaceReadRequest(request) { + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("invalid_request")) + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if _, exists := r.workspaceReads[env.ID]; exists { + r.mu.Unlock() + return errors.New("dispatch: workspace read already pending") + } + if len(r.workspaceReads) >= workspaceReadCapacity { + r.mu.Unlock() + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("read_capacity")) + } + resource, code := r.workspaceResourceLocked(request) + if code != "" { + r.mu.Unlock() + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) + } + if r.workspaceReads == nil { + r.workspaceReads = make(map[string]struct{}) + } + r.workspaceReads[env.ID] = struct{}{} + r.shutdownWG.Add(1) + r.mu.Unlock() + go func() { + defer r.shutdownWG.Done() + defer func() { r.mu.Lock(); delete(r.workspaceReads, env.ID); r.mu.Unlock() }() + // Observer loss does not discard an admitted native wait or replay it. + operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 12*time.Second) + defer cancel() + result := executeWorkspaceRead(operation, resource, request) + _ = r.sendWorkspaceRead(context.WithoutCancel(ctx), env, result) + }() + return nil +} + +func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (any, string) { + var resource any + if request.Handle != "" { + p := r.preparations[request.Handle] + if p == nil || p.environmentID != request.EnvironmentID || p.status.State != "ready" || + !p.owns || p.busy || p.ctx.Err() != nil || !time.Now().Before(p.deadline) { + return nil, "resource_unavailable" + } + resource = p.prepared + } else { + s := r.sessions[request.RunID] + if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || + !r.interactionRouteOpenLocked(s) { + return nil, "resource_unavailable" + } + resource = s.session + } + if r.localWorkspace != nil { + resource = r.localWorkspace + } + return resource, "" +} + +func executeWorkspaceRead(ctx context.Context, resource any, request proto.WorkspaceReadPayload) proto.WorkspaceReadResultPayload { + if request.Operation == "directory" { + reader, ok := resource.(agent.WorkspaceDirectoryLister) + if !ok { + return rejectedWorkspaceRead("read_unsupported") + } + read, err := reader.ListWorkspaceDirectory(ctx, request.Path, request.MaxEntries) + if err != nil { + return workspaceReadResult(agent.WorkspaceReadResult{}, err, 0) + } + if read.Entries == nil || len(read.Entries) > request.MaxEntries { + return workspaceReadResult(agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain, 0) + } + directory := &proto.WorkspaceDirectoryResult{Entries: make([]proto.WorkspaceDirectoryEntry, 0, len(read.Entries)), Truncated: read.Truncated} + for _, entry := range read.Entries { + directory.Entries = append(directory.Entries, proto.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) + } + if !proto.ValidWorkspaceDirectory(directory, request.MaxEntries) { + return workspaceReadResult(agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain, 0) + } + return proto.WorkspaceReadResultPayload{Outcome: "completed", Directory: directory, CloseAcknowledged: true} + } + reader, ok := resource.(agent.WorkspaceReader) + if !ok { + return rejectedWorkspaceRead("read_unsupported") + } + read, err := reader.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) + return workspaceReadResult(read, err, request.MaxBytes) +} + +func rejectedWorkspaceRead(code string) proto.WorkspaceReadResultPayload { + return proto.WorkspaceReadResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func workspaceReadResult(read agent.WorkspaceReadResult, err error, limit int) proto.WorkspaceReadResultPayload { + if err == nil && len(read.Data) <= limit && (!read.Truncated || len(read.Data) == limit) { + return proto.WorkspaceReadResultPayload{Outcome: "completed", Data: read.Data, Truncated: read.Truncated, CloseAcknowledged: true} + } + for _, failure := range []struct { + err error + code string + }{ + {agent.ErrWorkspaceReadUnsupported, "read_unsupported"}, + {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, + {agent.ErrWorkspaceReadBusy, "read_capacity"}, + {agent.ErrWorkspaceReadInvalid, "invalid_request"}, + {fs.ErrNotExist, "not_found"}, + {fs.ErrPermission, "permission_denied"}, + } { + if errors.Is(err, failure.err) { + return rejectedWorkspaceRead(failure.code) + } + } + return proto.WorkspaceReadResultPayload{Outcome: "unknown", ErrorCode: "read_unconfirmed"} +} + +func (r *Router) sendWorkspaceRead(ctx context.Context, request proto.Envelope, result proto.WorkspaceReadResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + trace := request.Trace + if len(trace) > 256 { + trace = "" + } + env, err := proto.NewEnvelopeWithTrace(proto.TypeWorkspaceReadResult, request.ID, result, trace) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read_test.go b/apps/parsar-daemon/internal/dispatch/workspace_read_test.go new file mode 100644 index 000000000..06f5413d7 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_read_test.go @@ -0,0 +1,216 @@ +package dispatch_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type workspaceTestReader struct { + read func(context.Context, string, int) (agent.WorkspaceReadResult, error) +} + +func (r *workspaceTestReader) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + return r.read(ctx, path, limit) +} + +type readablePreparation struct { + *controlledPreparation + *workspaceTestReader +} +type readableSession struct { + *fakeSession + *workspaceTestReader +} + +func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.WorkspaceReadResultPayload { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, env := range sender.snapshot() { + if env.Type == proto.TypeWorkspaceReadResult && env.ID == id { + var result proto.WorkspaceReadResultPayload + if env.DecodePayload(&result) != nil { + t.Fatal("invalid read result") + } + return result + } + } + time.Sleep(time.Millisecond) + } + t.Fatal("read result missing", id) + return proto.WorkspaceReadResultPayload{} +} + +func TestWorkspaceReadUsesPreparationThenTransferredRun(t *testing.T) { + sender := &recSender{} + var calls atomic.Int32 + reader := &workspaceTestReader{read: func(_ context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + calls.Add(1) + if path != "file" || limit != 3 { + return agent.WorkspaceReadResult{}, errors.New("request changed") + } + return agent.WorkspaceReadResult{Data: []byte{0, 1, 255}, Truncated: true}, nil + }} + p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: reader} + p.start = func(ctx context.Context, _ string, _ string, out chan<- proto.Envelope) (agent.Session, error) { + return &readableSession{&fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, reader}, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "idle", request)) + if result := waitWorkspaceRead(t, sender, "idle"); result.Outcome != "completed" || !result.CloseAcknowledged || !result.Truncated { + t.Fatal(result) + } + bad := request + bad.EnvironmentID = "another-environment" + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", bad)) + if result := waitWorkspaceRead(t, sender, "foreign"); result.ErrorCode != "resource_unavailable" { + t.Fatal(result) + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "start"})) + waitPreparationStatus(t, sender, "prepare", "started", "") + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "old-handle", request)) + if result := waitWorkspaceRead(t, sender, "old-handle"); result.Outcome != "rejected" { + t.Fatal(result) + } + request.Handle, request.RunID = "", "run" + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "active", request)) + if result := waitWorkspaceRead(t, sender, "active"); result.Outcome != "completed" { + t.Fatal(result) + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{})) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "cancelled", request)) + if result := waitWorkspaceRead(t, sender, "cancelled"); result.Outcome != "rejected" { + t.Fatal(result) + } + if calls.Load() != 2 { + t.Fatal("rejected reads reached adapter", calls.Load()) + } +} + +func TestWorkspaceReadWaitSurvivesObserverAndResourceRelease(t *testing.T) { + sender := &recSender{} + entered, settle := make(chan context.Context, 1), make(chan struct{}) + p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(ctx context.Context, _ string, _ int) (agent.WorkspaceReadResult, error) { + entered <- ctx + <-settle + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain + }}} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} + observer, cancel := context.WithCancel(t.Context()) + _ = r.Handle(observer, mustEnv(t, proto.TypeWorkspaceRead, "read", request)) + operation := <-entered + cancel() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "read", request)); err == nil { + t.Fatal("duplicate pending operation accepted") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "prepare", proto.ExecutionReleasePayload{Handle: ready.Handle})) + waitPreparationClosed(t, p.controlledPreparation) + if operation.Err() != nil { + t.Fatal("observer/release discarded accepted waiter") + } + short, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) + if err := r.Shutdown(short); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("shutdown lost pending read", err) + } + stop() + close(settle) + if result := waitWorkspaceRead(t, sender, "read"); result.Outcome != "unknown" || len(result.Data) != 0 || result.CloseAcknowledged { + t.Fatal(result) + } + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceReadCapacityIsConnectionBounded(t *testing.T) { + sender := &recSender{} + entered, settle := make(chan struct{}, 4), make(chan struct{}) + p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(context.Context, string, int) (agent.WorkspaceReadResult, error) { + entered <- struct{}{} + <-settle + return agent.WorkspaceReadResult{}, nil + }}} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} + for i := 0; i < 4; i++ { + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, fmt.Sprint(i), request)) + <-entered + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "excess", request)) + if result := waitWorkspaceRead(t, sender, "excess"); result.ErrorCode != "read_capacity" { + t.Fatal(result) + } + close(settle) + for i := 0; i < 4; i++ { + if result := waitWorkspaceRead(t, sender, fmt.Sprint(i)); result.Outcome != "completed" { + t.Fatal(result) + } + } +} + +func TestWorkspaceReadBoundsRequestsAndEchoedMetadata(t *testing.T) { + sender := &recSender{} + var calls atomic.Int32 + p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(context.Context, string, int) (agent.WorkspaceReadResult, error) { + calls.Add(1) + return agent.WorkspaceReadResult{Data: bytes.Repeat([]byte{255}, proto.WorkspaceReadMaxBytes)}, nil + }}} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: proto.WorkspaceReadMaxBytes} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, strings.Repeat("x", 3<<20), request)); err == nil { + t.Fatal("oversized ID accepted") + } + bad := request + bad.Path = strings.Repeat("x", proto.WorkspaceReadMaxRequestBytes) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "oversized", bad)) + if result := waitWorkspaceRead(t, sender, "oversized"); result.ErrorCode != "invalid_request" { + t.Fatal(result.Outcome, result.ErrorCode) + } + if calls.Load() != 0 { + t.Fatal("invalid control reached adapter") + } + id := strings.Repeat("\x00", proto.WorkspaceReadMaxIDBytes) + env := mustEnv(t, proto.TypeWorkspaceRead, id, request) + env.Trace = strings.Repeat("x", 3<<20) + if err := r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + if result := waitWorkspaceRead(t, sender, id); result.Outcome != "completed" { + t.Fatal(result.Outcome) + } + for _, reply := range sender.snapshot() { + if reply.Type != proto.TypeWorkspaceReadResult { + continue + } + encoded, err := json.Marshal(reply) + if err != nil || len(encoded) >= 4<<20 { + t.Fatal("oversized response", len(encoded), err) + } + if reply.Trace != "" { + t.Fatal("oversized trace echoed") + } + } + if calls.Load() != 1 { + t.Fatal("unexpected read count", calls.Load()) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go new file mode 100644 index 000000000..338e3bdd8 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -0,0 +1,165 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Router.mu protects this single bounded transfer for the dedicated Environment. +type workspaceUpload struct { + envelope proto.Envelope + request proto.WorkspaceWritePayload + data []byte + ready chan struct{} + finished bool + apply bool + uncertain bool +} + +func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) error { + id, err := uuid.Parse(env.ID) + if err != nil || id == uuid.Nil || id.String() != env.ID { + return errors.New("dispatch: invalid workspace write identity") + } + var request proto.WorkspaceWritePayload + if len(env.Payload) > proto.WorkspaceWriteMaxFrameBytes || env.DecodePayload(&request) != nil || !proto.ValidWorkspaceWriteRequest(request) { + // A malformed frame on an already admitted operation cannot claim that + // its earlier commit did not execute. + r.mu.Lock() + pending := r.workspaceWrite != nil && r.workspaceWrite.envelope.ID == env.ID + r.mu.Unlock() + if pending { + return errors.New("dispatch: malformed pending write frame") + } + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("invalid_request")) + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if request.Step == "begin" { + if r.workspaceExport != nil { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + if r.workspaceWrite != nil { + duplicate := r.workspaceWrite.envelope.ID == env.ID + r.mu.Unlock() + if duplicate { + return errors.New("dispatch: workspace write already admitted") + } + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) + } + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + for _, p := range r.preparations { + if p.owns { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + } + u := &workspaceUpload{envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), ready: make(chan struct{})} + r.workspaceWrite = u + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.runWorkspaceUpload(context.WithoutCancel(ctx), u) + return r.sendWorkspaceWrite(ctx, env.ID, proto.WorkspaceWriteResultPayload{Outcome: "ready"}) + } + u := r.workspaceWrite + if u == nil || u.envelope.ID != env.ID { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + if u.finished { + r.mu.Unlock() + return errors.New("dispatch: workspace write body already closed") + } + if request.Step == "chunk" && request.Offset == len(u.data) && len(request.Data) <= u.request.SizeBytes-len(u.data) { + u.data = append(u.data, request.Data...) + offset := len(u.data) + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, proto.WorkspaceWriteResultPayload{Outcome: "received", Offset: offset}) + } + if request.Step == "commit" && len(u.data) == u.request.SizeBytes { + digest := sha256.Sum256(u.data) + u.apply = hex.EncodeToString(digest[:]) == u.request.SHA256 + } + u.finished = true + close(u.ready) + r.mu.Unlock() + return nil +} + +func (r *Router) runWorkspaceUpload(ctx context.Context, u *workspaceUpload) { + defer r.shutdownWG.Done() + timer := time.NewTimer(120 * time.Second) + defer timer.Stop() + select { + case <-u.ready: + case <-r.shutdownCh: + case <-timer.C: + } + r.mu.Lock() + apply := u.apply && !r.closed + u.finished = true + data := u.data + u.data = nil + r.mu.Unlock() + result := rejectedWorkspaceWrite("invalid_request") + if apply { + write, err := r.localWorkspace.WriteWorkspaceFile(ctx, u.request.Path, data) + result = workspaceWriteResult(write, err, u.request.SizeBytes) + } + r.mu.Lock() + u.uncertain = result.Outcome == "unknown" + if !u.uncertain { + r.workspaceWrite = nil + } + r.mu.Unlock() + _ = r.sendWorkspaceWrite(ctx, u.envelope.ID, result) +} + +func rejectedWorkspaceWrite(code string) proto.WorkspaceWriteResultPayload { + return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) proto.WorkspaceWriteResultPayload { + if err == nil && write.SizeBytes == int64(size) { + return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size} + } + for _, failure := range []struct { + err error + code string + }{ + {agent.ErrWorkspaceWriteUnsupported, "write_unsupported"}, + {agent.ErrWorkspaceWriteUnavailable, "resource_unavailable"}, + {agent.ErrWorkspaceWriteBusy, "write_capacity"}, + {agent.ErrWorkspaceWriteInvalid, "invalid_request"}, + {agent.ErrWorkspaceWriteRejected, "write_rejected"}, + } { + if errors.Is(err, failure.err) { + return rejectedWorkspaceWrite(failure.code) + } + } + return proto.WorkspaceWriteResultPayload{Outcome: "unknown", ErrorCode: "write_unconfirmed"} +} + +func (r *Router) sendWorkspaceWrite(ctx context.Context, id string, result proto.WorkspaceWriteResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + env, err := proto.NewEnvelope(proto.TypeWorkspaceWriteResult, id, result) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go new file mode 100644 index 000000000..d33f46688 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -0,0 +1,167 @@ +package dispatch_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func localWriterRouter(t *testing.T, response string) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { + t.Helper() + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + workspace, staging := filepath.Join(parent, "workspace"), filepath.Join(parent, "staging") + for _, p := range []string{workspace, staging} { + if err := os.Mkdir(p, 0700); err != nil { + t.Fatal(err) + } + } + helperRoot, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + helper := filepath.Join(helperRoot, "helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\ntouch \"$1/invoked\"\nprintf '%s' '"+response+"'\n"), 0700); err != nil { + t.Fatal(err) + } + environment, session := uuid.NewString(), uuid.NewString() + for k, v := range map[string]string{"PARSAR_RUNTIME_ENVIRONMENT_ID": environment, "PARSAR_RUNTIME_SESSION_ID": session, "PARSAR_RUNTIME_WORKSPACE": workspace, "PARSAR_RUNTIME_DIRECTORY_HELPER": helper, "PARSAR_RUNTIME_WRITE_HELPER": helper, "PARSAR_RUNTIME_STAGING": staging} { + t.Setenv(k, v) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + sender := &recSender{} + r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, LocalWorkspace: binding}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = r.Shutdown(ctx) + }) + digest := sha256.Sum256([]byte("abc")) + return r, sender, proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "file", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}, workspace +} + +func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) proto.WorkspaceWriteResultPayload { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, env := range sender.snapshot() { + if env.ID != id || env.Type != proto.TypeWorkspaceWriteResult { + continue + } + var result proto.WorkspaceWriteResultPayload + if env.DecodePayload(&result) != nil { + t.Fatal("bad write result") + } + if result.Outcome == outcome { + return result + } + } + time.Sleep(time.Millisecond) + } + t.Fatal("write result missing", id, outcome) + return proto.WorkspaceWriteResultPayload{} +} + +func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { + r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + for _, field := range []string{"environment", "session"} { + bad := request + if field == "environment" { + bad.EnvironmentID = uuid.NewString() + } else { + bad.SessionID = uuid.NewString() + } + id := uuid.NewString() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, bad)); err != nil { + t.Fatal(err) + } + waitWorkspaceWrite(t, sender, id, "rejected") + } + id := uuid.NewString() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)); err != nil { + t.Fatal(err) + } + waitWorkspaceWrite(t, sender, id, "ready") + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)); err == nil { + t.Fatal("duplicate transfer accepted") + } + for offset, part := range []string{"a", "bc"} { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "chunk", Offset: offset, Data: []byte(part)})); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { + t.Fatal("helper started before commit") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { + t.Fatal(err) + } + if got := waitWorkspaceWrite(t, sender, id, "completed"); got.SizeBytes != 3 { + t.Fatal(got) + } + if _, err := os.Stat(filepath.Join(workspace, "invoked")); err != nil { + t.Fatal("helper not called", err) + } +} + +func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) { + for _, mode := range []string{"offset", "digest", "short"} { + t.Run(mode, func(t *testing.T) { + r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + id := uuid.NewString() + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)) + chunk := proto.WorkspaceWritePayload{Step: "chunk", Data: []byte("abc")} + switch mode { + case "offset": + chunk.Offset = 1 + case "digest": + chunk.Data = []byte("bad") + case "short": + chunk.Data = []byte("a") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, chunk)) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})) + waitWorkspaceWrite(t, sender, id, "rejected") + if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { + t.Fatal("bad transfer invoked helper") + } + }) + } +} + +func TestLocalUploadUnknownRetainsOwner(t *testing.T) { + r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"unknown","error":"write_failed"}`) + id := uuid.NewString() + for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { + t.Fatal(err) + } + } + waitWorkspaceWrite(t, sender, id, "unknown") + next := uuid.NewString() + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)) + if got := waitWorkspaceWrite(t, sender, next, "rejected"); got.ErrorCode != "write_capacity" { + t.Fatal(got) + } + if err := r.Shutdown(t.Context()); err == nil { + t.Fatal("shutdown declared uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go new file mode 100644 index 000000000..f858a1bb5 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -0,0 +1,110 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Binding freezes operator-owned identity and paths for one Runtime lifetime. +type Binding struct { + environment string + networkAccess string + stateKey string + workspace string + helper string + exportHelper string + writer *fileWriter +} + +func New(environment, session, workspace, helper string) (*Binding, error) { + for _, id := range []string{environment, session} { + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil || parsed.String() != id { + return nil, errors.New("local workspace requires canonical resource identities") + } + } + for _, name := range []string{workspace, helper} { + if !filepath.IsAbs(name) || filepath.Clean(name) != name || name == "/" || strings.ContainsAny(name, "\x00\r\n\\") { + return nil, errors.New("local workspace requires clean absolute deployment paths") + } + } + root, err := os.Lstat(workspace) + if err != nil || !root.IsDir() || root.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("local workspace root must be an existing directory") + } + program, err := os.Stat(helper) + if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || strings.HasPrefix(helper, workspace+string(filepath.Separator)) { + return nil, errors.New("local workspace helper must be executable outside the workspace") + } + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper}, nil +} + +func Load() (*Binding, error) { + values := []string{os.Getenv("PARSAR_RUNTIME_ENVIRONMENT_ID"), os.Getenv("PARSAR_RUNTIME_SESSION_ID"), os.Getenv("PARSAR_RUNTIME_WORKSPACE"), os.Getenv("PARSAR_RUNTIME_DIRECTORY_HELPER")} + network := os.Getenv("PARSAR_RUNTIME_NETWORK_ACCESS") + if network != "" && network != "enabled" && network != "disabled" { + return nil, errors.New("unsupported local Runtime network policy") + } + writeHelper, staging := os.Getenv("PARSAR_RUNTIME_WRITE_HELPER"), os.Getenv("PARSAR_RUNTIME_STAGING") + exportHelper := os.Getenv("PARSAR_RUNTIME_EXPORT_HELPER") + if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" { + return nil, nil + } + b, err := New(values[0], values[1], values[2], values[3]) + if err != nil { + return nil, err + } + b.networkAccess = network + if exportHelper != "" { + // Reuse the startup executable/root checks; this grants no caller authority. + if _, err := New(values[0], values[1], values[2], exportHelper); err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(exportHelper) + if err != nil || resolved != exportHelper { + return nil, errors.New("workspace exporter must be a canonical executable") + } + b.exportHelper = exportHelper + } + if writeHelper != "" || staging != "" { + if err := b.bindWriter(writeHelper, staging); err != nil { + return nil, err + } + } + return b, nil +} + +// Configure validates the reference before supplying the immutable local cwd. +func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + if b == nil && r.LocalEnvironment == nil { + return r, nil + } + if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || + r.RemoteEnvironment != nil || r.DisableExecutionEnvironment || r.WorkDir != "" || + r.ConversationID != "" || r.WorkspaceAuthoring || len(r.Attachments) != 0 || !r.StrictResume || !r.ReleaseOnCompletion { + return r, errors.New("request does not match the dedicated local Environment") + } + if (!r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "") && r.LocalEnvironment.NetworkAccess != b.networkAccess { + return r, errors.New("request does not match the local Runtime network policy") + } + if !r.WorkspaceReadOnly { + if r.LocalEnvironment.SystemPackages && !r.LocalEnvironment.ToolEnvironment { + return r, errors.New("system packages require initialized tool configuration") + } + if r.LocalEnvironment.ToolEnvironment { + if err := VerifyToolEnvironment(r.LocalEnvironment.SystemPackages); err != nil { + return r, err + } + } + r.WorkDir = b.workspace + } + return r, nil +} + +// NetworkAccess is deployment-owned; read-only workspace controls need no network. +func (b *Binding) NetworkAccess() string { return b.networkAccess } diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go new file mode 100644 index 000000000..3e6677119 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -0,0 +1,92 @@ +package localworkspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { + t.Helper() + root := t.TempDir() + helper := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + environment, session := uuid.NewString(), uuid.NewString() + b, err := New(environment, session, root, helper) + if err != nil { + t.Fatal(err) + } + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} +} + +func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { + b, valid := testBinding(t) + configured, err := b.Configure(valid) + if err != nil || configured.WorkDir != b.workspace { + t.Fatalf("frozen cwd: %+v %v", configured, err) + } + for name, mutate := range map[string]func(*proto.PromptRequestPayload){ + "missing reference": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = nil }, + "other Environment": func(r *proto.PromptRequestPayload) { + r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} + }, + "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, + "path override": func(r *proto.PromptRequestPayload) { r.WorkDir = b.workspace }, + "remote": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment = &proto.RemoteEnvironment{ID: "other"} }, + "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, + "product authoring": func(r *proto.PromptRequestPayload) { r.WorkspaceAuthoring = true }, + "non-strict resume": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, + } { + t.Run(name, func(t *testing.T) { + r := valid + mutate(&r) + if _, err := b.Configure(r); err == nil { + t.Fatal("unsafe request accepted") + } + }) + } + if _, err := (*Binding)(nil).Configure(valid); err == nil { + t.Fatal("unbound Runtime accepted a local Environment") + } + if _, err := (*Binding)(nil).Configure(proto.PromptRequestPayload{}); err != nil { + t.Fatal("ordinary unbound behavior changed", err) + } +} + +func TestLocalHelperCannotInheritCredentials(t *testing.T) { + b, _ := testBinding(t) + t.Setenv("PARSAR_PRIVATE_CREDENTIAL", "synthetic-secret") + script := "#!/bin/sh\n[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n" + if err := os.WriteFile(b.helper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) + if err != nil || got.Entries == nil || len(got.Entries) != 0 || got.Truncated { + t.Fatalf("read-only helper: %+v %v", got, err) + } + for _, path := range []string{"/etc", "..", "a/../b", "a//b", ".", "a\\b"} { + if _, err := b.ListWorkspaceDirectory(t.Context(), path, 2); err == nil { + t.Fatalf("invalid path accepted: %q", path) + } + } +} + +func TestDirectoryRejectsMalformedOrIncompleteResponses(t *testing.T) { + for _, frame := range []string{ + `{"version":2,"directory":{"entries":[],"truncated":false}}`, + `{"version":1,"directory":{"entries":[]}}`, + `{"version":1,"directory":{"entries":null,"truncated":false}}`, + `{"version":1,"directory":{"entries":[{"name":"../secret","kind":"file","size_bytes":1}],"truncated":false}}`, + `{"version":1,"error":"unknown"}`, + `{"version":1,"directory":{"entries":[],"truncated":false}} {}`, + } { + if _, err := decodeDirectory([]byte(frame), 2); err == nil { + t.Fatal("unconfirmed response accepted", frame) + } + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/parsar-daemon/internal/localworkspace/directory.go new file mode 100644 index 000000000..40980ffa8 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/directory.go @@ -0,0 +1,78 @@ +package localworkspace + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { + if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid + } + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(operation, b.helper, b.workspace, path, strconv.Itoa(limit)) + cmd.Dir = "/" + cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} + cmd.WaitDelay = time.Second + pipe, err := cmd.StdoutPipe() + if err != nil { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable + } + if err := cmd.Start(); err != nil { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable + } + const maxOutput = 4 << 20 + data, err := io.ReadAll(io.LimitReader(pipe, maxOutput+1)) + if err != nil || len(data) > maxOutput { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if err != nil || waitErr != nil || len(data) > maxOutput { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain + } + return decodeDirectory(data, limit) +} + +func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, error) { + var wire struct { + Version int `json:"version"` + Error *string `json:"error"` + Directory *proto.WorkspaceDirectoryResult `json:"directory"` + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 || (wire.Error == nil) == (wire.Directory == nil) { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain + } + if wire.Error != nil { + err := agent.ErrWorkspaceReadUncertain + switch *wire.Error { + case "not_found": + err = fs.ErrNotExist + case "permission_denied": + err = fs.ErrPermission + case "invalid_path": + err = agent.ErrWorkspaceReadInvalid + } + return agent.WorkspaceDirectoryResult{}, err + } + if !proto.ValidWorkspaceDirectory(wire.Directory, limit) { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain + } + result := agent.WorkspaceDirectoryResult{Entries: make([]agent.WorkspaceDirectoryEntry, 0, len(wire.Directory.Entries)), Truncated: wire.Directory.Truncated} + for _, e := range wire.Directory.Entries { + result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: e.Name, Kind: e.Kind, SizeBytes: e.SizeBytes}) + } + return result, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go new file mode 100644 index 000000000..7ba84f1f4 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go @@ -0,0 +1,53 @@ +package localworkspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func TestNativeLocalDirectoryConfinement(t *testing.T) { + helper := os.Getenv("PARSAR_LOCAL_DIRECTORY_TEST_HELPER") + if helper == "" { + t.Skip("actual pinned directory helper required") + } + root, outside := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(root, "nested", "data.bin"), filepath.Join(outside, "secret")} { + if err := os.WriteFile(path, []byte{0, 1, 255, 17}, 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { + t.Fatal(err) + } + b, err := New(uuid.NewString(), uuid.NewString(), root, helper) + if err != nil { + t.Fatal(err) + } + got, err := b.ListWorkspaceDirectory(t.Context(), "nested", 10) + if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 { + t.Fatalf("native file metadata: %+v %v", got, err) + } + if _, err := b.ListWorkspaceDirectory(t.Context(), "escape", 10); err == nil { + t.Fatal("native helper followed an external symlink") + } + got, err = b.ListWorkspaceDirectory(t.Context(), "", 1) + if err != nil || !got.Truncated || len(got.Entries) != 1 { + t.Fatalf("native directory bound: %+v %v", got, err) + } + if err := os.Rename(root, root+"-original"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root + "-original") }) + if err := os.Symlink(outside, root); err != nil { + t.Fatal(err) + } + if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); err == nil { + t.Fatal("native helper followed a replaced root") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/export.go b/apps/parsar-daemon/internal/localworkspace/export.go new file mode 100644 index 000000000..b94bcbd37 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/export.go @@ -0,0 +1,40 @@ +package localworkspace + +import ( + "context" + "errors" + "io" + "os/exec" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (b *Binding) CanExport() bool { return b != nil && b.exportHelper != "" } + +// ExportOutputs streams only from the deployment-owned root; successful exit is mandatory. +func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { + if !b.CanExport() || output == nil { + return errors.New("workspace export unavailable") + } + cmd := exec.CommandContext(ctx, b.exportHelper, b.workspace) + cmd.Dir = "/" + cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} + cmd.Stdout = &exportWriter{output: output} + cmd.WaitDelay = time.Second + return cmd.Run() +} + +type exportWriter struct { + output io.Writer + size int64 +} + +func (w *exportWriter) Write(data []byte) (int, error) { + if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { + return 0, errors.New("workspace export exceeds bound") + } + n, err := w.output.Write(data) + w.size += int64(n) + return n, err +} diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go new file mode 100644 index 000000000..d673ce39d --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -0,0 +1,52 @@ +package localworkspace + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// These paths belong to the packaged Runtime, not a harness or public template. +const ( + InitializationDirectory = "/environment/initialization" + ToolEnvironmentShell = InitializationDirectory + "/tool-env.sh" + ToolEnvironmentJSON = InitializationDirectory + "/tool-env.json" + PackageDirectory = "/environment/packages" + SystemPackageDirectory = PackageDirectory + "/system" + SystemPackageReceipt = InitializationDirectory + "/system-root.json" + SystemToolLauncher = "/usr/local/bin/agents-api-tool-root" +) + +// VerifyToolEnvironment is required only for execution consuming initialized +// tool configuration. It never makes Files reads depend on execution setup. +func VerifyToolEnvironment(systemPackages bool) error { + paths := []string{InitializationDirectory, PackageDirectory, ToolEnvironmentShell, ToolEnvironmentJSON} + if systemPackages { + paths = append(paths, SystemPackageDirectory, SystemPackageReceipt, SystemToolLauncher) + } + for _, path := range paths { + actual, err := filepath.EvalSymlinks(path) + info, statErr := os.Lstat(path) + if err != nil || statErr != nil || actual != path { + return errors.New("initialized tool configuration unavailable") + } + if path == InitializationDirectory || path == PackageDirectory || path == SystemPackageDirectory { + if !info.IsDir() { + return errors.New("initialized tool directory unavailable") + } + } else if !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1024*1024 { + return errors.New("initialized tool configuration is not immutable") + } + } + if systemPackages { + raw, err := os.ReadFile(SystemPackageReceipt) + var receipt struct { + Version int `json:"version"` + } + if err != nil || json.Unmarshal(raw, &receipt) != nil || receipt.Version != 1 { + return errors.New("installed system tools unavailable") + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/network_policy_test.go b/apps/parsar-daemon/internal/localworkspace/network_policy_test.go new file mode 100644 index 000000000..d266c81d3 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/network_policy_test.go @@ -0,0 +1,25 @@ +package localworkspace + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" +) + +func TestRuntimeNetworkPolicyMustMatchExecutionButNotReadOnly(t *testing.T) { + for _, deployed := range []string{"", "enabled", "disabled"} { + for _, requested := range []string{"", "enabled", "disabled", "restricted"} { + b, req := testBinding(t) + b.networkAccess = deployed + req.LocalEnvironment.NetworkAccess = requested + _, err := b.Configure(req) + if (err == nil) != (deployed == requested) { + t.Fatalf("execution policy %q/%q: %v", deployed, requested, err) + } + req.WorkspaceReadOnly = true + req.LocalEnvironment = &proto.LocalEnvironment{ID: b.environment} + if _, err := b.Configure(req); err != nil { + t.Fatal("read-only operation requires unrelated execution policy", err) + } + } + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go new file mode 100644 index 000000000..d9aa9a7ec --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -0,0 +1,60 @@ +package localworkspace + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +const CapabilityDirectory = "/environment/initialization/capabilities" +const SkillDirectory = CapabilityDirectory + "/skills" + +// VerifySkills consumes the common initialized layout, independently of native loading. +func VerifySkills(skills []agentskill.Metadata) error { + if len(skills) == 0 { + return nil + } + for _, directory := range []string{CapabilityDirectory, SkillDirectory} { + actual, err := filepath.EvalSymlinks(directory) + if err != nil || actual != directory { + return errors.New("initialized Skill directory unavailable") + } + } + seen := map[string]bool{} + for _, metadata := range skills { + if metadata.Type != "inline" || metadata.Name == "" || filepath.Base(metadata.Name) != metadata.Name || metadata.Name == "." || metadata.Name == ".." || seen[metadata.Name] { + return agentskill.ErrInvalid + } + seen[metadata.Name] = true + root := filepath.Join(SkillDirectory, metadata.Name) + count, total := 0, int64(0) + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return agentskill.ErrInvalid + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 { + return agentskill.ErrInvalid + } + count++ + total += info.Size() + if count > agentskill.MaxFiles || total > agentskill.MaxExpandedBytes { + return agentskill.ErrInvalid + } + return nil + }); err != nil { + return err + } + body, err := os.ReadFile(filepath.Join(root, "SKILL.md")) + if err != nil || agentskill.ValidateManifest(body, metadata) != nil { + return agentskill.ErrInvalid + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go new file mode 100644 index 000000000..fc391c51a --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -0,0 +1,103 @@ +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "io/fs" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// This private bound matches the existing installer; it is not an upstream limit. +const WriteMaxBytes = proto.WorkspaceWriteMaxBytes + +var _ agent.WorkspaceWriter = (*Binding)(nil) + +func (b *Binding) AcceptsFileWrite(environment, session string) bool { + return b != nil && b.writer != nil && b.environment == environment && b.stateKey == "agents-api-"+session +} + +// WriteWorkspaceFile starts only after the caller supplies the complete bounded +// body. Core must persist mutation ownership before invoking this operation. +func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result agent.WorkspaceWriteResult, err error) { + if b == nil || b.writer == nil { + return result, agent.ErrWorkspaceWriteUnsupported + } + if len(data) > WriteMaxBytes || len(path) > 4096 || path == "." || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + return result, agent.ErrWorkspaceWriteInvalid + } + if ctx.Err() != nil { + return result, agent.ErrWorkspaceWriteUnavailable + } + w := b.writer + if !w.mu.TryLock() { + return result, agent.ErrWorkspaceWriteBusy + } + defer w.mu.Unlock() + if w.uncertain { + return result, agent.ErrWorkspaceWriteUncertain + } + defer func() { w.uncertain = errors.Is(err, agent.ErrWorkspaceWriteUncertain) }() + // Once admitted, observer cancellation cannot turn a possible commit into a + // rejection. Missing results poison this Runtime writer, even after local reap. + operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second) + defer cancel() + digest := sha256.Sum256(data) + cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging) + cmd.Dir = "/" + cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} + cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:])) + output := &writeOutput{} + cmd.Stdout = output + cmd.WaitDelay = time.Second + if err := cmd.Start(); err != nil { + return result, agent.ErrWorkspaceWriteUnavailable + } + if err := cmd.Wait(); err != nil { + return result, agent.ErrWorkspaceWriteUncertain + } + return decodeWrite(output.Bytes(), len(data)) +} + +// A malformed helper cannot grow the daemon's output buffer without bound. +type writeOutput struct{ data bytes.Buffer } + +func (b *writeOutput) Bytes() []byte { return b.data.Bytes() } + +func (b *writeOutput) Write(p []byte) (int, error) { + if len(p) > 1024-b.data.Len() { + return 0, errors.New("local write response exceeds limit") + } + return b.data.Write(p) +} + +func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error) { + var wire struct { + Version int `json:"version"` + Outcome string `json:"outcome"` + SizeBytes *int64 `json:"size_bytes"` + Error *string `json:"error"` + } + invalid := agent.ErrWorkspaceWriteUncertain + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 { + return agent.WorkspaceWriteResult{}, invalid + } + if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) { + return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil + } + if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil && (*wire.Error == "invalid_input" || *wire.Error == "write_failed") { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected + } + return agent.WorkspaceWriteResult{}, invalid +} diff --git a/apps/parsar-daemon/internal/localworkspace/write_binding.go b/apps/parsar-daemon/internal/localworkspace/write_binding.go new file mode 100644 index 000000000..c656e8cfb --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/write_binding.go @@ -0,0 +1,45 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" +) + +type fileWriter struct { + helper string + staging string + mu sync.Mutex + uncertain bool +} + +// bindWriter runs only during startup. Path checks do not prove native isolation; +// deployment qualification must deny tools access to staging and its ancestors. +func (b *Binding) bindWriter(helper, staging string) error { + invalid := errors.New("local file writer requires a protected sibling staging directory and external executable") + parent := filepath.Dir(b.workspace) + if parent == "/" || filepath.Dir(staging) != parent || staging == b.workspace { + return invalid + } + for _, name := range []string{b.workspace, staging, helper} { + if !filepath.IsAbs(name) || filepath.Clean(name) != name || strings.ContainsAny(name, "\x00\r\n\\") { + return invalid + } + resolved, err := filepath.EvalSymlinks(name) + if err != nil || resolved != name { + return invalid + } + } + dir, err := os.Stat(staging) + if err != nil || !dir.IsDir() { + return invalid + } + program, err := os.Stat(helper) + if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || helper == parent || strings.HasPrefix(helper, parent+string(filepath.Separator)) { + return invalid + } + b.writer = &fileWriter{helper: helper, staging: staging} + return nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go new file mode 100644 index 000000000..70f83121b --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -0,0 +1,209 @@ +package localworkspace + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +func writableBinding(t *testing.T, script string) *Binding { + t.Helper() + b, _ := testBinding(t) + // macOS test roots may contain /var aliases. Writer deployment paths must be canonical. + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + b.workspace = filepath.Join(parent, "workspace") + staging := filepath.Join(parent, "staging") + for _, p := range []string{b.workspace, staging} { + if err := os.Mkdir(p, 0700); err != nil { + t.Fatal(err) + } + } + helper, err := filepath.EvalSymlinks(b.helper) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+script), 0700); err != nil { + t.Fatal(err) + } + if err := b.bindWriter(helper, staging); err != nil { + t.Fatal(err) + } + return b +} + +func TestWriteRejectsUnsafeBindings(t *testing.T) { + b := writableBinding(t, "exit 1\n") + link := filepath.Join(filepath.Dir(b.workspace), "alias") + if err := os.Symlink(b.writer.staging, link); err != nil { + t.Fatal(err) + } + inside := filepath.Join(b.workspace, "helper") + if err := os.WriteFile(inside, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatal(err) + } + for _, pair := range [][2]string{{"", b.writer.staging}, {b.writer.helper, ""}, {b.writer.helper, b.workspace}, {b.writer.helper, link}, {inside, b.writer.staging}, {b.writer.helper, filepath.Dir(b.workspace)}} { + if err := b.bindWriter(pair[0], pair[1]); err == nil { + t.Fatalf("unsafe writer accepted: %q", pair) + } + } +} + +func TestWriteReceiptAndCredentialBoundary(t *testing.T) { + t.Setenv("PARSAR_PRIVATE_CREDENTIAL", "synthetic-secret") + b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") + result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2}) + if err != nil || result.SizeBytes != 3 { + t.Fatalf("write: %+v %v", result, err) + } + for _, p := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { + if _, err := b.WriteWorkspaceFile(t.Context(), p, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatalf("path %q: %v", p, err) + } + } + if _, err := b.WriteWorkspaceFile(t.Context(), "file", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(err) + } +} + +func TestWriteDetachmentAndConcurrentAdmission(t *testing.T) { + b := writableBinding(t, "cat >/dev/null\ntouch \"$1/started\"\nwhile [ ! -f \"$1/release\" ]; do sleep 0.01; done\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\n") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := b.WriteWorkspaceFile(ctx, "file", nil); done <- err }() + defer os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600) + deadline := time.After(5 * time.Second) + for { + if _, err := os.Stat(filepath.Join(b.workspace, "started")); err == nil { + break + } + select { + case <-deadline: + t.Fatal("helper did not start") + case <-time.After(5 * time.Millisecond): + } + } + cancel() + if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteBusy) { + t.Fatalf("overlap: %v", err) + } + if err := os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal("detached admitted write lost receipt", err) + } + case <-time.After(5 * time.Second): + t.Fatal("write did not finish") + } +} + +func TestWriteUncertaintyStopsSuccessor(t *testing.T) { + for name, script := range map[string]string{ + "missing": "cat >/dev/null\nexit 0\n", + "wrong-size": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":8}'\n", + "overflow": "cat >/dev/null\nhead -c 2048 /dev/zero\n", + "exit": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\nexit 1\n", + } { + t.Run(name, func(t *testing.T) { + b := writableBinding(t, script) + if _, err := b.WriteWorkspaceFile(t.Context(), "file", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + t.Fatalf("first: %v", err) + } + if err := os.WriteFile(b.writer.helper, []byte("#!/bin/sh\ntouch \"$1/forbidden\"\n"), 0700); err != nil { + t.Fatal(err) + } + if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + t.Fatalf("successor: %v", err) + } + if _, err := os.Stat(filepath.Join(b.workspace, "forbidden")); !os.IsNotExist(err) { + t.Fatal("unknown write admitted successor") + } + }) + } +} + +func TestWriteReceiptValidation(t *testing.T) { + for _, response := range []string{ + `{"version":1,"outcome":"completed"}`, + `{"version":1,"outcome":"completed","size_bytes":0,"error":"write_failed"}`, + `{"version":1,"outcome":"failed","size_bytes":0,"error":"write_failed"}`, + `{"version":1,"outcome":"unknown","error":"write_failed"}`, + `{"version":1,"outcome":"failed","error":"other"}`, + `{"version":1,"outcome":"completed","size_bytes":0} {}`, + `{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`, + } { + if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + t.Fatalf("unsafe receipt %s: %v", response, err) + } + } + for _, code := range []string{"invalid_input", "write_failed"} { + if _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0); !errors.Is(err, agent.ErrWorkspaceWriteRejected) { + t.Fatal(err) + } + } +} + +func TestLocalWriteNativeInstaller(t *testing.T) { + helper := os.Getenv("PARSAR_TEST_LOCAL_WRITE_HELPER") + if helper == "" { + t.Skip("requires the built native installer") + } + b := writableBinding(t, "exit 1\n") + if err := b.bindWriter(helper, b.writer.staging); err != nil { + t.Fatal(err) + } + for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { + data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size] + got, err := b.WriteWorkspaceFile(t.Context(), "file", data) + if err != nil || got.SizeBytes != int64(size) { + t.Fatalf("size %d: %+v %v", size, got, err) + } + actual, err := os.ReadFile(filepath.Join(b.workspace, "file")) + if err != nil || !bytes.Equal(actual, data) { + t.Fatalf("size %d bytes differ: %v", size, err) + } + } + old := filepath.Join(b.workspace, "file") + if err := os.WriteFile(old, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(b.workspace, "alias") + if err := os.Link(old, alias); err != nil { + t.Fatal(err) + } + if _, err := b.WriteWorkspaceFile(t.Context(), "file", []byte("replacement")); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(alias); err != nil || string(data) != "original" { + t.Fatal("hard-link contents changed", err) + } + escape := filepath.Join(b.workspace, "escape") + if err := os.Symlink(b.writer.staging, escape); err != nil { + t.Fatal(err) + } + for _, path := range []string{"escape/new", "escape", "missing/file"} { + if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); !errors.Is(err, agent.ErrWorkspaceWriteRejected) { + t.Fatalf("%s: %v", path, err) + } + } + if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { + t.Fatal("known rejection blocked next write", err) + } + entries, err := os.ReadDir(b.writer.staging) + if err != nil || len(entries) != 0 { + t.Fatal("staging retained successful/known-rejected data", err) + } +} diff --git a/apps/parsar-daemon/internal/paths/paths.go b/apps/parsar-daemon/internal/paths/paths.go new file mode 100644 index 000000000..3d5d89d63 --- /dev/null +++ b/apps/parsar-daemon/internal/paths/paths.go @@ -0,0 +1,107 @@ +// Package paths resolves on-disk locations for parsar-daemon state under +// ~/.parsar/parsar-daemon// — one subdir per profile so "test" +// and "prod" servers can be paired in parallel without colliding. +// +// Files are 0o600, parent dir 0o700. These functions only resolve +// paths — callers do the I/O. +package paths + +import ( + "fmt" + "os" + "path/filepath" + "regexp" +) + +const DefaultProfile = "default" + +// profilePattern restricts profile names to filesystem-safe chars so +// a malicious --profile can't escape via "../etc/passwd" tricks. +var profilePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) + +// ValidateProfile rejects names that wouldn't survive being used as +// a directory component. +func ValidateProfile(name string) error { + if name == "" { + return fmt.Errorf("profile name must not be empty") + } + if !profilePattern.MatchString(name) { + return fmt.Errorf("profile %q must match %s", name, profilePattern.String()) + } + return nil +} + +// Root returns ~/.parsar. Honours PARSAR_HOME for tests / +// sandbox environments without a writable home. +func Root() (string, error) { + if override := os.Getenv("PARSAR_HOME"); override != "" { + return override, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".parsar"), nil +} + +// ProfileDir returns ~/.parsar/parsar-daemon/. NOT created; +// use EnsureProfileDir. +func ProfileDir(profile string) (string, error) { + if err := ValidateProfile(profile); err != nil { + return "", err + } + root, err := Root() + if err != nil { + return "", err + } + return filepath.Join(root, "parsar-daemon", profile), nil +} + +// EnsureProfileDir mkdirs the profile dir at mode 0o700 and returns +// its path. Idempotent. +func EnsureProfileDir(profile string) (string, error) { + dir, err := ProfileDir(profile) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create profile dir %s: %w", dir, err) + } + return dir, nil +} + +// AuthFile returns the absolute path to auth.json for a profile. +func AuthFile(profile string) (string, error) { + dir, err := ProfileDir(profile) + if err != nil { + return "", err + } + return filepath.Join(dir, "auth.json"), nil +} + +// PIDFile returns the absolute path to connect.pid for a profile. +func PIDFile(profile string) (string, error) { + dir, err := ProfileDir(profile) + if err != nil { + return "", err + } + return filepath.Join(dir, "connect.pid"), nil +} + +// LogFile returns the absolute path to connect.log for a profile. +func LogFile(profile string) (string, error) { + dir, err := ProfileDir(profile) + if err != nil { + return "", err + } + return filepath.Join(dir, "connect.log"), nil +} + +// SessionsFile returns the absolute path to sessions.json. +func SessionsFile(profile string) (string, error) { + dir, err := ProfileDir(profile) + if err != nil { + return "", err + } + return filepath.Join(dir, "sessions.json"), nil +} diff --git a/apps/parsar-daemon/internal/paths/paths_test.go b/apps/parsar-daemon/internal/paths/paths_test.go new file mode 100644 index 000000000..bc42181a8 --- /dev/null +++ b/apps/parsar-daemon/internal/paths/paths_test.go @@ -0,0 +1,130 @@ +package paths_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// withTempHome points PARSAR_HOME at a fresh tempdir for the test. +// t.Setenv refuses to run with t.Parallel — the exact constraint +// we want. +func withTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("PARSAR_HOME", dir) + return dir +} + +func TestValidateProfile(t *testing.T) { + good := []string{"default", "test", "prod", "alpha-1", "alice_mac", "a.b.c", strings.Repeat("a", 64)} + for _, name := range good { + if err := paths.ValidateProfile(name); err != nil { + t.Errorf("ValidateProfile(%q) returned %v, want nil", name, err) + } + } + bad := []string{ + "", + "../escape", + "with/slash", + "with\\backslash", + "with space", + strings.Repeat("a", 65), + "emoji_\xf0\x9f\x98\x80", + } + for _, name := range bad { + if err := paths.ValidateProfile(name); err == nil { + t.Errorf("ValidateProfile(%q) returned nil, want error", name) + } + } +} + +func TestRootHonoursParsarHome(t *testing.T) { + home := withTempHome(t) + got, err := paths.Root() + if err != nil { + t.Fatalf("Root: %v", err) + } + if got != home { + t.Fatalf("Root = %q, want %q", got, home) + } +} + +func TestProfileDirAndFiles(t *testing.T) { + home := withTempHome(t) + want := filepath.Join(home, "parsar-daemon", "test") + + gotDir, err := paths.ProfileDir("test") + if err != nil { + t.Fatalf("ProfileDir: %v", err) + } + if gotDir != want { + t.Fatalf("ProfileDir = %q, want %q", gotDir, want) + } + + // ProfileDir alone must not create the directory. + if _, err := os.Stat(gotDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("ProfileDir created dir prematurely: stat err = %v", err) + } + + cases := map[string]func(string) (string, error){ + "auth.json": paths.AuthFile, + "connect.pid": paths.PIDFile, + "connect.log": paths.LogFile, + "sessions.json": paths.SessionsFile, + } + for filename, fn := range cases { + got, err := fn("test") + if err != nil { + t.Fatalf("%s resolver: %v", filename, err) + } + expect := filepath.Join(want, filename) + if got != expect { + t.Errorf("%s = %q, want %q", filename, got, expect) + } + } +} + +func TestEnsureProfileDirCreates0700(t *testing.T) { + _ = withTempHome(t) + dir, err := paths.EnsureProfileDir("default") + if err != nil { + t.Fatalf("EnsureProfileDir: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat after EnsureProfileDir: %v", err) + } + if !info.IsDir() { + t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) + } + if mode := info.Mode().Perm(); mode != 0o700 { + t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) + } + + // Idempotent (mkdir -p semantics). + dir2, err := paths.EnsureProfileDir("default") + if err != nil { + t.Fatalf("EnsureProfileDir (second call): %v", err) + } + if dir2 != dir { + t.Fatalf("EnsureProfileDir second call returned %q, want %q", dir2, dir) + } +} + +func TestInvalidProfileShortCircuits(t *testing.T) { + _ = withTempHome(t) + if _, err := paths.ProfileDir("bad/profile"); err == nil { + t.Fatal("ProfileDir accepted invalid profile name") + } + if _, err := paths.AuthFile("bad/profile"); err == nil { + t.Fatal("AuthFile accepted invalid profile name") + } + if _, err := paths.EnsureProfileDir("bad/profile"); err == nil { + t.Fatal("EnsureProfileDir accepted invalid profile name") + } +} diff --git a/apps/parsar-daemon/internal/transport/bootstrap.go b/apps/parsar-daemon/internal/transport/bootstrap.go new file mode 100644 index 000000000..14b4b68c9 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/bootstrap.go @@ -0,0 +1,140 @@ +// Package transport carries proto.Envelope frames between the daemon +// and the server-side agentdaemon gateway: +// +// 1. Bootstrap — one-shot HTTP POST asking the server for the WS URL +// and heartbeat cadence; validates credential before WS handshake. +// 2. Dial / Conn — the live WebSocket. Single send goroutine (gorilla +// requires single writer) and a bounded recv channel. +// 3. Reconnect — exponential-backoff loop the caller drives. +// +// Wire-only: nothing here knows about agent kinds. +package transport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// BootstrapResponse mirrors gateway/handler.go's Bootstrap response. +type BootstrapResponse struct { + DeviceID string `json:"device_id"` + WorkspaceID string `json:"workspace_id"` + WSURL string `json:"ws_url"` + HeartbeatSeconds int `json:"heartbeat_seconds"` + ProtocolVersion string `json:"protocol_version"` +} + +// HeartbeatInterval defends against the server returning 0 — a zero +// heartbeat means "never ping" which guarantees a 60s timeout, so we +// fall back to 15s instead of silently breaking liveness. +func (b BootstrapResponse) HeartbeatInterval() time.Duration { + if b.HeartbeatSeconds <= 0 { + return 15 * time.Second + } + return time.Duration(b.HeartbeatSeconds) * time.Second +} + +// Bootstrap calls POST /agent-daemon/bootstrap with the +// device's runner_credential as a bearer token. +func Bootstrap(ctx context.Context, serverURL, deviceID, credential, daemonVersion string) (*BootstrapResponse, error) { + if strings.TrimSpace(serverURL) == "" { + return nil, fmt.Errorf("transport.Bootstrap: serverURL required") + } + if strings.TrimSpace(deviceID) == "" { + return nil, fmt.Errorf("transport.Bootstrap: deviceID required") + } + if strings.TrimSpace(credential) == "" { + return nil, fmt.Errorf("transport.Bootstrap: credential required") + } + body, err := json.Marshal(map[string]string{"device_id": deviceID}) + if err != nil { + return nil, fmt.Errorf("transport.Bootstrap: marshal body: %w", err) + } + target, err := joinURL(serverURL, "/agent-daemon/bootstrap") + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("transport.Bootstrap: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+credential) + if daemonVersion != "" { + req.Header.Set("User-Agent", "parsar-daemon/"+daemonVersion) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("transport.Bootstrap: post: %w", err) + } + defer resp.Body.Close() + // Cap the body so a misbehaving server can't OOM us. 64 KiB is + // orders of magnitude above what the real handler returns. + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("transport.Bootstrap: server returned %s: %s", resp.Status, strings.TrimSpace(string(raw))) + } + var out BootstrapResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("transport.Bootstrap: decode response: %w", err) + } + if out.DeviceID == "" { + return nil, fmt.Errorf("transport.Bootstrap: server returned empty device_id") + } + return &out, nil +} + +// joinURL concatenates base + path, normalising trailing slashes. +func joinURL(base, path string) (string, error) { + u, err := url.Parse(strings.TrimRight(base, "/")) + if err != nil { + return "", fmt.Errorf("transport: parse base url %q: %w", base, err) + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("transport: base url %q is missing scheme or host", base) + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + u.Path = u.Path + path + return u.String(), nil +} + +// DeriveWSURL turns a Bootstrap response's ws_url into the absolute URL +// to dial. Empty ws_url (dev mode without a separate public hostname) +// is derived from serverBase by swapping http→ws / https→wss. Non- +// absolute ws_url is rejected — the server-side handler is the only +// component that knows the externally-reachable host. +func DeriveWSURL(boot BootstrapResponse, serverBase string) (string, error) { + if abs := strings.TrimSpace(boot.WSURL); abs != "" { + u, err := url.Parse(abs) + if err != nil { + return "", fmt.Errorf("transport: parse ws_url %q: %w", abs, err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return "", fmt.Errorf("transport: ws_url %q must use ws:// or wss://", abs) + } + return abs, nil + } + u, err := url.Parse(strings.TrimRight(serverBase, "/")) + if err != nil { + return "", fmt.Errorf("transport: parse serverBase: %w", err) + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + default: + return "", fmt.Errorf("transport: serverBase scheme %q must be http or https", u.Scheme) + } + u.Path = strings.TrimRight(u.Path, "/") + "/agent-daemon/ws" + return u.String(), nil +} diff --git a/apps/parsar-daemon/internal/transport/bootstrap_test.go b/apps/parsar-daemon/internal/transport/bootstrap_test.go new file mode 100644 index 000000000..8dc951999 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/bootstrap_test.go @@ -0,0 +1,161 @@ +package transport_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" +) + +func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { + var sawAuth, sawCT, sawDeviceID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agent-daemon/bootstrap" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method %q", r.Method) + } + sawAuth = r.Header.Get("Authorization") + sawCT = r.Header.Get("Content-Type") + var body struct { + DeviceID string `json:"device_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + sawDeviceID = body.DeviceID + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_id": "rt_abc", + "workspace_id": "ws_xyz", + "ws_url": "wss://example/agent-daemon/ws", + "heartbeat_seconds": 15, + "protocol_version": "0.2.0", + }) + })) + defer srv.Close() + + resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt_abc", "secret123", "0.0.0-dev") + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + if resp.DeviceID != "rt_abc" || resp.WorkspaceID != "ws_xyz" { + t.Errorf("unexpected response: %+v", resp) + } + if resp.WSURL != "wss://example/agent-daemon/ws" { + t.Errorf("ws_url = %q", resp.WSURL) + } + if got := resp.HeartbeatInterval().Seconds(); got != 15 { + t.Errorf("HeartbeatInterval = %vs, want 15s", got) + } + if sawAuth != "Bearer secret123" { + t.Errorf("Authorization = %q, want Bearer secret123", sawAuth) + } + if sawCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", sawCT) + } + if sawDeviceID != "rt_abc" { + t.Errorf("body.device_id = %q, want rt_abc", sawDeviceID) + } +} + +func TestBootstrapHeartbeatIntervalDefaultsToFifteen(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_id": "rt", + "workspace_id": "ws", + "ws_url": "", + "heartbeat_seconds": 0, // server forgot to set it + }) + })) + defer srv.Close() + + resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + if got := resp.HeartbeatInterval().Seconds(); got != 15 { + t.Errorf("HeartbeatInterval = %vs, want 15s default", got) + } +} + +func TestBootstrapNonSuccessSurfacesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad_credential","detail":"wrong key"}`)) + })) + defer srv.Close() + + _, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") + if err == nil { + t.Fatal("Bootstrap returned nil error on 401") + } + if !strings.Contains(err.Error(), "bad_credential") || !strings.Contains(err.Error(), "401") { + t.Errorf("error %q missing 'bad_credential' or '401'", err.Error()) + } +} + +func TestBootstrapRejectsEmptyInputs(t *testing.T) { + cases := []struct{ name, base, device, cred string }{ + {"empty base", "", "rt", "c"}, + {"empty device", "https://x", "", "c"}, + {"empty cred", "https://x", "rt", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := transport.Bootstrap(context.Background(), tc.base, tc.device, tc.cred, "v") + if err == nil { + t.Fatal("Bootstrap accepted empty input") + } + }) + } +} + +func TestDeriveWSURLPrefersAbsolute(t *testing.T) { + got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") + if err != nil { + t.Fatalf("DeriveWSURL: %v", err) + } + if got != "wss://prod/agent-daemon/ws" { + t.Errorf("got %q, want wss://prod/agent-daemon/ws", got) + } +} + +func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { + cases := []struct{ base, want string }{ + {"https://parsar.example.com", "wss://parsar.example.com/agent-daemon/ws"}, + {"http://localhost:3000", "ws://localhost:3000/agent-daemon/ws"}, + {"http://localhost:3000/", "ws://localhost:3000/agent-daemon/ws"}, + {"https://parsar.example.com/api", "wss://parsar.example.com/api/agent-daemon/ws"}, + } + for _, tc := range cases { + got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, tc.base) + if err != nil { + t.Errorf("DeriveWSURL(%q): %v", tc.base, err) + continue + } + if got != tc.want { + t.Errorf("DeriveWSURL(%q) = %q, want %q", tc.base, got, tc.want) + } + } +} + +func TestDeriveWSURLRejectsRelativeWSURL(t *testing.T) { + _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "/agent-daemon/ws"}, "https://x") + if err == nil { + t.Fatal("DeriveWSURL accepted relative ws_url") + } +} + +func TestDeriveWSURLRejectsBadScheme(t *testing.T) { + _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, "ftp://example") + if err == nil { + t.Fatal("DeriveWSURL accepted ftp scheme") + } +} diff --git a/apps/parsar-daemon/internal/transport/reconnect.go b/apps/parsar-daemon/internal/transport/reconnect.go new file mode 100644 index 000000000..615019ee1 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/reconnect.go @@ -0,0 +1,126 @@ +package transport + +import ( + "context" + "errors" + "math/rand/v2" + "time" +) + +// BackoffPolicy is the exponential-backoff schedule for redialing. +// Defaults: 1s → 2s → 4s → ... capped at 30s, with ±20% jitter so a +// fleet of restarting daemons doesn't thunder onto the gateway. +type BackoffPolicy struct { + // Initial is the first delay after attempt 1 fails. Zero → 1s. + Initial time.Duration + // Max caps any single delay. Zero → 30s. + Max time.Duration + // Factor is the multiplier between delays. ≤1 → 2.0. + Factor float64 + // JitterFraction is ±range as a fraction of the unjittered delay. + // Zero disables jitter; default 0.2 gives ±20%. + JitterFraction float64 +} + +// DefaultBackoff is the production policy. +var DefaultBackoff = BackoffPolicy{ + Initial: 1 * time.Second, + Max: 30 * time.Second, + Factor: 2.0, + JitterFraction: 0.2, +} + +// Delay returns the delay before attempt n (1-indexed: attempt 1 = the +// FIRST retry after the initial failure). The result is jittered and +// clamped to [0, Max]. +func (p BackoffPolicy) Delay(attempt int) time.Duration { + if attempt < 1 { + attempt = 1 + } + if p.Initial <= 0 { + p.Initial = 1 * time.Second + } + if p.Max <= 0 { + p.Max = 30 * time.Second + } + if p.Factor <= 1 { + p.Factor = 2.0 + } + d := float64(p.Initial) + for i := 1; i < attempt; i++ { + d *= p.Factor + if d >= float64(p.Max) { + d = float64(p.Max) + break + } + } + if p.JitterFraction > 0 { + // rand/v2 yields a uniform float in [0, 1); shift to [-1, 1). + jitter := (rand.Float64()*2 - 1) * p.JitterFraction * d + d += jitter + } + if d < 0 { + return 0 + } + if d > float64(p.Max) { + d = float64(p.Max) + } + return time.Duration(d) +} + +// Sleep blocks for d unless ctx fires first. Returns ctx.Err() on +// cancellation so callers can decide whether to bail out of the +// reconnect loop. Returns nil on normal expiry. +func Sleep(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// ErrPermanent is returned by a DialFn when the error must NOT be +// retried — e.g. 401 bad_credential or 426 incompatible_version. +var ErrPermanent = errors.New("transport: permanent error (do not retry)") + +// DialFn is invoked once per attempt. Wrap with ErrPermanent on auth/ +// protocol fatalities; any other error triggers backoff + retry. +type DialFn func(ctx context.Context) (*Conn, error) + +// Reconnect loops on dial calls with exponential backoff. First attempt +// has no warm-up delay. onAttempt (if non-nil) is invoked before each +// attempt with the attempt index, last delay slept, and the previous +// dial error — surfacing lastErr lets the caller log root causes +// instead of hiding them behind retry bookkeeping. +func Reconnect(ctx context.Context, dial DialFn, policy BackoffPolicy, onAttempt func(attempt int, lastDelay time.Duration, lastErr error)) (*Conn, error) { + var ( + lastDelay time.Duration + lastErr error + ) + for attempt := 1; ; attempt++ { + if onAttempt != nil { + onAttempt(attempt, lastDelay, lastErr) + } + conn, err := dial(ctx) + if err == nil { + return conn, nil + } + if errors.Is(err, ErrPermanent) { + return nil, err + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + lastErr = err + lastDelay = policy.Delay(attempt) + if sleepErr := Sleep(ctx, lastDelay); sleepErr != nil { + return nil, sleepErr + } + } +} diff --git a/apps/parsar-daemon/internal/transport/reconnect_test.go b/apps/parsar-daemon/internal/transport/reconnect_test.go new file mode 100644 index 000000000..a1320e024 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/reconnect_test.go @@ -0,0 +1,165 @@ +package transport_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" +) + +func TestBackoffPolicyDelayGrowsExponentiallyAndCaps(t *testing.T) { + // No jitter for deterministic assertion. + p := transport.BackoffPolicy{Initial: time.Second, Max: 10 * time.Second, Factor: 2.0} + want := []time.Duration{ + 1 * time.Second, // attempt 1 + 2 * time.Second, // attempt 2 + 4 * time.Second, // attempt 3 + 8 * time.Second, // attempt 4 + 10 * time.Second, // attempt 5 (capped) + 10 * time.Second, // attempt 6 (stays capped) + } + for i, w := range want { + got := p.Delay(i + 1) + if got != w { + t.Errorf("Delay(attempt=%d) = %v, want %v", i+1, got, w) + } + } +} + +func TestBackoffPolicyJitterStaysWithinFraction(t *testing.T) { + p := transport.BackoffPolicy{Initial: time.Second, Max: 30 * time.Second, Factor: 2.0, JitterFraction: 0.2} + // Attempt 4 unjittered = 8s. ±20% means [6.4s, 9.6s]. + for range 100 { + got := p.Delay(4) + if got < (6400*time.Millisecond) || got > (9600*time.Millisecond) { + t.Fatalf("Delay(4) = %v, want in [6.4s, 9.6s]", got) + } + } +} + +func TestBackoffPolicyZeroDefaultsAreSensible(t *testing.T) { + // All-zero policy must still produce a positive delay. + p := transport.BackoffPolicy{} + got := p.Delay(1) + if got <= 0 || got > 2*time.Second { + t.Errorf("default policy Delay(1) = %v, want roughly 1s ± jitter", got) + } +} + +func TestSleepReturnsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := transport.Sleep(ctx, 1*time.Hour); !errors.Is(err, context.Canceled) { + t.Errorf("Sleep returned %v, want context.Canceled", err) + } +} + +func TestSleepNoOpForZeroDuration(t *testing.T) { + start := time.Now() + if err := transport.Sleep(context.Background(), 0); err != nil { + t.Errorf("Sleep(0): %v", err) + } + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Errorf("Sleep(0) took %v, want <50ms", elapsed) + } +} + +func TestReconnectReturnsOnFirstSuccess(t *testing.T) { + calls := 0 + dial := func(_ context.Context) (*transport.Conn, error) { + calls++ + return nil, nil // success path + } + conn, err := transport.Reconnect(context.Background(), dial, transport.BackoffPolicy{Initial: time.Millisecond}, nil) + if err != nil { + t.Fatalf("Reconnect: %v", err) + } + if conn != nil { + t.Errorf("expected nil Conn from stub dial, got %v", conn) + } + if calls != 1 { + t.Errorf("dial called %d times, want 1", calls) + } +} + +func TestReconnectBailsOnPermanentError(t *testing.T) { + calls := 0 + dial := func(_ context.Context) (*transport.Conn, error) { + calls++ + return nil, fmt.Errorf("auth dead: %w", transport.ErrPermanent) + } + _, err := transport.Reconnect(context.Background(), dial, transport.BackoffPolicy{Initial: time.Millisecond}, nil) + if !errors.Is(err, transport.ErrPermanent) { + t.Errorf("Reconnect err = %v, want ErrPermanent chain", err) + } + if calls != 1 { + t.Errorf("dial called %d times on permanent err, want 1 (no retry)", calls) + } +} + +func TestReconnectRetriesUntilSuccess(t *testing.T) { + calls := 0 + dial := func(_ context.Context) (*transport.Conn, error) { + calls++ + if calls < 3 { + return nil, errors.New("flaky") + } + return nil, nil + } + var seenAttempts []int + var seenErrs []error + _, err := transport.Reconnect( + context.Background(), dial, + transport.BackoffPolicy{Initial: time.Millisecond, Max: time.Millisecond, Factor: 2.0}, + func(attempt int, _ time.Duration, lastErr error) { + seenAttempts = append(seenAttempts, attempt) + seenErrs = append(seenErrs, lastErr) + }, + ) + if err != nil { + t.Fatalf("Reconnect: %v", err) + } + if calls != 3 { + t.Errorf("dial called %d times, want 3", calls) + } + if got, want := seenAttempts, []int{1, 2, 3}; !equalInts(got, want) { + t.Errorf("onAttempt sequence = %v, want %v", got, want) + } + // First callback has no prior error; subsequent ones see the + // "flaky" error so the daemon log can show WHY it's retrying. + if seenErrs[0] != nil { + t.Errorf("onAttempt[0] lastErr = %v, want nil on first attempt", seenErrs[0]) + } + for i := 1; i < len(seenErrs); i++ { + if seenErrs[i] == nil || seenErrs[i].Error() != "flaky" { + t.Errorf("onAttempt[%d] lastErr = %v, want %q", i, seenErrs[i], "flaky") + } + } +} + +func TestReconnectHonoursContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + dial := func(_ context.Context) (*transport.Conn, error) { + cancel() + return nil, errors.New("transient") + } + _, err := transport.Reconnect(ctx, dial, transport.BackoffPolicy{Initial: time.Hour}, nil) + if !errors.Is(err, context.Canceled) { + t.Errorf("Reconnect err = %v, want context.Canceled", err) + } +} + +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/apps/parsar-daemon/internal/transport/ws.go b/apps/parsar-daemon/internal/transport/ws.go new file mode 100644 index 000000000..fdcd686d1 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/ws.go @@ -0,0 +1,369 @@ +package transport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + + "net/http" + "net/url" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// DialOptions is the input to Dial. HTTPHeader is optional. +type DialOptions struct { + // WSURL is the absolute ws://... or wss://... URL. + WSURL string + + // DeviceID is the runtime row id stamped at pair time. Sent as + // device_id query param; the gateway uses it as the session key. + DeviceID string + + // Credential is the bearer compared against runner_credential_hash. + // Sent as the token query param (not as a header) because some + // HTTP middleware strips Authorization on upgrade requests. + Credential string + + // DaemonVersion is the X.Y.Z string used for + // proto.VersionCompatible. Mismatches close at upgrade time (4126). + DaemonVersion string + + // HandshakeTimeout caps WS upgrade wait. Zero → 10s. + HandshakeTimeout time.Duration + + // HTTPHeader is appended to the upgrade request. Useful in tests. + HTTPHeader http.Header +} + +// Conn is the daemon-side WebSocket. One read goroutine + one write +// goroutine; callers Send synchronously and Recv off a channel. +// +// Conn does NOT own the heartbeat loop — call StartHeartbeats once +// Dial returns. Splitting keeps transport free of "what's a live +// request count" knowledge. +type Conn struct { + ws *websocket.Conn + deviceID string + + recvCh chan proto.Envelope + sendCh chan envelopeWithAck + + closeOnce sync.Once + closed chan struct{} + + errMu sync.Mutex + err error + + hbCancel context.CancelFunc +} + +// envelopeWithAck pairs an outbound frame with a done channel so Send +// blocks until the frame is flushed (or the write goroutine errors). +// Single-writer serialisation point gorilla/websocket requires. +type envelopeWithAck struct { + env proto.Envelope + ack chan error +} + +// Dial opens the reverse WebSocket. Read/write goroutines are live +// on return; caller MUST eventually Close to avoid goroutine leaks. +func Dial(ctx context.Context, opts DialOptions) (*Conn, error) { + if opts.WSURL == "" || opts.DeviceID == "" || opts.Credential == "" || opts.DaemonVersion == "" { + return nil, fmt.Errorf("transport.Dial: WSURL, DeviceID, Credential, DaemonVersion are all required") + } + if opts.HandshakeTimeout <= 0 { + opts.HandshakeTimeout = 10 * time.Second + } + dialURL, err := withQueryParams(opts.WSURL, map[string]string{ + "device_id": opts.DeviceID, + "token": opts.Credential, + "version": opts.DaemonVersion, + }) + if err != nil { + return nil, err + } + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = opts.HandshakeTimeout + + dialCtx, cancel := context.WithTimeout(ctx, opts.HandshakeTimeout) + defer cancel() + wsConn, resp, err := dialer.DialContext(dialCtx, dialURL, opts.HTTPHeader) + if err != nil { + // 401/403/426 are operator-fixable and MUST NOT be retried — + // the gateway will keep rejecting until credential / device / + // daemon version is fixed. Mark them ErrPermanent so Reconnect + // bails. Everything else stays transient. + if resp != nil { + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusUpgradeRequired: + return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with %s: %w: %w", resp.Status, err, ErrPermanent) + } + return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with %s: %w", resp.Status, err) + } + return nil, fmt.Errorf("transport.Dial: ws upgrade: %w", err) + } + // Bound a single inbound frame so a misbehaving server can't OOM + // us. Matches the gateway's 4 MiB outbound ceiling. + wsConn.SetReadLimit(4 * 1024 * 1024) + + c := &Conn{ + ws: wsConn, + deviceID: opts.DeviceID, + recvCh: make(chan proto.Envelope, 64), + sendCh: make(chan envelopeWithAck, 64), + closed: make(chan struct{}), + } + go c.writeLoop() + go c.readLoop() + return c, nil +} + +// Recv returns the inbound envelope channel. Closes when the connection +// terminates; Err() returns the cause after. +func (c *Conn) Recv() <-chan proto.Envelope { return c.recvCh } + +// Done closes when the connection has shut down. +func (c *Conn) Done() <-chan struct{} { return c.closed } + +// Err returns the first fatal error from either loop, or nil on clean +// shutdown via Close. +func (c *Conn) Err() error { + c.errMu.Lock() + defer c.errMu.Unlock() + return c.err +} + +func (c *Conn) DeviceID() string { return c.deviceID } + +// Send marshals env, queues it, and blocks until written (or ctx fires +// / conn closes). Safe for concurrent callers — the write goroutine is +// the only thing touching the underlying ws write path. +func (c *Conn) Send(ctx context.Context, env proto.Envelope) error { + ack := make(chan error, 1) + wrapped := envelopeWithAck{env: env, ack: ack} + select { + case c.sendCh <- wrapped: + case <-c.closed: + return errClosedOrErr(c) + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-ack: + return err + case <-c.closed: + return errClosedOrErr(c) + case <-ctx.Done(): + return ctx.Err() + } +} + +// Close tears down the conn. Safe to call concurrently; idempotent. +// The courteous CloseMessage frame is NOT written from here — +// writeLoop owns single-writer access and emits it from its defer. +func (c *Conn) Close() error { + c.closeOnce.Do(func() { + if c.hbCancel != nil { + c.hbCancel() + } + close(c.closed) + }) + return nil +} + +// StartHeartbeats kicks off a ticker that calls payloadFn every +// interval and Sends the resulting HeartbeatPayload. Returns +// immediately. Caller-controlled because the heartbeat carries fields +// (active_requests, claude_available) only the agent layer knows. +// Nil logger falls back to log.Bg(). +func (c *Conn) StartHeartbeats(parentCtx context.Context, interval time.Duration, payloadFn func() proto.HeartbeatPayload, logger *slog.Logger) { + if interval <= 0 || payloadFn == nil { + return + } + if logger == nil { + logger = log.Bg() + } + ctx, cancel := context.WithCancel(parentCtx) + c.hbCancel = cancel + go func() { + defer cancel() + sendHeartbeat := func(sendTimeout time.Duration) { + env, err := proto.NewEnvelope(proto.TypeHeartbeat, "", payloadFn()) + if err != nil { + logger.Error("marshal heartbeat envelope", "err", err) + return + } + // Per-send deadline so a wedged peer doesn't pile up + // heartbeats on sendCh. + sendCtx, sendCancel := context.WithTimeout(ctx, sendTimeout) + if err := c.Send(sendCtx, env); err != nil && !errors.Is(err, context.Canceled) { + logger.Warn("send heartbeat", "err", err) + } + sendCancel() + } + + sendHeartbeat(interval) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-c.closed: + return + case <-t.C: + sendHeartbeat(interval) + } + } + }() +} + +// --------------------------------------------------------------------- +// internals +// --------------------------------------------------------------------- + +// writeLoop owns every write to the underlying websocket. Single- +// writer invariant: this goroutine is the ONLY place that touches the +// ws write path — including the final courteous Close frame, emitted +// from the defer rather than from Conn.Close() so gorilla/websocket's +// internal write state isn't raced. +func (c *Conn) writeLoop() { + defer func() { + // Best-effort close frame so the peer sees code 1000 rather + // than EOF. + _ = c.ws.SetWriteDeadline(time.Now().Add(time.Second)) + _ = c.ws.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "client closing")) + _ = c.ws.Close() + }() + for { + select { + case <-c.closed: + return + case msg := <-c.sendCh: + raw, err := json.Marshal(msg.env) + if err != nil { + msg.ack <- fmt.Errorf("transport: marshal envelope: %w", err) + continue + } + _ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.ws.WriteMessage(websocket.TextMessage, raw); err != nil { + wrapped := fmt.Errorf("transport: write envelope (type=%s): %w", msg.env.Type, err) + msg.ack <- wrapped + c.fail(wrapped) + return + } + msg.ack <- nil + } + } +} + +// readLoop pulls frames, decodes, pushes onto recvCh. Bounded recv +// buffer means a stuck consumer eventually backpressures into the WS +// read deadline — better to disconnect than balloon memory. +func (c *Conn) readLoop() { + defer func() { + // Closing recvCh signals consumers ("drain me then check + // Err()"); closing closed signals everyone else. + c.closeOnce.Do(func() { + if c.hbCancel != nil { + c.hbCancel() + } + _ = c.ws.Close() + close(c.closed) + }) + close(c.recvCh) + }() + for { + _, raw, err := c.ws.ReadMessage() + if err != nil { + if isCleanClose(err) { + return + } + if isPermanentClose(err) { + c.fail(fmt.Errorf("transport: server closed connection (runtime deleted): %w", ErrPermanent)) + return + } + c.fail(fmt.Errorf("transport: read frame: %w", err)) + return + } + log.Bg().Debug("transport: received WS frame", "bytes", len(raw)) + var env proto.Envelope + if err := json.Unmarshal(raw, &env); err != nil { + // Skip malformed frames rather than tearing down — server + // might have shipped a new event type we don't understand + // yet, and dropping is friendlier than disconnecting. + log.Bg().Warn("transport: dropping malformed WS frame", + "bytes", len(raw), "err", err.Error(), "head", string(raw[:min(len(raw), 200)])) + continue + } + log.Bg().Info("transport: dispatching envelope", "type", env.Type, "id", env.ID) + select { + case c.recvCh <- env: + case <-c.closed: + return + } + } +} + +func (c *Conn) fail(err error) { + c.errMu.Lock() + if c.err == nil { + c.err = err + } + c.errMu.Unlock() +} + +func errClosedOrErr(c *Conn) error { + if e := c.Err(); e != nil { + return e + } + return ErrConnClosed +} + +// ErrConnClosed is returned by Send on clean shutdown. +var ErrConnClosed = errors.New("transport: connection closed") + +// isCleanClose reports peer-initiated graceful close codes. +func isCleanClose(err error) bool { + if websocket.IsCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + ) { + return true + } + return false +} + +// CloseRuntimeDeleted is the custom WS close code the server sends +// when the runtime has been deleted by an admin — permanent error. +const CloseRuntimeDeleted = 4001 + +// isPermanentClose reports server-sent close codes that must NOT be +// retried. +func isPermanentClose(err error) bool { + return websocket.IsCloseError(err, CloseRuntimeDeleted) +} + +// withQueryParams appends params, preserving any existing query string. +func withQueryParams(raw string, params map[string]string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("transport: parse ws url %q: %w", raw, err) + } + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/apps/parsar-daemon/internal/transport/ws_test.go b/apps/parsar-daemon/internal/transport/ws_test.go new file mode 100644 index 000000000..d5e013292 --- /dev/null +++ b/apps/parsar-daemon/internal/transport/ws_test.go @@ -0,0 +1,426 @@ +package transport_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// fakeGateway is a minimal stand-in for the server-side +// /agent-daemon/ws handler. Echoes inbound frames and records dial +// query params. +type fakeGateway struct { + upgrader websocket.Upgrader + + mu sync.Mutex + dialURL string + frames []proto.Envelope + wantAuth bool + + connCh chan *websocket.Conn +} + +func newFakeGateway() *fakeGateway { + return &fakeGateway{ + upgrader: websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}, + connCh: make(chan *websocket.Conn, 1), + } +} + +func (g *fakeGateway) handler(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + g.dialURL = r.URL.String() + g.mu.Unlock() + + conn, err := g.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + g.connCh <- conn + + // Read until client closes; record every frame. + for { + _, raw, err := conn.ReadMessage() + if err != nil { + _ = conn.Close() + return + } + var env proto.Envelope + if err := json.Unmarshal(raw, &env); err != nil { + continue + } + g.mu.Lock() + g.frames = append(g.frames, env) + g.mu.Unlock() + } +} + +func (g *fakeGateway) recordedFrames() []proto.Envelope { + g.mu.Lock() + defer g.mu.Unlock() + out := make([]proto.Envelope, len(g.frames)) + copy(out, g.frames) + return out +} + +func (g *fakeGateway) recordedDialURL() string { + g.mu.Lock() + defer g.mu.Unlock() + return g.dialURL +} + +// httpToWS rewrites httptest server URL into the ws:// equivalent. +func httpToWS(s string) string { return "ws" + strings.TrimPrefix(s, "http") } + +func TestDialPassesAuthInQueryParams(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "rt_abc", + Credential: "shh-secret", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + dialURL := gw.recordedDialURL() + if !strings.Contains(dialURL, "device_id=rt_abc") { + t.Errorf("dial URL %q missing device_id", dialURL) + } + if !strings.Contains(dialURL, "token=shh-secret") { + t.Errorf("dial URL %q missing token", dialURL) + } + if !strings.Contains(dialURL, "version=0.1.0") { + t.Errorf("dial URL %q missing version", dialURL) + } + if got := conn.DeviceID(); got != "rt_abc" { + t.Errorf("Conn.DeviceID = %q, want rt_abc", got) + } +} + +func TestDialRoundTripsEnvelope(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + env, err := proto.NewEnvelope(proto.TypeDelta, "run_1", proto.DeltaPayload{Delta: "hello", Sequence: 1}) + if err != nil { + t.Fatalf("NewEnvelope: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := conn.Send(ctx, env); err != nil { + t.Fatalf("Send: %v", err) + } + + // Poll because the read happens in a goroutine and recording is + // post-decode. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + frames := gw.recordedFrames() + if len(frames) > 0 { + if frames[0].Type != proto.TypeDelta || frames[0].ID != "run_1" { + t.Errorf("recorded frame = %+v, want type=delta id=run_1", frames[0]) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("fake gateway never recorded the sent frame") +} + +func TestRecvDeliversServerSentFrames(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + // Server pushes a prompt_request down to the daemon. + serverConn := <-gw.connCh + pushed, _ := proto.NewEnvelope("prompt_request", "run_abc", map[string]string{"prompt": "hi"}) + raw, _ := json.Marshal(pushed) + if err := serverConn.WriteMessage(websocket.TextMessage, raw); err != nil { + t.Fatalf("server write: %v", err) + } + + select { + case env := <-conn.Recv(): + if env.Type != "prompt_request" || env.ID != "run_abc" { + t.Errorf("received %+v, want prompt_request/run_abc", env) + } + case <-time.After(2 * time.Second): + t.Fatal("never received the server-pushed frame") + } +} + +func TestCloseTerminatesRecvAndUnblocksSend(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + + // Wait for server-side accept. + <-gw.connCh + _ = conn.Close() + + select { + case _, ok := <-conn.Recv(): + if ok { + // Drain — close should be followed by close-of-recv-channel. + <-conn.Recv() + } + case <-time.After(2 * time.Second): + t.Fatal("Recv channel was not closed after Close") + } + + // Done() must be closed too. + select { + case <-conn.Done(): + case <-time.After(time.Second): + t.Fatal("Done() never closed after Close") + } + + // A post-close Send must return immediately rather than hang. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + env, _ := proto.NewEnvelope(proto.TypeDelta, "x", proto.DeltaPayload{Delta: "x"}) + if err := conn.Send(ctx, env); err == nil { + t.Fatal("Send after Close returned nil error") + } +} + +func TestStartHeartbeatsTicks(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + <-gw.connCh + + var calls atomic.Int32 + conn.StartHeartbeats(context.Background(), 30*time.Millisecond, func() proto.HeartbeatPayload { + calls.Add(1) + return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), DaemonVersion: "0.0.0-dev"} + }, nil) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + // Look for at least two heartbeat frames at the server end. + var seen int + for _, f := range gw.recordedFrames() { + if f.Type == proto.TypeHeartbeat { + seen++ + } + } + if seen >= 2 { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("expected ≥2 heartbeat frames, got payload calls=%d, frames=%+v", calls.Load(), gw.recordedFrames()) +} + +func TestStartHeartbeatsSendsImmediately(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + <-gw.connCh + + var calls atomic.Int32 + hbCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + conn.StartHeartbeats(hbCtx, time.Hour, func() proto.HeartbeatPayload { + calls.Add(1) + return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), DaemonVersion: "0.0.0-dev"} + }, nil) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for _, f := range gw.recordedFrames() { + if f.Type == proto.TypeHeartbeat { + return + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("expected immediate heartbeat before first interval tick; payload calls=%d frames=%+v", calls.Load(), gw.recordedFrames()) +} + +func TestDialBubblesUpgradeError(t *testing.T) { + // Server that 401s the upgrade. We have to write the rejection + // before any WS handshake completes. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("nope")) + })) + defer srv.Close() + + _, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err == nil { + t.Fatal("Dial returned nil error on 401") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("Dial error %q missing status code", err.Error()) + } +} + +// Regression: WS used to retry forever even when the server returned +// 426 incompatible_version. 401 / 403 / 426 must wrap with +// ErrPermanent so Reconnect bails. +func TestDialMarksOperatorFixableUpgradeRejectionsAsPermanent(t *testing.T) { + for _, tc := range []struct { + name string + code int + }{ + {"unauthorized", http.StatusUnauthorized}, + {"forbidden", http.StatusForbidden}, + {"upgrade_required", http.StatusUpgradeRequired}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(`{"error":"x","detail":"y"}`)) + })) + defer srv.Close() + _, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err == nil { + t.Fatalf("Dial returned nil for status %d", tc.code) + } + if !errors.Is(err, transport.ErrPermanent) { + t.Errorf("Dial err %v does not wrap ErrPermanent (status %d)", err, tc.code) + } + }) + } +} + +// Guards against ErrPermanent classification swallowing transient +// gateway hiccups — 5xx must stay retryable so a deployment / restart +// recovers on its own. +func TestDialDoesNotMarkServerErrorsAsPermanent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + _, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err == nil { + t.Fatal("Dial returned nil for 500") + } + if errors.Is(err, transport.ErrPermanent) { + t.Errorf("Dial err %v marks 500 as permanent (must stay transient)", err) + } +} + +func TestDialValidatesRequiredOptions(t *testing.T) { + _, err := transport.Dial(context.Background(), transport.DialOptions{}) + if err == nil { + t.Fatal("Dial accepted empty options") + } +} + +func TestSendRespectsContextCancel(t *testing.T) { + gw := newFakeGateway() + srv := httptest.NewServer(http.HandlerFunc(gw.handler)) + defer srv.Close() + + conn, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + DeviceID: "d", + Credential: "c", + DaemonVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + <-gw.connCh + + // Build an env, then call Send with an already-cancelled ctx. + env, _ := proto.NewEnvelope(proto.TypeDelta, "r", proto.DeltaPayload{Delta: "x"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := conn.Send(ctx, env); !errors.Is(err, context.Canceled) { + t.Fatalf("Send with cancelled ctx = %v, want context.Canceled", err) + } +} diff --git a/apps/parsar-daemon/testdata/onboarding/main.go b/apps/parsar-daemon/testdata/onboarding/main.go new file mode 100644 index 000000000..b804d027d --- /dev/null +++ b/apps/parsar-daemon/testdata/onboarding/main.go @@ -0,0 +1,125 @@ +// This synthetic harness exercises the production router without a native model. +// It is test-only, has no workspace/tools, and is never in the built-in catalog. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type sender struct { + mu sync.Mutex + encoder *json.Encoder +} + +func (s *sender) Send(_ context.Context, e proto.Envelope) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.encoder.Encode(e) +} + +type harness struct{ history map[string]string } + +func (h *harness) start(_ context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + if !req.StrictResume || !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + return nil, errors.New("unsupported fixture operation") + } + previous := h.history[req.AgentStateKey] + if req.AgentSessionID != previous || req.RequireExistingNativeSession { + return nil, errors.New("native history mismatch") + } + id := previous + if id == "" { + id = "fixture-" + req.AgentStateKey + h.history[req.AgentStateKey] = id + } + s := &session{out: out, run: req.RunID, native: id} + s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: "ready", Sequence: 1}) + return s, nil +} + +type session struct { + mu sync.Mutex + out chan<- proto.Envelope + run, native string + closed bool +} + +func (s *session) emit(kind string, payload any) { + e, _ := proto.NewEnvelope(kind, s.run, payload) + s.out <- e +} +func (s *session) Cancel(context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.closed { + s.closed = true + close(s.out) + } + return nil +} +func (s *session) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "cancelled", Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} +} +func (s *session) Steer(ctx context.Context, p proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, p, func() {}) +} +func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload, written func()) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return agent.ErrSteeringInactive + } + written() + s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: p.Text, Sequence: 2}) + s.emit(proto.TypeDone, proto.DonePayload{Content: "ready" + p.Text, Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) + s.closed = true + close(s.out) + return nil +} + +func run() error { + registry := agent.NewRegistry() + h := &harness{history: map[string]string{}} + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: proto.AgentKindCapabilities{ + Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, ToolObservations: true, SubagentControl: true, EnvironmentNone: true, + }}, h.start) + sink := &sender{encoder: json.NewEncoder(os.Stdout)} + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) + if err != nil { + return err + } + defer router.Shutdown(context.Background()) + heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: registry.SupportedAgentKinds()}) + if err = sink.Send(context.Background(), heartbeat); err != nil { + return err + } + decoder := json.NewDecoder(os.Stdin) + for { + var e proto.Envelope + if err = decoder.Decode(&e); errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + if err = router.Handle(context.Background(), e); err != nil { + return err + } + } +} +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md new file mode 100644 index 000000000..bf9d06f0a --- /dev/null +++ b/contracts/agents-api/README.md @@ -0,0 +1,778 @@ +# Agents API contract + +The external reference is [openai-python beta/agents](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents), +pinned in `upstream.json`. Its resource methods, corresponding types, pagination +and streaming helpers define the compatibility target. This directory records +the boundary; it does not imply that every upstream feature is implemented. + +Parsar owns product Agents and Teams. This service owns upstream execution +resources, including reusable Agents and protocol subagents. The OpenAI Agents +Python **SDK** is a separate future dependency for business Team orchestration in +Parsar, not the HTTP contract. Design rules live in +[CONTRIBUTING.md](../../CONTRIBUTING.md#design-and-compatibility-requirements). + +## Implementation direction + +Keep the independent service, authentication, PostgreSQL/sqlc persistence, +transactional admission and official-client test harness. Replace the parts that +let legacy daemon representations define execution semantics. Starting over is +permitted where a replacement is smaller and clearer; neither a wholesale rewrite +nor compatibility with the old private implementation is a goal. + +Concentrate native configuration, structured input/output and Item translation +in an execution adapter. The application core owns execution state and persistence; +engine-specific shapes stay at the adapter boundary. Codex uses its native +app-server; Claude uses the maintained Agent SDK. Reuse native protocols and SDKs +for further harnesses rather than adding another model/tool loop. +The [harness contract and parity baseline](harnesses.md) describes equal-engine +registration, qualification and shared acceptance. +Verify configuration against actual execution: response defaults must not merely +describe values the adapter never applied. + +The private native registry persists fenced connection observations and pinned +Environment-event snapshots through the existing execution owner. Session reads +and live SSE also expose safe `self_hosted` output and reservation-owned connection +actions. Public self-hosted creation accepts initial text or empty Codex Sessions; +initial input reserves work while returning the connection target promptly. +Later idle text submissions wait for preparation/admission. Cancellation-only events +reuse durable admission without creating work or retargeting retries; pending +pre-Turn input still blocks new cancellation. HTTP acceptance does not establish +native completion or process quiescence. Environment retrieval +exposes durable status and safe empty installation metadata for that profile. +Non-deferred functions and homogeneous result-only batches reuse the existing +callback/application path, with explicit call identity and no new Turn on results. +These callbacks are not installed Environment resources. +Message-only batches append to an active Turn under the same Session lock that +reserves idle work; retries retain their original target through completion and later work. +Populated installation metadata, mixed input and full lifecycle conformance remain +unimplemented; see the [Environment scope](environments.md). +Initial messages commit with creation and a connection action; an initial deadline +failure is queryable before a Turn exists. Ordinary and streamed creation share this path. + +The three-harness Docker V1 MVP is accepted: Codex, Claude Code and MiniMax Code +share the execution/workspace contract, with independent Core/database deployment, +Files/Artifacts, cancellation and owned-history continuation. Optional features +still differ. See the [accepted scope and evidence](#accepted-milestone-and-evidence). +The same three harnesses also passed separate real E2B V1 qualification in PR #705; +see the [E2B operator guide](../../services/agents-api/deploy/e2b/README.md). +Select further work only within current user authorization. Parsar cutover and +business Team orchestration are separate from protocol coverage. + +## Upstream resource inventory + +This inventory is based on the pinned Python source, not our generated OpenAPI. +It contains 42 distinct HTTP operations in 15 resource classes, excluding async +duplicates, overloads and client-side helpers. There are 36 handler entries; the six +Subagent read operations remain missing. The separate +general `/v1/files` source-file API is outside this 42-operation count. + +An implemented route is not complete semantic compatibility. **Accepted** below +means a recorded workflow passed under a specific profile; **partial** means some +variants work; **missing** means no implementation; **unverified** means behavior +has not been shown to match upstream. Do not convert the route count into a +compatibility percentage or treat a Docker result as E2B qualification. + +Paths below are SDK resource paths beneath `client.beta.agents`. Method names use +the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. + +| Resource | Upstream operations | Current coverage | +| --- | --- | --- | +| Root reusable Agents | create, retrieve, update, list, delete | Partial create/retrieve/update/list/delete and Session references; configuration/error gaps remain | +| sessions | create, retrieve, update, list, delete | Create (ordinary/live), retrieve, list with root-Agent filter, metadata-only update, public deletion with owned Docker/E2B cleanup; general physical cleanup and exact hosted semantics remain open | +| sessions.events | create, stream | Text/cancel/function-result admission and live events; function-action state snapshots supported | +| sessions.turns | retrieve, list | Implemented reads; lifecycle conformance still partial | +| sessions.items | list | Partial Item variants | +| sessions.artifacts | retrieve, list, delete, content | Shared output capture and immutable stored reads/deletion on the accepted three-harness Docker/E2B profiles, including retained downloads after Runtime loss; exact upstream defaults/errors, unchanged-file republishing and cancellation-edge parity remain unverified | +| sessions.subagents | retrieve, list | Missing | +| sessions.subagents.items | list | Missing | +| sessions.subagents.turns | retrieve, list | Missing | +| sessions.subagents.turns.items | list | Missing | +| environments | retrieve | Supported Codex self-hosted and three-harness Docker/E2B hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | +| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker/E2B workspaces; Codex self-hosted listing is a separate supported path. Full listing, overwrite and error semantics remain partial | +| environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps | +| vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing | +| vaults.credentials | create, retrieve, update, list, delete | Static-bearer create/retrieve/list/token replacement/deletion with scoped encrypted storage; Session attachment and exact-URL HTTPS MCP binding; OAuth, archive semantics and full hosted lifecycle parity remain missing | + +For each resource, verify the referenced request/response unions and observable +behavior, not just the route. Non-text initial input, configuration +options, text/image content, function results, environment variants, full Item/SSE +variants, defaults, field omission/nullability and errors need their own cases. +Use strict official-client tests plus raw HTTP assertions; SDKs can accept extra +fields and cannot prove that reported configuration matches the running engine. +Where SDK types or public documentation do not establish behavior, record the +uncertainty and obtain upstream evidence before marking it conformant. Temporary +unsupported errors are implementation gaps, never evidence of full compatibility. + +## Accepted milestone and evidence + +The three-harness Docker V1 milestone was accepted on 2026-09-20 after +[PR #703](https://github.com/MiniMax-AI-Dev/parsar/pull/703). A fresh source-free Core +package and main-built daemon passed fixed Python SDK 3.13.0/raw HTTP/real Kimi K3 +regressions for Codex, Claude Code and MiniMax Code. The profiles use independent +execution databases and no Parsar services or product database. + +These final regressions supplement, rather than repeat, every earlier check. +Codex's full 26-check deployment evidence, Claude's hosted/Artifact/crash/security +evidence, and MiniMax's real MiniMax Artifact and Core/Runtime SIGKILL evidence +retain their exact tested scope. The full gate, focused race checks and fresh +Astra high review passed for the candidate; no additional live run is claimed by +this documentation update. Evidence on `zju_a100_2`: + +- `~/.parsar/remediation/20260920/three-harness-mvp/REPORT.md` and + `acceptance-results.json`: final baseline, runs, reused evidence and cleanup. +- `~/.parsar/remediation/20260919/docker-mvp/` and `claude-v1/`: + preceding deployment and safety acceptance. +- [MiniMax workspace qualification](mcode-workspace-v1.md) and + `~/.parsar/remediation/20260919/mcode-workspace/`: native isolation and recovery. + +Qualification is Linux amd64 Docker V1, not arbitrary host isolation, production +HA, E2B, or Anthropic-model acceptance for Claude Code. No exactly-once guarantee +is made for future model choices: the recorded Kimi continuation limitation is a +new model-issued command after a recovery prompt, not automatic API replay. + +### E2B V1 qualification + +PR #705 (`9cd1c46c7fab6eeef8cb35ce71f1ea0ca2cf8bc1`) separately qualified +Codex, Claude Code and MiniMax Code with actual E2B and real Kimi/MiniMax APIs. +Fixed SDK/raw HTTP acceptance covered independent Core/database deployment, +Files/Artifacts, auth/tenant and native credential/history isolation, cancellation, +Core/Runtime crashes, exact-history continuation without replay and native network +restrictions. All three runs completed cleanup without fallback. The five Provider +operations passed real lifecycle/race acceptance; `make check` and fresh independent +Astra high review passed. The merged tree matches the accepted candidate. + +Evidence: `~/.parsar/remediation/20260920/e2b-runtime-v1/` on `zju_a100_2`, including +`acceptance-results.json`, `provider-real-final.log`, `make-check.log`, +`blind-review.md` and exact image/template build pins. This uses the existing +colocated Runtime contract, with no public resource or protocol expansion. +The qualified Linux amd64 templates require a reachable HTTPS/WSS Core and a +minimum two-hour renewable E2B lease. Expiry destroys volatile workspace/history; +unknown effects cannot authorize recreation or replay. Pools, migration and +user-managed enrollment remain outside this qualification. + +### Remaining protocol work + +| Area | Missing or unverified scope | +| --- | --- | +| Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | +| Environment Templates | Skills references, Plugins, capability directories, restricted network, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline Skills and Session references are supported | +| Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | +| Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions/MCP remain unsupported | +| Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | +| Existing resources | Full Item/SSE/Usage variants, omitted/null/default/error semantics, pagination and overlapping lifecycle behavior beyond recorded cases | + +An implementation gap and an unknown upstream behavior require different follow-up +work. Retain both explicitly; a restrictive local policy or successful SDK parse +cannot establish upstream equivalence. The Feishu board owns live task selection, +including further deployment qualification; this inventory describes merged behavior. + +## Public semantics + +- Credentials use `POST /vaults/{vault_id}/credentials` and + `GET /vaults/{vault_id}/credentials/{credential_id}`. The initial profile accepts + only `static_bearer` with required string token and HTTPS destination, plus a + required name trimmed to 1–256 UTF-8 bytes. Tokens remain opaque, including empty + strings; exact hosted token validation is unverified. The local URL profile + excludes userinfo/fragments and preserves queries without normalization or network + contact. Public metadata contains identity, owning Vault, name, timestamps and + auth type/destination; it never returns tokens or ciphertext and can be read + without the encryption key. Missing encryption configuration locally rejects + creation/replacement with 503. Attached Sessions can use static credentials for + exact-URL HTTPS MCP; OAuth, storage-key rotation, archive behavior and key scopes remain gaps. See the [credential storage guide](../../services/agents-api/credentials.md) + for encryption and operational limits; this does not establish complete Credential + or hosted error/retry compatibility. +- `GET /vaults/{vault_id}/credentials` lists only safe metadata, with parent and + cursor ownership checked within the authenticated project and requested Vault. + It uses the same paging/filter grammar as Vault listing below. Credential status + is stored separately from Vault status, defaults active, and never appears in + the public response. Both active and archived Credentials are included by default; + synthetic archived fixtures establish read/filter behavior, not archive lifecycle. + No token/ciphertext column, decryption, execution or encryption key is needed. + An inaccessible parent is not returned as an authorized empty collection. Existing + create/retrieve/token replacement and dispatch rules are unchanged; exact hosted + errors and pagination under concurrent mutation remain unverified. +- `POST /vaults/{vault_id}/credentials/{credential_id}` replaces a static token using + only required `auth.type=static_bearer` and string `auth.token`. It preserves opaque + strings, rejects missing/null/type/extra-field mutations and returns safe metadata. + Ciphertext/update time change atomically within the same tenant/Vault/ID/type/URL; + name, destination, identity, creation time and Session bindings are unchanged. No + old-token decryption or MCP call occurs. Subsequent dispatch reads use the committed + replacement; already-resolved requests may retain the old token. OAuth replacement, + storage-key rotation, hot reload/revocation and exact hosted concurrent-update, + timestamp and retry semantics remain gaps. +- `DELETE /vaults/{vault_id}/credentials/{credential_id}` returns only `id`, + `deleted: true` and `object: vault.credential.deleted`. It removes one owned row + and its ciphertext without an encryption key. Local retrieval/update/repeated + deletion then return 404; lists omit it. Frozen Session choices and history remain + intact, while subsequent secret lookups fail without credential reselection or + anonymous fallback. Already-resolved tokens and running Sessions are not revoked. + Archive relationships, exact hosted post-delete visibility and repeat/error + semantics are unverified; physical storage erasure is not established. +- Vaults use `POST /vaults`, `GET /vaults` and `GET /vaults/{vault_id}` with the same project/tenant + authentication and Beta header as other resources. The response contains only + `id`, `object: vault`, `created_at`, `name` and `metadata`. Omitted name stays null; + explicit null is rejected. Supplied strings are trimmed and must contain 1–256 + UTF-8 bytes. Omitted/null metadata becomes `{}` and values must be strings. + Session-specific metadata pair/character limits do not apply. The existing + 64 KiB encoded metadata and 1 MiB HTTP body bounds are local implementation + limits. Creation does not start execution. Retrieval maps missing, malformed and + foreign IDs to the same local not-found response. Exact hosted error/retry semantics, + restricted-key scopes and archive lifecycle remain unverified or unimplemented; + this is not complete Vault compatibility. Listing accepts `after`, creation order + (default `desc`), a default limit of 20 clamped to 1–100, and scalar or SDK bracket-array + `status` filters. Both `active` and `archived` are included by default. The private + classification is stored, never returned; existing/new Vaults default active. + Synthetic archived fixtures prove read/filter behavior only. No public archive + writer or delete-to-archive mapping is implemented. Equal creation times use ID + order locally; repeated scalars and mixed status encodings are rejected. Exact + hosted query errors and pagination over changing data remain unverified. +- `DELETE /vaults/{vault_id}` returns `id`, `deleted: true` and `object: vault.deleted` + after project-scoped parent removal and atomic cascade of all stored Credentials. + It needs no encryption key or execution connection. Local parent/child reads, + repeated deletion and new references return 404; lists omit the removed resources. + Existing Session snapshots and recorded retries retain their IDs and selections. + Subsequent secret lookup fails without reselection; already-dispatched tokens are + not withdrawn. Archive relationships, exact hosted visibility/concurrent errors, + provider revocation and physical erasure remain separate gaps. +- Reusable Agents use `POST /agents` and `GET /agents/{agent_id}`. Keep their own + identity, timestamps and metadata separate from Session effective configuration. + On creation, omitted/null name and instructions resolve to null, metadata to `{}`, tools to + `[]`, text to ordinary/medium, and multi-agent settings to disabled/null. Enabled + multi-agent settings default to six concurrent subagents. Function defer-loading + defaults to false and programmatic tool calling to true. Saving these values + does not itself admit a native execution. Session references are admitted separately. +- `POST /agents/{agent_id}` updates only supplied fields. Omitted fields remain + unchanged; metadata replaces all pairs and null/empty clears it. Name/instructions + null clears them. Concurrent updates preserve unrelated fields. Existing Session + snapshots and their recorded creation-retry identity remain unchanged; new Sessions + resolve the latest saved configuration. No-field updates leave timestamps unchanged. + Nested fields currently replace whole values and null uses the saved defaults; + hosted nested/null behavior, no-op timestamp policy and exact errors remain + unverified. This operation shares the existing saved-configuration coverage gaps. +- `DELETE /agents/sessions/{session_id}` returns the canonical `id`, + `object=agent.session.deleted` and `deleted=true` after durable public removal. + Session/Turn/Items reads, live streams, metadata updates and new input exclude + the resource. Queued work is cancelled; active work receives the existing + asynchronous cancellation request while internal finalization remains available. + Existing streams close on observing removal without an invented deletion event. + Creation keys remain reserved (local 409); missing/repeated deletion locally + returns 404. Qualified managed Docker/E2B deletion also reclaims its owned Runtime; + broader physical SQL/native history cleanup, immediate native quiescence and + exact hosted error/retry/overlapping-stream semantics remain unverified or + unimplemented. Shared devices, saved Agents and other Sessions are independent. +- `DELETE /agents/{agent_id}` removes the tenant-owned saved configuration and + returns `id`, `object=agent.deleted`, and `deleted=true`. Existing Sessions and + history are retained; recorded creation retries recover their frozen snapshot, + while new references to the source fail. Local missing/repeated deletion returns + 404. Exact hosted errors and overlapping creation/deletion ordering are unverified. +- `GET /agents` lists tenant-owned reusable resources with `after`, `limit` and + `order` (default `desc`). It uses creation-time/ID keysets and the same resource + mapping as retrieval. Positive int64 limits are accepted; pages contain up to + 100 resources, with `has_more` and the final resource ID guiding continuation. + The local default is 20. The list envelope includes `object`, `data`, `has_more`, + `first_id` and `last_id`; empty pages use null IDs. The pinned SDK omits null + limits and empty cursors. Exact upstream default/cap, empty-envelope nullability + and error taxonomy remain unverified; SDK auto-pagination does not prove them. +- Saved Agent model-default reasoning resolution remains missing: an omitted effort + stays unresolved rather than being populated from a guessed model default. An + explicit effort/summary is retained. Omitted/null service tier currently follows + the service's `auto` policy; complete upstream-default/error/retry conformance is + unverified. HTTP MCP with explicit `service` origin and + boolean `required` (default false) supports saved configuration and Codex `none` execution, + plus `self_hosted` execution behind explicit combination capabilities. Remote + static Bearer authentication additionally requires `mcp_http_remote_bearer_auth`; + it keeps the secret in the trusted service native process. Required initialization + additionally needs `mcp_http_required` on the pinned native profile. Native root + thread creation/cold resume must initialize required servers before a native + Turn starts; failure cannot silently replace retained history. Public acceptance + or queued work does not prove native readiness. Hosted creation timing/error + parity and continuing server health remain unverified. + The saved HTTP transport includes `headers:{}`; effective Session transport omits + headers. Omitted/null `allowed_tools` is unrestricted; `[]` denies all tools. + Session `vault_ids` attaches tenant-owned Vaults. Explicit `credential_id` must + belong to an attached Vault and match the exact HTTPS URL; omission/null selects + one matching static credential, zero stays anonymous and ambiguity fails. Private + immutable selections do not populate the public credential field. Scope is + rechecked before dispatch-only decryption; authenticated execution requires the + separate bearer capability and never downgrades on failure. Exact URL/selection + timing, implicit response population and hosted errors remain local or unverified. + Other MCP variants and web-search remain gaps, not changes to the pinned target + or claims of complete resource coverage. + +- Use `/agents/sessions` beneath the configured API base URL, bearer authentication + and `OpenAI-Beta: agents=v1`. Do not introduce a competing `/sessions` surface. +- Session creation takes an environment and inline agent configuration or a saved + agent reference. The saved ID and effective configuration are copied into an + immutable Session snapshot. Omitted fields inherit; supplied objects and arrays + replace the entire field ([configuration guide](https://developers.openai.com/api/docs/guides/agents-api/configuration)). + Tools null clears the list as specified by pinned `session_create_params.py`. + Saved metadata never becomes Session metadata. A model name is not a daemon engine name. + Current admission requires disabled multi-agent, implicit reasoning, tier `auto`, + ordinary text and non-deferred functions. Unsupported saved settings fail before + Session persistence, unless replaced by supported overrides. Other native options + remain implementation gaps, not excluded protocol variants. + Fixed SDK/raw HTTP checks cover inherited/overridden configuration, tenant ownership, + source preservation, independent Session snapshots, retries and service restart. + New saved-reference Sessions record caller intent before source lookup. Matching + creation retries recover their accepted snapshot even after source update/deletion; + changed overrides conflict. Retries also require the original typed creator. + Known creators without recorded request intent retain resolved-hash behavior; + records without creator identity reject retries. Neither identity is backfilled. These local + retry rules are not verified hosted semantics. + In the pinned `session_create_params.py`, `stream` defaults to false and neither + `stream` nor `agent_id` permits null. Metadata omission/null defaults to an empty + map; individual values must be strings, including valid empty strings. Validate + these distinctions before persistence rather than coercing null to Go zero values. +- `POST /agents/sessions/{id}` updates metadata only: omission preserves it, + null or `{}` clears it, and an object replaces all pairs. Apply the same string + and character limits as creation. Preserve execution state, effective configuration + and the original creation retry identity. Fixed SDK/raw HTTP checks cover these + distinctions, tenant isolation, active Session reads and restart persistence. +- `GET /agents/sessions` accepts `after`, `limit` (1..100, default 20), `order` + (default `desc`) and optional `agent_id`. The filter matches the immutable root + Agent ID, including inline IDs and Sessions whose saved source was changed or + deleted. Filter before pagination within the authenticated tenant; no source + lookup is required. Omission lists all Agents. Empty filters, same-tenant cursors + outside the filter and exact hosted errors/defaults remain unverified. +- `AgentSession` includes the effective agent/environment, Unix-second timestamps, + `object: agent.session`, metadata, required actions, status, usage and vault IDs. + A Session remains reusable after its current Turn completes. +- Input, cancellation and function results are submitted through session events. + Turns are queried through `/agents/sessions/{id}/turns`; do not invent turn-create + endpoints. Event submissions support the `Idempotency-Key` header. +- Per the [official Session guide](https://developers.openai.com/api/docs/guides/agents-api/sessions), + input steers an active Turn and starts a new Turn when idle. Streams are live-only; + recover missed work through persisted Session/Turn/Items queries, not assumed SSE + replay. Internal input ordering is not a public event-stream cursor. +- List operations use the upstream `after`, `limit`, `order` and resource-specific + filters. Stream events preserve the upstream discriminators and payload shapes. +- The upstream self-hosted environment includes an exec-server `remote_url`. + A Parsar daemon socket is not automatically compatible with that transport. + Provider adaptation must be explicit and verified before advertising support. +- Environment retrieval returns `object: agent.environment`, its ID/type, durable + resource status and required non-null `files`, `plugins` and `skills` arrays. + Hosted initial files report safe frozen metadata; empty arrays do not + describe native discovery or workspace files created by commands. Unknown + installation configurations are rejected, not reported as empty. Reads use the + owning live Session's project partition and do not require execution setup. + Remaining unsupported installation configuration, full hosted lifecycle and + exact hosted error semantics remain gaps. + +[Environment Templates](environment-templates.md) provide tenant-owned CRUD/list +and immutable Session resolution through the same hosted initialization. They do not +select an E2B image or make unsupported initialization executable. + +## Delivery and verification + +| Capability | Current state | +| --- | --- | +| Independent deployment | Source-free Core package and separate execution PostgreSQL ownership; managed Docker/E2B Runtime co-locates daemon, selected harness and workspace; no Parsar dependency | +| Saved Agents and Sessions | Saved Agent routes, immutable inline/referenced Session configuration, metadata updates, root-Agent filtering and scoped cursor pagination | +| Public execution | Initial/later text, active input and cancellation through Codex, Claude Code or MiniMax Code; Codex/Claude additionally support qualified public functions; see profile limits below | +| Pending function actions | Persisted calls/results/application receipts, `required_actions`, Session `requires_action`, Turn `waiting`, and live state snapshots; other interactions remain incomplete | +| Public recovery and SSE | Persisted Turn/Items queries and partial Usage; live lifecycle/Item/text events, creation streaming and the official one-Turn tool-handler helper | +| Execution ownership | Immutable Session engine/device, durable input receipts and database writer fencing; uncertain claimed work fails on restart, without blind replay | +| Files and Artifacts | Bounded Environment listing and inline/file_id copies into qualified V1 workspaces; source-file lifecycle and immutable output capture/download/deletion; [Files limits](environment-files.md), [source limits](source-files.md) | +| Clients | Fixed Python SDK 3.13.0 and official Go SDK v3.61.0; raw HTTP and real provider acceptance supplement controlled tests | +| Release and product | Registry publication and Parsar cutover remain open; basic Docker/E2B provisioning is operator opt-in; business Team orchestration is deferred | + +### Public engine profiles + +`AGENTS_API_ENGINE` supplies the default for new Sessions. The optional +[Core harness extension](harness-selection.md) explicitly selects an enabled engine; +existing Sessions retain their immutable choice. Model identity is independent. +All three profiles require disabled `multi_agent`, implicit reasoning, service tier +`auto` and ordinary text output. Optional tools/configuration are qualified per +operation and placement; native support is not public admission by itself. + +| Engine | Qualified placements and limits | +| --- | --- | +| `codex` (default) | `none`, the bounded official `self_hosted` path and Docker/E2B `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none`/`self_hosted`, not hosted; supported verbosity follows the native policy below | +| `claude_sdk` | `none` and Docker/E2B `openai_hosted`; medium verbosity, object-root function schemas and text-only function results; anonymous/static-bearer HTTP MCP with either required value on `none`; hosted HTTP MCP remains unsupported | +| `mcode` | `none` text and Docker/E2B `openai_hosted` workspace execution; medium verbosity; public functions/MCP, image input and complete public usage breakdown remain unsupported | + +All three hosted profiles reuse the [Docker](environments.md#basic-public-docker-hosted-profile) +or [E2B](environments.md#basic-public-e2b-hosted-profile) provider lifecycle, +workspace Files/Artifacts, cancellation and recovery queries, with engine-specific +native isolation. Configuration and immutable Runtime images/templates require explicit +operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), +[Claude Code](../../services/agents-api/deploy/claude/README.md), and +[MiniMax Code](../../services/agents-api/deploy/mcode/README.md). The +[E2B guide](../../services/agents-api/deploy/e2b/README.md) packages those qualified +images as pinned templates. +The shared initialization path supports env/setup and system/npm/Python packages; +see the [evidence and limits](environment-templates.md#verification). Remaining +unsupported startup installations, restricted domains and hosted public HTTP MCP +remain outside these accepted profiles. MiniMax's private MCP tool bridge +is internal transport, not public MCP support. + +The [Codex self-hosted profile](environments.md) remains distinct from managed +Docker/E2B and from future user-managed Runtime enrollment. Product `claude_code` +is likewise a separate integration from the API's `claude_sdk` engine key. +Unsupported configurations fail before Session creation; unsupported results fail +before a batch write. Native capability claims cannot replace service profile +qualification, tenant authority or exact binding checks. An existing Session +never silently changes engine/device. See the +[HTTP MCP limits](../../services/agents-api/README.md#http-mcp-execution). + +The Store's internal DTO is not the upstream response model. The API layer must +validate and resolve the upstream schema before persistence, and report only +supported options. For example, upstream metadata is limited to 16 pairs with +64-character keys and 512-character values; a storage byte limit is not a +replacement for that public validation. + +Use the pinned official Python client against the actual service, with response +validation enabled, for supported Session/Turn/Items operations, pagination, streaming, +errors, idempotency and tenant isolation. A client import or permissive parsing +alone is not evidence of compatibility. Unsupported capabilities must be explicit +errors, not successful placeholder resources. Add any provider or engine-specific +extension separately from upstream fields and document it here when implemented. + +`openapi.yaml` is our generated supported surface; it is not the full upstream +specification. The shared Go wire types are in `v1`. Physical Session cleanup, non-text +message input, structured output execution, broader options/tools, remaining Vault lifecycle, +Subagents and environment/provider resources remain incomplete. Reject unsupported +requests explicitly; persisted saved configuration is not execution admission. + +### Native subagent control + +The pinned `types/beta/multi_agent_config.py` defines `enabled=false` as disabling +subagent tools. The dispatcher enforces that effective value with a typed daemon +policy and capability admission; the Codex adapter applies native feature controls +on fresh and resumed Turns. Operator feature preferences cannot re-enable them. +Controlled model-boundary tests check absence of direct/deferred subagent tools +while the official function workflow continues to run. + +Disabled `multi_agent` input is admitted. Enabled multi-agent execution and public +Subagent resources are still unsupported. Do not infer that `Agent.tools` is the +complete native tool registry: environment and subagent tools have separate +configuration. The upstream behavior of internal Goal, Skills and user-input +utilities needs further evidence; their presence alone is not proof of a mismatch. + +A private typed discovery path projects verified native child identities through +the leased execution journal. It freezes service identity, Session ownership, +native parent/creation and first-observation provenance for internal scoped reads. +It does not enable the public resources above or infer lifecycle from idle/unload. +Bounded discovery and delivery rules are documented in +[the contributor guide](../../CONTRIBUTING.md#current-implementation). + +### Turn recovery reads + +`GET /v1/agents/sessions/{session_id}/turns` and retrieval by `turn_id` +return persisted Turn states using the pinned official client contract. Lists +support `after`, `limit` (1..100, default 20), and `order` (default `desc`). +The cursor is a Turn ID in the same tenant and Session. Failed turns expose a +generic `internal_error`, never raw engine diagnostics. `usage` exposes the latest persisted complete token breakdown, including cached input +and reasoning output. Missing measurements remain null; Session usage sums recorded +Turn measurements as best-effort usage, without estimating missing history. Session runtime state derives from the latest Turn. + +### Item recovery reads + +`GET /v1/agents/sessions/{session_id}/items` supports the same list controls, +with a stable Item ID cursor and first-observation ordering. Messages preserve +text, phase and completion snapshots. Commands preserve reported output, exit +code, duration and working directory. MCP calls preserve server/tool identity, +arguments and structured results/errors. Dynamic functions have linked call and +result Items. Native file changes appear as `apply_patch` function calls with +reported changes as arguments; no result is invented when the engine reports none. +Web search exposes its supported action fields. + +Terminal Turns make unfinished Items `incomplete`; a failed tool does not imply +that the Turn failed. Native start/completion snapshots and Codex command-output fragments are +available when the daemon emits them; other +tool-output deltas remain unsupported. Tool output is visible to the Session's +authenticated tenant and may include the command's or tool's own diagnostic text. + +Reads use the durable index without reconstructing native journals. Existing +indexed history is preserved. Migration 15 requires old unindexed archives to be +prepared by release `906069e` before upgrade; see the +[upgrade procedure](../../services/agents-api/README.md#upgrading-archived-item-history). +The retired archive format could not recover unrecorded message boundaries or +outcomes; those limitations remain in already indexed historical Items. +Unsupported native variants, reasoning, subagent Items and Items mutation +are not covered. Public submission supports text messages, cancellation and function results. + +Legacy Done frames alone do not complete assistant Items. Aggregate answer text +is confirmed by successful Turn termination; failed Turns retain observed deltas +instead of treating adapter diagnostics as assistant output. + +Pagination orders by first-observation timestamp, then the Item's immutable +Session position and public ID. New Items retain observation order even when +timestamps match. The index also stores a zero-based output index per Turn for +streaming; inputs do not consume it. Updates and retries do not move Items +or change output indexes. Existing indexed history retains its pre-upgrade +deterministic order rather than guessing an unavailable original source order. + +### No-environment execution + +The dispatcher executes public `environment.type=none` on an authenticated, +bound host advertising `environment_none`. Codex uses `CODEX_EXEC_SERVER_URL=none` +and verifies native environment state before starting/resuming. Claude SDK uses +its restrictive profile with no built-in tools and only declared function callbacks. +A missing capability or unsupported native method fails rather than silently +allocating a local execution environment. Native state still lives on the host; +function callbacks may access their own resources. This is not filesystem isolation. +Private `daemon` snapshots and the self-hosted registry/Noise transport are +distinct from this mode. + +The native reference is Codex `rust-v0.153.4`, commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, especially +`codex-rs/exec-server/src/environment_provider.rs`. The self-hosted registry +requires executor registration, harness authorization and encrypted relay; +a daemon WebSocket URL is not that protocol. +The [Environment assessment](environments.md) records all environment/template/file +operations, ownership, native authentication gaps and the implementation sequence. + +### Public execution admission + +`POST /v1/agents/sessions/{session_id}/events` accepts `agent.session.input.message` +with user `input_text` content, `agent.session.input.cancel` and +`agent.session.input.tool_result`. Successful atomic +admission returns 204, as consumed by the official `events.create` method. A retry +key identifies the entire ordered request; conflict does not partially admit it. +Messages start queued work or steer the active Turn. Individual input messages +remain distinct Items even when their text shares one native prompt. + +Enable the standalone daemon gateway to run the worker; without it, admission +returns 503. The worker selects capable same-tenant engine hosts, binds each Session +once, and runs at most four Turns concurrently. Queued cancellation needs no live +engine. Active cancellation waits for a native receipt; terminal completion can +win that race. Query Turn/Items to recover results after a stream interruption. + +The worker takes a database advisory lease; a second worker cannot start on the +same database. All execution writes use that lease connection and stop after loss +of ownership. Startup marks previously claimed Turns failed without replaying them +and retains queued work. Database fencing does not stop already queued native +commands, recover missing daemon frames or guarantee exactly-once external effects. +Session status reflects the latest persisted Turn; usage reports recorded measurements. + +Native verification uses `PARSAR_NATIVE_DAEMON_BIN`, `PARSAR_NATIVE_PROOF_DIR` under +`~/.parsar/`, and `PARSAR_OFFICIAL_SDK_PYTHON` pointing to the pinned SDK environment. +The Store native integration test runs `tests/official_execution.py` against a real +HTTP handler, PostgreSQL, daemon and Codex with a synthetic model provider. + +Token measurements use the pinned SDK's `TokenUsage` fields. The optional daemon +`usage.tokens` supplies complete per-Turn counters; journal and terminal writes +replace that Turn's snapshot atomically. Repeated snapshots do not increase totals. +Unknown historical breakdowns are not backfilled, and a Session total includes only +recorded measurements. Costs and prices are outside this execution contract. + +### Live events + +`GET /v1/agents/sessions/{session_id}/events` implements the official live-only +stream. Open it before submitting input. Session in-progress/idle/failed and Turn +created/in-progress/completed/failed/cancelled events carry transition snapshots. +Supported Items emit added/done events; assistant text emits content-part and +text-delta/done events. Inputs, including function results, have no output index. Function results emit +`item.added` and remain queryable; `item.done` only carries agent output. Public +function-result output/error retain the saved submission and field presence; +native error-to-text translation does not rewrite those fields. Completed text replaces +accumulated deltas; cancelled unfinished Items retain their partial content and +`incomplete` status. Codex command-output fragments use the pinned +`agent.output.command_execution_output.delta` event with the command Item ID and +stable output index. Draft Item output accumulates fragments; a supplied final +snapshot replaces it and is not emitted as another delta. Native output quotas and +text conversion apply, so the stream is not a byte-complete stdout/stderr capture. +Pinned native 0.153.4 can omit output emitted before its streaming subscription, +including from the eventual aggregate; this bridge cannot recover unobserved bytes. +That native gap remains open. Older peers may provide completion snapshots only. +Reasoning summaries and other +interim tool-output variants remain outside the supported surface. + +Events publish only after their transaction commits. An idle Session keeps its +stream open for later Turns. Reconnection starts at the latest committed position, +including when Last-Event-ID is sent; it does not replay missed work. Connect, +buffer new events, then retrieve saved Session/Turn/Items state to recover. Deduplicate +by Item ID and retain finalized Items when applying buffered updates. + +The internal buffer is limited to 256 events / 64 MiB per Session, with a single +oversized-event exception. A lagging reader receives a customer-safe `error` and +disconnects rather than silently skipping output. Slow socket writes time out +without blocking execution. Unsupported event variants are not implied by this endpoint. + +Internal function execution uses the same native daemon harness, with resolved +non-deferred definitions and Store result admission. It verifies ordered text/image +results, error text, application receipts, matching action/Item call IDs, cancellation +and native Session continuity. Codex supplies the model transport's default image +detail. This proof uses a synthetic model responder and the real daemon/Codex; +the public workflow below exercises the same native bridge through HTTP. Deferred functions, +other tool kinds and the native 64-definition limit remain compatibility gaps. + +Function-action read coverage uses persisted-call fixtures with the real service +handler, PostgreSQL and pinned official client. Call insertion and application +receipts update Turn/Session state atomically; duplicate notifications emit no new +state. Actions remain visible until the execution adapter acknowledges application, +or cancellation/terminal state removes them. This acknowledgement timing and the +exact sequence of repeated `requires_action` notifications are implementation +choices: the pinned source defines their shape but not that precise ordering. +Session state events contain `event_id`, `type` and `session`; Turn events retain +`session_id` and `turn_id`. There is no invented Turn `waiting` event. + +Public function-result admission is verified with the pinned Python client and +raw HTTP against a dedicated PostgreSQL fixture: required fields, nullable output +and error, ordered text/image output, variant rejection, atomic batches, scoped +access and retries after terminal state. This admission verification complements the native public workflow below. +The generated Swagger 2.0 document leaves the output union unconstrained because +it cannot express string-or-content-array unions; the pinned upstream types and +server validation define the supported alternatives. + +### Public function configuration + +Inline `agent.tools` accepts non-deferred `function` definitions with the upstream +required name, description and JSON Schema parameter object. Missing +`defer_loading` resolves to `false`; null and other types are rejected. Omitted, +null and empty tool lists resolve to an empty list. The resolved tools are part of +the immutable Session configuration and creation retry identity. Saved-Agent +inheritance uses the same resolved tools. Deferred discovery, other tool kinds, +the native 64-definition cap and unique nonblank names of at most 512 bytes remain +compatibility gaps. Claude SDK additionally requires object-root schemas and +text-only results. Codex internal Goal/Skills/user-input/discovery semantics need +upstream evidence; their presence alone does not prove a tool-set mismatch. + +The worker selects a same-tenant host advertising `function_tools` for configured +Sessions. Work remains queued when no compatible host is available, including +when a previously bound host no longer advertises that capability. It does not +silently discard the definitions or move an existing native Session. + +`TestNativePublicFunctionExecution` runs `tests/official_functions.py` using the +pinned official SDK against the actual HTTP handler, worker, PostgreSQL, daemon +and Codex. A synthetic model requests a configured function; the client reads +`required_actions`, submits ordered text/image results through public events, +retries the same result, receives completion, and reuses the Session. A subsequent +Turn verifies error output, and a third verifies cancellation while waiting. +The test checks native result receipts, retained function Items and no duplicate +native continuation. This proves the implemented workflow, not compatibility +with every tool variant or the upstream service's exact event timing. + +Accepted results currently enter public Items through native execution observations. +If cancellation prevents native application (for example, a result followed by +cancel in one admitted batch), the submission remains saved internally but has no +public result Item or `item.added`. Admission-time result indexing and unapplied +result recovery remain a separate compatibility gap; retries do not repair it. + +`TestNativePublicFunctionStreamHelper` runs `tests/official_function_stream.py` +with the same native fixture and pinned SDK. `sessions.stream(tool_handlers=...)` +submits a mapping returned by a handler and a generic failure when the handler +raises. It verifies one invocation per call, retained output/error field presence, +native application, and termination after the matching Turn completes and Session +returns idle. These are controlled tests with synthetic model responses. Live +execution acceptance additionally requires a real model API; provider connectivity +alone does not prove the Agents API/daemon/harness workflow. + +### Default verbosity on native models + +The pinned `AgentTextParam` defines `medium` as the default text amount. Omitted, +null and explicit `medium` keep the same effective Session configuration and retry +identity. Supported native models receive the explicit requested level. For +unsupported or unknown models, the Codex adapter removes a `medium` override and +uses native defaults while preserving the requested model and catalog snapshot. +It still rejects unsupported `low`/`high` and unreadable catalogs. + +This follows [Codex 0.153.4 request selection](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/core/src/client.rs#L951) +and its [unknown-model fallback](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/models-manager/src/model_info.rs#L134). +Controlled native verification checks explicit levels on supported models, absent +verbosity on an unknown model, initial/resumed Turns, and default retry equivalence. +This does not imply support for non-default verbosity on every model. + +Live MiniMax-M3 verification used the pinned SDK, actual service/worker/PostgreSQL, +daemon and Codex with MiniMax's real Responses API. Two Turns verified a successful +function result, handler failure, retained result fields, stream termination and +native history continuity by recalling a random value returned only by the first +tool invocation. Omitted, null and explicit medium reused the same creation +identity. The tool data was synthetic; model responses were live. This does not +establish non-default verbosity, tool-set enforcement or full protocol conformance. + +### Initial text at Session creation + +Session creation accepts the pinned string and user-message-array input +forms. It shares text validation and admission with the events endpoint. The +Session and its initial work commit atomically; an identical +creation retry never re-admits the input, including after later or terminal Turns. +With `none`, this includes the first Turn and input Items. With `self_hosted`, it +includes the initial reservation and connection action; preparation and Turn +admission belong to the existing Worker. Creation returns while the executor is +offline, and an initial deadline failure leaves a failed Session without a Turn. +Omitted/null input creates an idle Session. Execution must be enabled and the +configured engine must support admission before any initial work is persisted. + +Fixed SDK/raw HTTP and PostgreSQL tests cover the accepted forms, saved and inline +configuration, ordering, tenant isolation, retries, rollback and persistence. +Non-text input remains a gap. Empty arrays and blank text +currently fail the shared message validator; exact upstream handling of these +cases, local size limits and error details remains unverified. Swagger 2 cannot +express the string/array union, so input is unconstrained with a type description. + +### Session creation streaming + +`POST /v1/agents/sessions` also accepts `stream=true` for the supported creation +inputs. Fresh creation sends `agent.session.created` with the pre-input Session, +then its committed activity/Turn/Item/output events. Self-hosted initial creation +first requests the Environment connection, before native readiness and a Turn. +The cursor comes +from the atomic creation upsert, so rapid initial execution cannot move the start +past its own events. The ordinary bounded-buffer/gap policy still applies. + +The local `Idempotency-Key` creation extension shares identity across response +modes. Retrying creation streams only future changes and never resubmits input or +replays old events. Recover a lost Session ID by repeating the same request/key +with `stream=false`, then use Session/Turn/Items reads. Disconnect only stops the +HTTP observer; committed reservations and admitted execution continue. Idle streams remain open for later +Turns. Pinned SDK3.13.0 proves the creation stream and created-event schema; exact +upstream initial snapshot/order, POST stream lifetime and retry behavior have not +been compared with the hosted service. These choices are not full conformance. + +`official_session_creation_stream.py` covers the pinned client and raw HTTP on +real PostgreSQL: idle/initial text and saved Agents, first snapshots and ordered +Items, retries across response modes, later Turns, disconnect recovery, isolation +and errors before stream headers. Store tests cover concurrent upsert ownership, +pre-admission cursors and observers draining after execution has completed. + +## Acceptance evidence and remaining scope + +These accepted changes have distinct evidence levels. The associated PR records +include validation and limitations; later acceptance does not upgrade an earlier +controlled fixture into a real-provider test. + +| Area | Evidence | +| --- | --- | +| Codex function stream/default text | [#544](https://github.com/MiniMax-AI-Dev/parsar/pull/544), [#545](https://github.com/MiniMax-AI-Dev/parsar/pull/545): fixed SDK/raw HTTP, actual PostgreSQL/daemon/native harness; #545 adds real MiniMax success/error and native history continuity | +| Independent build/container | [#552](https://github.com/MiniMax-AI-Dev/parsar/pull/552), [#563](https://github.com/MiniMax-AI-Dev/parsar/pull/563): isolated binaries/container, official Python/Go clients and real MiniMax execution across API restart | +| Session creation and source identity | [#564](https://github.com/MiniMax-AI-Dev/parsar/pull/564), [#567](https://github.com/MiniMax-AI-Dev/parsar/pull/567), [#572](https://github.com/MiniMax-AI-Dev/parsar/pull/572): atomic initial text, creation streaming and mutation-independent saved-reference retries | +| Saved resource lifecycle and Session filtering | [#573](https://github.com/MiniMax-AI-Dev/parsar/pull/573), [#574](https://github.com/MiniMax-AI-Dev/parsar/pull/574), [#581](https://github.com/MiniMax-AI-Dev/parsar/pull/581): official client/raw HTTP, PostgreSQL, tenant isolation and source mutation/deletion; #581 also filters completed real MiniMax Sessions | +| Claude SDK public execution | [#580](https://github.com/MiniMax-AI-Dev/parsar/pull/580): built API/registered daemon/packaged SDK with real MiniMax text, function success/error, active input, SSE/Items, pending-call cancellation and daemon cold continuation with retained native identity/history | + +Principal workflows above are accepted within their profiles. Missing resources, +broader configuration/content, complete Usage provenance, unapplied result +visibility, exact hosted errors/event timing and crash-window reconciliation remain +open. Claude SDK raw usage is retained internally; public usage stays null without +a complete token breakdown. Neither successful cold continuation nor database +writer fencing proves recovery of interrupted native side effects. Full protocol +compatibility, other harnesses/platforms and Parsar cutover are not established. + +### Caller principal foundation + +Caller keys now resolve an explicitly configured organization/project and typed +user/service-account identity. An immutable project-to-tenant mapping is verified +against PostgreSQL before startup. Optional official organization/project headers +must match the key's authorized scope; ambiguous or conflicting headers use the +existing `401 invalid_api_key` response. This error policy is an implementation +choice, not verified hosted error parity. Project resource access remains shared +within the authorized project. New Sessions persist immutable creator kind/ID from +the authenticated principal; ordinary and streaming creation retries require the +same typed subject, including when recovering before saved-Agent lookup. Rotated +keys for that subject share retry identity. Unknown historical creators cannot be +claimed by retry. This local 409 policy is not verified hosted retry parity. +Creator fields remain internal and do not extend the public Session schema. +Executor keys now require the target Session's verified project and typed creator, +with optional exact-Environment restriction. Key issuance can precede Session +creation; rotation/revocation and current authorization reuse the durable ledger +and existing native registry. Historical keys remain revoked and unclaimed. This +executor-specific prerequisite does not open public Environment admission or +establish complete ownership, hosted key lifecycle or error compatibility. See the +[standalone configuration](../../services/agents-api/README.md#standalone-http-service). + +Core documents its optional [harness selection extension](harness-selection.md) separately from the pinned upstream contract. + +Model endpoints and credentials may be supplied at Session creation through the +[write-only execution extension](model-execution.md). Provider catalogs and their +business permissions remain client/product responsibilities. diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md new file mode 100644 index 000000000..4a237734f --- /dev/null +++ b/contracts/agents-api/environment-files.md @@ -0,0 +1,135 @@ +# Environment files + +The complete protocol target remains the SDK pinned in [upstream.json](upstream.json). +The public GET and POST `/agents/environments/{id}/files` have partial coverage. +Inline and source-file (`file_id`) creation target a qualified V1 local Environment, +including the managed Docker/E2B profiles for Codex, Claude Code and MiniMax Code. +[Source Files](source-files.md) have their own project-owned lifecycle. Managed +hosted provisioning and shared Artifacts are accepted within the +[recorded Docker MVP scope](README.md#accepted-milestone-and-evidence) and separate +[E2B qualification](README.md#e2b-v1-qualification); complete +Files/Environment semantics and other providers are not implied. + +## Pinned contract + +The [Files resource](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/files.py) +and [list parameters](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/agents/environments/file_list_params.py) +specify optional absolute-directory filtering, limit 1–100, case-sensitive +path-component ordering (default descending), and an opaque `page` token with +unchanged path/order/limit across pages. Limit and path are nullable SDK inputs; +order and page are not nullable when supplied. + +Each [EnvironmentFile](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/agents/environments/environment_file.py) +has `environment_id`, `object: agent.environment.file`, absolute `path`, and integer +`size_bytes`. The pinned [TokenPage](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/pagination.py) +requires `data`; `has_more` and `next` are optional and nullable. This implementation +returns `data` and `next` (null on the final page), with no additional page fields. + +## Current scope and local policies + +- Read direct regular files in one authorized self-hosted or qualified local workspace + directory. Local public paths are rooted at `/workspace`, independently of the + physical path frozen into its dedicated Runtime. + Omitted path selects the workspace root. Do not recurse or follow symlinks; + directory, symlink and other non-regular entries are omitted. +- Omitted limit uses 20. Query keys may occur once; empty values, unknown keys and + malformed query encoding are rejected. The pinned SDK's + [query serializer](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/_qs.py) + omits scalar `None` values, so nullable limit/path follow omission behavior. + Literal `null` and empty scalar query values are not accepted. +- Require an absolute UTF-8 POSIX directory of at most 4096 bytes within the + workspace. Reject `..` components before normalization, backslash, NUL, CR and LF. + Normalize redundant separators, `.` and trailing separators before binding a + cursor or passing the workspace-relative directory to execution. +- Authorize through the existing tenant-scoped Environment lookup before inspecting + directory paths, cursors or runtime availability. Existing project-shared reads + remain permitted. The reader receives that exact Environment and rechecks its + execution ownership; the API never selects a daemon or a local filesystem path. +- Accept only a complete validated native directory, bounded by the shared + 1024-entry limit. Truncation, unknown kinds, missing sizes, duplicate or unsafe + names, and uncertain output return safe 503 without `data` or `next`. The bound + applies before filtering non-regular entries, sorting or public pagination. +- Cursors are bounded base64url tokens tied to tenant, Environment, canonical + directory, effective order/limit, and the full sorted regular-file path/size + result. Every page rereads the directory. Changed files or parameters invalidate + continuation with safe 400. No cache, durable cursor registry or snapshot is + promised; unchanged path/size metadata does not prove unchanged contents. +- Reader errors reuse the existing safe error mapping: not found 404, invalid input + 400 and unavailable execution 503. Native error text never enters the response. + Listing does not create a Turn or admit model input. Idle reads use temporary + read-only preparation; actual transport disconnect/reconnect events remain visible. + +Default limit, omitted-path scope, recursion, non-regular entries, exact invalid or +missing-path errors and cursor invalidation behavior are +local policies or remaining gaps, not verified hosted semantics. The pinned source +does not establish them. Do not interpret the bounded direct-file implementation +as complete Files.list compatibility. + +## Inline and source-file creation + +The pinned create union requires `type: inline`, standard Base64 `data` and an +absolute destination `path` under `/workspace`, or `type: file_id`, `file_id` and +that path. Source IDs resolve only within the authenticated execution project; +filenames, URLs and filesystem paths cannot substitute for an ID. Both members +use the same destination writer. Required null/omitted fields, extra fields, +query parameters and invalid Base64 are rejected. Empty bytes are valid. Inline +paths must be canonical and cannot name the workspace root; the parent must exist. +The current destination limit is 50 MiB for either source, with bounded JSON and 64 KiB daemon +frames. These are local limits and policies, not verified upstream restrictions. + +Creation uses the same tenant Environment lookup as listing. The Worker checks the +stored local profile, immutable exact device/Environment binding and live capability. +It never starts a model for upload or supplies a filesystem root from the request. +The deployment must qualify the protected sibling workspace/staging layout and +its selected native adapter. The [engine profile guides](README.md#public-engine-profiles) +describe accepted Docker configurations; the [E2B operator guide](../../services/agents-api/deploy/e2b/README.md) +adds the qualified E2B deployment. A capability or path declaration alone +does not establish isolation or public hosted admission. + +Before sending any bytes, persist the mutation identity and request digest under +the Session lock. Pending input/execution and another unresolved upload exclude a +new mutation. The Runtime receives the complete body, verifies its digest and uses +the existing installer to replace the destination with a fresh mode-0600 inode. +Existing hard-link aliases retain their original contents. Uploads do not create +parent directories or preserve destination permissions; exact upstream overwrite +and metadata behavior remain unverified. Later independent tool writes can change +the installed file; the response does not promise a snapshot. + +Only an exact committed/rejected receipt settles durable ownership. Caller detach, +connection loss, timeout or missing output cannot be treated as rejection. Unknown +writes remain pending across Core restart and block successor mutation without +replay; read-only recovery remains available. Automatic uncertain-write recovery +and placement replacement are outside this batch. Controlled failures preserve +the destination only when the installer proves rejection, and only against this +operation, not independent workspace writers. Temporary-file cleanup is best effort. + +Successful creation returns only the four EnvironmentFile fields. Reuse the common +safe error mapper; current 400/409/413/503 policies and error timing are not evidence +of exact upstream parity. This referenced-source milestone cannot close the complete Files +resource or Environment lifecycle requirements. + +Source resolution reads an immutable snapshot before destination admission. A +source deleted before that lookup is unavailable; an already-resolved copy may +finish. Deleting a source never deletes a copied workspace file. A larger general +Files upload can be downloaded but is rejected before Environment dispatch when +it exceeds the destination limit. Exact hosted delete/copy timing is unverified. + +## Acceptance boundary + +API tests cover raw response fields, complete-result validation, filters, sorting, +pagination, local cursor policies, authorization order and safe errors. The separate +official-client fixture exercises flat directories generated through a real model, +raw HTTP and pinned SDK pagination, sizes, and two-tenant isolation. It does not +establish unspecified recursive, symlink or snapshot behavior. Runtime availability +and each engine's isolated placement require their own native and service checks. + +The opt-in `services/agents-api/tests/official_environment_files_create.py` reuses +the pinned SDK and raw HTTP listing assertions. Its stdin supplies the base URL, +preconfigured Environment ID, model-input text, and two private caller token +sources (`token_env` or `token_file`). The invoking native fixture supplies an +existing `uploads` directory and a staging symlink rejection probe, verifies exact +installed hashes, and has a real model consume source-copied text after source +deletion. Its source fixture also streams a 512 MiB upload/download and verifies +that it cannot bypass the smaller destination bound. This distinction +keeps private setup separate from public hosted creation acceptance. Mechanism tests +exercise detached/unknown outcomes and durable gates independently of model output. diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md new file mode 100644 index 000000000..95c159cbe --- /dev/null +++ b/contracts/agents-api/environment-templates.md @@ -0,0 +1,446 @@ +# Environment Templates and initialization + +Core owns reusable configuration through the five pinned +[Template operations](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py). +Templates do not contain a running workspace and do not select a provider image. +An E2B `templateID:build_UUID` remains private operator packaging configuration. +Each referencing Session obtains its own Environment through the same initialization +and five-operation SandboxProvider path as inline configuration. + +## Supported batch + +- Create, retrieve, update, delete and list under `/v1/agents/environments/templates`. + Every operation requires project authentication and `OpenAI-Beta: agents=v1`. + CRUD/list works without an execution deployment. +- Optional nullable name, preserved verbatim, with a local 1–256 Unicode character + bound. Network supports `enabled` and `disabled`; omitted/null create network + defaults to the pinned enabled policy. Update omission preserves; supplied name + or network replaces, with null clearing name or resetting network. +- Empty/null installation fields retain empty defaults. Responses contain safe + metadata and never `env`, `setup_commands` or inline file data. Initial files are + supported as described below, together with inline Skills, env, ordered setup and system/npm/Python packages; remaining populated installations reject explicitly. +- Listing uses `after`, `limit` (1–100, default 20), and `order` (default `desc`). + Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs + and cursors return the same not-found result. No compute is allocated by CRUD. +- Session `environment_template_id` resolves under the caller's tenant. Omitted + network inherits; enabled can narrow to disabled, never the reverse. Effective + configuration is frozen without passing the template ID to execution. +- Updating/deleting a template does not change existing Sessions. Creation retries + recover recorded caller intent before template lookup, including after deletion; + changed intent conflicts. This is the existing local retry policy, not a claim of + complete upstream idempotency semantics. + +```python +from openai import OpenAI + +client = OpenAI(base_url="https://your-core.example/v1", api_key="your-project-key") +template = client.beta.agents.environments.templates.create( + name="Python workspace", network={"access": "disabled"}, + env={"APP_MODE": "analysis"}, packages={"python": ["packaging==26.0"]}, + setup_commands=[{"command": "mkdir -p /workspace/outputs"}] +) +session = client.beta.agents.sessions.create( + agent={"model": "your-configured-model"}, + environment={"type": "openai_hosted", "environment_template_id": template.id}, + input="Create /workspace/outputs/report.txt containing the result of 6 * 7.", +) +# Inspect Session/Turn/Items and retrieve published Artifacts after completion. +# Delete the Session to reclaim its Environment; template deletion is independent. +``` + +## Initial files + +Both inline hosted configuration and reusable templates accept `files` entries with +an absolute destination inside `/workspace`: `inline` with standard-base64 `data`, or +`file_id` referencing a project-owned Files API upload. The guide's limits are 50 +initial files, 5 MiB per inline file, 10 MiB total inline content, and 50 MiB per +referenced file. Session/Template JSON requests allow 16 MiB for the base64 envelope. +Paths must be canonical, distinct and stay within the workspace; symlinks are not +followed. A failed install never starts native execution. + +Configure `AGENTS_API_CREDENTIAL_KEY_FILE` with the existing execution-service +base64 32-byte encryption key. Template writes and Session resolution need it; +ordinary metadata reads do not. Template inline metadata contains type/path/size, +while references contain type/path/file_id. Sessions receive fresh file IDs and +sizes for both variants. Initialization keeps file data out of ordinary configuration, +resource responses, lifecycle events and command arguments. Templates keep references; each Session authorizes and +freezes its own encrypted source bytes. Later source deletion cannot change them. + +Template `files` omission preserves on update; null/[] clears. Referenced Sessions +inherit files. Supplying `files` together with `environment_template_id`, including +null/[], explicitly rejects while replacement/merge/null semantics remain unconfirmed. +Use a complete standalone inline configuration when a different file set is needed. + +Core initializes both paths with the same trusted file installer through Provider +RunCommand. Daemon authentication remains available, but native preparation and live +Files wait for all writes. Each file gets a two-minute transfer budget; the batch has +a thirty-minute local budget and shares maintenance scans with other allocations. +These are local operational limits, not verified upstream timing. Initial input +retains its existing five-minute admission deadline; large installations can use an +idle Session and wait for connected status before submitting input. + +Uncertain writes and Core restart during initialization fail the new Environment and +reclaim it; they do not replay partial installation. After completion, reconnect and +native-history recovery preserve user modifications instead of reinstalling files. +Docker/E2B and all three harnesses use this same lifecycle. The Provider API remains +five operations; public Templates are never E2B image templates. + +## Inline Skills + +Both templates and standalone hosted configuration accept inline Skill ZIPs: + +```python +import base64 +from pathlib import Path + +skill = { + "type": "inline", "name": "report", "description": "Create the report.", + "source": {"type": "base64", "media_type": "application/zip", + "data": base64.b64encode(Path("report.zip").read_bytes()).decode()}, +} +template = client.beta.agents.environments.templates.create(skills=[skill]) +``` + +Each archive contains one top-level folder with `SKILL.md` and optional supporting +files. The manifest name/description must match the request. Portable descriptive +frontmatter supports `name`, `description`, `license`, `compatibility` and string +`metadata`; native hooks, permission controls and subagent directives reject. +Local limits are 50 Skills, 5 MiB compressed and 20 MiB expanded per archive, +10 MiB compressed and 50 MiB expanded in total, and 1,000 entries per archive. +Regular files only: path traversal, links, duplicate destinations, special files +and invalid manifests reject. Content is inert during installation; executable +files retain their executable bit. These operational limits are not claims about +upstream limits. + +Responses contain only type/name/description. Archive content stays in encrypted, +resource-bound template and Session snapshots. Updates replace supplied `skills`; +omission preserves and null/[] clears. Existing Sessions retain their frozen +content after template update/deletion. A template reference with an explicit +Skills override rejects pending confirmation of upstream merge semantics. + +The shared initializer installs Skills under +`/environment/initialization/capabilities/skills/` before setup and native execution. +Setup and native tools can read that tree but cannot write it; completed recovery +never reinstalls it. The execution contract carries installed metadata only. +Codex registers native extra roots, Claude creates its own explicit Skill plugin +envelope, and MiniMax points its native user-global catalog at the shared root. +MiniMax retains disabled unrestricted built-in tools and uses its existing +isolated workspace tool worker. No Provider or model/tool loop is added. + +Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency +configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter +preparation. Other files are not interpreted as a public plugin installation. +Public `skill_reference`, `/v1/skills` version resolution, generic Plugins and +capability-directory imports remain separate gaps. Native built-in Skill visibility +is not evidence of exact public tool-set parity. Qualification probes alone do not +establish complete public support; record real service acceptance separately. + +## Packaged Runtime initialization contract + +Template handlers and stores resolve public configuration without choosing a +harness, native path or compute backend. The common initialization lifecycle uses +the following existing Linux Runtime packaging requirements through Provider +`RunCommand`; these are private deployment requirements, not public Template fields. + +- `/workspace` is the public workspace. `/environment/workspace` names the same + storage for trusted initialization; `/environment/staging` is private staging. +- `/usr/bin/python3 -I -S` runs the trusted, fd-anchored initial-file installer. + It invokes the existing `/usr/local/bin/agents-api-codex-write` atomic writer. + That executable is a shared filesystem helper packaged for every harness; its + historical name does not select Codex or invoke native Codex tools. +- Confidential content travels on bounded stdin. Successful initialization needs + the writer's versioned completion receipt and confirmed process exit. Unknown + effects use the existing allocation cleanup path rather than replay. +- Provider implementations preserve argv, stdin, exit status and allocation + ownership. They do not interpret public templates. Runtime adapters own native + configuration; initialization must not consume a harness's private history, + model credentials or native tool protocol. + +New hosted harnesses reuse these helpers and paths; new Providers deploy the same +Runtime contract. Neither addition should change template validation, storage or +resolution. Extend this contract only for an accepted initialization requirement. +The trusted `/usr/local/bin/agents-api-runtime-initialize` receives a bounded +version-1 JSON operation on stdin. It configures read-only tool env under +`/environment/initialization`, installs packages under `/environment/packages`, +and runs ordered commands through distro bubblewrap. The fixed mount/process map +excludes daemon credentials, native history and staging. User values are applied +inside isolation, never to the launcher. Receipt and process exit must both confirm +completion; child output is discarded because it can contain secrets. + +Runtime receives a `tool_environment` execution flag, without template identity or +provider information. Adapters validate the common files and apply them in their +native tool sandbox: Claude uses its native Bash hook, Codex its managed Bash hook, +and MiniMax its isolated native-tool worker. Native transports remain unchanged. +Files reads do not require initialized tool configuration. Docker setup requires +the existing nested-sandbox deployment profile for every harness; E2B supplies +the same Runtime layout and kernel isolation. + +Codex 0.153.4 can execute an original command when a native hook process fails. +The adapter verifies the required trusted managed hook before preparation and +stops the Turn on an observed failed hook. Earlier command effects may already +exist; this is not an atomic hook-failure prevention guarantee. + +## System packages + +`packages.system` accepts package names for the Runtime's Debian apt repositories, +in both templates and inline hosted configuration. Real apt/dpkg installs packages +and runs package scripts before npm/Python dependencies and setup commands. Template +updates replace the package object; omission preserves it and null clears it. +Referencing Sessions freeze the existing template configuration. + +Each Runtime image supplies a seed built before daemon, harness and credential +installation. The common initializer extracts it into +`/environment/packages/system` under the unprivileged Runtime identity. Matching +package databases and base tools are included; private Runtime files and native +history are absent. Installation uses its own process/filesystem view. Package +output is not exposed in public diagnostics. A failed or uncertain installation +fails the Environment through the existing lifecycle and is not replayed. + +Setup and native shell tools enter this installed root read-only, with the same +workspace and adapter-owned temporary storage. Trusted launchers stay outside the +package-controlled root. Codex uses its managed hook, Claude its full-shell prefix, +and MiniMax Code its existing tool worker; native execution and cancellation retain +their existing owners. Core carries only the required initialized-tool condition. +Files operations retain their existing authorization and initialization boundary. + +This is a single-UID tool environment, not a full operating-system service manager. +Packages requiring additional Unix identities, privileged operations or background +system services may fail explicitly. There is no apt mirror, package cache, arbitrary +root installation or package retry mechanism. Existing operation and initialization +time budgets apply. New harnesses implement the same Runtime contract rather than +adding template-specific business logic. + +## Explicit gaps and evidence boundaries + +Nonempty `capability_directories` and `plugins`, and Skills API references, +plus restricted-domain network policy, remain unsupported +for both templates and inline initialization. The separate live Files API remains +available after initialization. Unsupported requests reject without echoing payloads. + +The [hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/openai-hosted) +clarifies that configured env values are readable by Agent code, files/packages +precede setup commands, nonzero setup prevents start, and runtime-reserved env names +must reject. The shared initialization batch implements those fields with encrypted snapshots +and the existing readiness gate. Public reads show packages but omit env/commands. +Template updates replace each supplied field; omission preserves it and null clears +it. Referenced Sessions inherit the snapshot; explicit env/packages/setup overrides +with a template ID reject while override semantics remain unconfirmed. + +Files and inline Skills are installed first, followed by system, npm/Python packages and ordered commands; +the default cwd is `/workspace`. One command or package operation has the existing +two-minute local budget, within the thirty-minute initialization budget. No command +is retried after unknown effects. Completed setup never runs on reconnect. +Package dependencies are available to native tools across working directories. +System packages use the isolated tool root described above. + +The [update Reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates/methods/update) +defines runtime network as post-setup and packages as preceding that policy. +Initialization therefore uses its isolated provisioning network; native tools +apply the requested enabled/disabled policy afterward. Allowing setup internet is +an implementation inference from that phase boundary, not an explicit upstream +guarantee. Env values are intentionally readable by Agent code; they must not +appear automatically in public metadata or initialization diagnostics. + +The [current Template reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates) +mentions different GA/beta defaults; this service retains `agents=v1` and the +[fixed baseline](upstream.json), whose omitted network is enabled. Exact upstream +errors, no-op timestamps, concurrent pagination and referenced Session null-network +override semantics remain unverified. The last case explicitly rejects in this +batch rather than guessing inheritance. This batch is not full protocol compatibility. + +## Verification + +`official_environment_templates.py` checks all five fixed-SDK operations plus raw +HTTP, exact safe response shapes, field replacement/defaults, pagination, tenant +isolation and rejected confidential canaries. `official_e2b_v1.py` opts in with +private `verify_environment_templates: true`; it creates its actual native/model +Sessions from public templates, verifies frozen snapshots and creation retries +after update/delete, then reuses the existing execution, Files/Artifacts, isolation, +cancellation and crash/history-recovery assertions. Its disabled-network Session +inherits that policy from another template. Runtime and provider packaging were +unchanged in the original metadata-only batch. Database integration tests cover persistence and concurrent field updates; +API tests cover parsing and caller-intent distinctions. + +`official_environment_initial_files.py` and the `verify_initial_files: true` option +together with `verify_environment_templates: true` in the real E2B runner add +both-source/template/inline metadata, source-deletion, +foreign-tenant and actual first-native-read checks. Existing Files/Artifacts, +cancel/crash/history checks then verify that initialization did not change the +execution loop or overwrite later user modifications. Controlled PostgreSQL lifecycle +tests separately exercise interrupted installation, readiness and maintenance fairness. +A test's presence is not a passing acceptance result; retain actual run evidence. + +### Accepted initial-file profiles (2026-09-20) + +The batch passed fixed SDK 3.13.0/raw HTTP acceptance with real models on Docker +and E2B for Codex, Claude Code and MiniMax Code. Both template and inline paths +verified initial native reads, Files/Artifacts, tenant and credential isolation, +source/template deletion followed by creation retry, cancellation, and preserved +workspace changes/native history after Core and Runtime restarts. E2B also verified +Core interruption during initialization: no native execution, no replay and owned +resource reclamation. All six completed runs reported clean resource cleanup. + +Separate real Provider checks covered Docker binary stdin/backpressure and E2B +50 MiB stdin. The real shared installer verified empty, binary, nested and 50 MiB +files, rejected symlink destinations, and preserved outside bytes. PostgreSQL/race +suites and `make check` passed. The optional native build probe skipped by the +default gate is not counted as real acceptance. Runtime images were the retained +qualified builds; Core was built from this batch. E2B runs preceded the final +readiness guard and store-interface cleanup, which received targeted regression; +The six-profile matrix preceded final creation-intent size and canonical-identity +corrections. Real HTTP/PostgreSQL regression accepted a 1 MiB file and two 5 MiB +inline files with retries, and verified canonical template/file encryption bindings. +A further rebuilt standalone Docker/Codex run passed a 5 MiB initial file with +real model reads, Artifacts, cancellation and retained history in 101.23 seconds. +The original Docker matrix used Core SHA-256 +`31973b17dd96106743e581c400555e3a4b036ad8cb3e68b51530a2b56023abe3`. + +Docker MiniMax Code passed with the real MiniMax API at its standard HTTPS origin +through the test network relay. Earlier Kimi/MiniMax connection timeouts remain +recorded with unknown cause, as does a Docker reconnect failure under a different +Core/Runtime restart order. They are not claimed as fixed. Sanitized run results, +checks, build hashes and failed attempts are retained under the private +`environment-template-files` acceptance directory and the linked task record. + +### Accepted env/setup and npm/Python batch + +`official_environment_setup.py` adds fixed-client/raw-response assertions for +confidential snapshots, safe package metadata, real registry dependencies, ordered +setup, native visibility across cwd and the post-setup network boundary. Runtime +mechanism tests cover private files/processes, immutable configuration, child +cleanup and failure receipts. The batch passed real-model template and inline acceptance on newly built Docker +Runtimes for Codex, Claude Code and MiniMax Code, plus Codex on a newly built E2B +template. Each verified actual npm/PyPI installs, ordered setup, native env and +dependency visibility across working directories, Files/Artifacts, cancellation, +post-setup disabled networking and recovery without repeating initialization. E2B +also verified daemon/history/process/envd isolation and separate Core/Runtime +crashes with exact native history and no automatic input replay. + +The shared initializer additionally passed actual Docker isolation probes for all +three profiles and E2B registry installation. Docker nonzero setup and missing cwd +failed before native Turns and reclaimed the Environment. PostgreSQL/race checks +cover encrypted owner/field-bound snapshots, readiness and uncertain-install cleanup. +A rebuilt standalone Core passed real fixed-SDK/raw-HTTP retries with changed, added +and removed inline env/setup under a saved Agent; unchanged retries still recover +after Agent deletion. Only this creation-identity regression required the final +Core rebuild; the completed model matrix preceded that isolated hash correction. + +One MiniMax inline post-restart model request reported an upstream timeout after +100 seconds. The affected inline rerun passed in 214.75 seconds, with no production +transport changes; this does not establish or fix the timeout cause. All completed +runs confirmed owned resource cleanup. The three-harness-by-two-Provider matrix +was not repeated: shared E2B initialization and the changed native adapter paths +were covered separately. System packages were outside that batch; their current +qualification is recorded separately. Unconfirmed reference overrides remain gaps, +and native Codex hook failure retains the limitation stated above. +Private sanitized run/check/build evidence is retained under +`~/.parsar/remediation/20260920/environment-template-setup/` and the linked board. +These results do not establish complete Template or Agents API compatibility. + + +### Accepted inline-Skill profiles (2026-09-20) + +`official_environment_skills.py` supplies fixed-client/raw-HTTP checks and a +native-discovered Skill whose helper produces an unpredictable Artifact, checks +private credentials/staging, and attempts to modify its own installed manifest. +Standalone Core, dedicated PostgreSQL and freshly packaged Docker Runtimes passed +with Codex 0.153.4/Kimi, Claude SDK 0.3.269 (native 2.1.269)/Kimi, and MiniMax Code +0.4.12/MiniMax-M3. Codex covered template and inline configuration; Claude and +MiniMax covered the template path through the same initializer. All verified safe +metadata, foreign-tenant rejection, frozen snapshots after template clear/delete, +creation retry, native Skill execution, Files/Artifacts, cancellation, and owned +history/workspace recovery after Core and Runtime restart. Cleanup completed. +The Core binary SHA-256 was +`85be1bc26ca6c03617ba74bf092485656dda9311bb636d507be6576a2f087f16`. + +The shared initializer also passed on a real Docker container and E2B VM, including +binary/executable content, read-only Skill access from setup, duplicate/path +rejection and private-state isolation. This batch did not repeat the E2B model +matrix: Provider code is unchanged, while its shared initialization boundary was +exercised in a real VM. PostgreSQL/API/archive tests, Claude SDK tests/build, +`make openapi`, `make sqlc-generate` and `make check` passed. The default gate's +optional native build probe remains skipped and is not counted as live acceptance. + +Initial integration failed safely because the proposed Skill parent was root-owned; +using the existing Runtime initialization directory resolved that packaging +boundary without broadening permissions. The earlier MiniMax/Kimi native timeout +and probe-only unrestricted-tool configuration failure remain recorded. The latter +passed after restoring the unchanged production tool settings. Docker builds reused +qualified base images after registry DNS failure; current daemon, adapter and +initializer artifacts were copied using the repository packaging recipe. Raw +receipts retain their inherited historical manifest fields; accompanying source, +Core and image hashes identify the actual candidates. Evidence is retained under +`~/.parsar/remediation/20260920/environment-template-skills` and the linked board +record. This profile does not establish complete upstream Skill semantics. + +### System-package qualification (2026-09-20) + +The batch passed standalone Docker acceptance with current-source Core/daemon and +newly packaged Codex, Claude Code and MiniMax Code Runtimes. Fixed SDK 3.13.0 and +raw HTTP exercised public templates; Codex also exercised inline configuration. +Actual Kimi/MiniMax requests verified jq, compiler/libpq linkage, dependent +npm/Python packages, ordered setup, native visibility, read-only installed roots, +Skill/credential protection, Files/Artifacts, public cancellation and retained +workspace/native history after Core and Runtime restart. Cancellation checks +observed tool identities disappear before sandbox teardown. All three completed +runs reported clean resource cleanup. Template omission, replacement, null/empty +values and atomic invalid-input rejection received additional real HTTP checks. + +The Core SHA-256 was +`9466a8419fd0e4ad8cd4fb1786ef131c2cc504513bf77642fca43ce07b8114a6`. +The Codex template/inline run took 599.72 seconds; Claude and MiniMax template +runs took 271.69 and 357.34 seconds. Real initialization mechanism checks separately +covered isolated package scripts and compilation. Focused Go/SDK tests, OpenAPI +generation and `make check` passed. The optional native build probe skipped by +the default gate is not counted as real acceptance. + +E2B finalization rewrites `/usr/local` permissions. Its trusted bootstrap must +restore the common system-tool launcher's packaged `0555` mode before native +preparation; root ownership alone does not satisfy that Runtime receipt check. +The first qualified Codex E2B template passed real Kimi template and inline acceptance in +576.41 seconds, including final seed/launcher protection, actual package/setup +visibility, Files/Artifacts, credential/history/process/envd isolation, public +cancellation, separate Core/Runtime crashes, continued owned history without +input or initialization replay, preserved user files, and disabled native-tool +networking. Cleanup completed without fallback errors. This run uses the updated +daemon with the bounded discovery adjustment described below. The immutable build +is `1b60xhq0j13fnr5zipkg:7d11189a-b2bd-4690-bc3f-da0792439f91`. The full +three-harness E2B matrix was not repeated: shared initialization and the changed +native adapter paths were covered separately. + +Independent review then identified a missing native cwd alias: the installed tool +root exposed `/workspace`, while Codex retained `/environment/workspace`. Both +now mount the same authorized workspace. A rebuilt Docker Runtime passed actual +system/npm/Python initialization and entry from the default directory and its +subdirectory in 106.30 seconds, including private-state isolation and read-only +tools. The earlier model runs selected `/workspace` and do not prove this fix. +The rebuilt E2B template +`1b60xhq0j13fnr5zipkg:e6437586-927e-4683-99fe-632b51a974fd` then passed the +real Kimi template/inline loop in 457.91 seconds. Native command Items and actual +effects verified the default directory and subdirectory; the same run passed +Files/Artifacts, private-state isolation, cancellation, Core/Runtime recovery, +preserved history and user modifications, and disabled tool networking. Cleanup +reported no errors. The final mount-only correction received this actual regression +and Python source checks; the two full `make check` runs precede it. + +Failed attempts are retained: early admission incorrectly required the private +initialization receipt; execution preparation now owns that check. Test-only proxy +configuration and simultaneous package installation attempts failed before the +sequential accepted runs, without extending production budgets. E2B cold discovery +once killed `codex --version`; unchanged discovery subsequently passed, but a later cold deployment repeated +the failure with no observed OOM. The shared CLI availability probe now allows +15 seconds instead of five; no retry or Provider-specific startup path is added. +The precise initial paging/contention cause remains unconfirmed. Docker execution +results above precede this isolated startup-budget adjustment. The E2B launcher-mode mismatch failed preparation before any +native input was applied. The subsequent native isolation fixture assumed +`sudo` existed; the real tool transcript showed `FileNotFoundError`. The fixture +now records an absent privilege command explicitly while retaining all authority +and private-state checks. Interactive PTY behavior and packages needing additional +Unix identities or privileged services are not qualified by these results. + +Sanitized results, image/source hashes, full checks and failed evidence are retained +under `~/.parsar/remediation/20260920/environment-template-capabilities` and the board. +Early Docker result manifests contain inherited installer archive fields; those +fields do not qualify a new installer archive. Current binary and image hashes +identify the tested deployment. These checks do not establish complete upstream +Template or Agents API compatibility. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md new file mode 100644 index 000000000..d5210176c --- /dev/null +++ b/contracts/agents-api/environments.md @@ -0,0 +1,530 @@ +# Environment contract and implementation path + +This assessment covers the fixed [Python SDK contract](upstream.json). It is an +implementation plan with partial current coverage. Public execution admits +`environment.type=none` on Codex and Claude SDK, the Codex self-hosted text/function +profile, and operator-configured Docker/E2B hosted profiles for Codex, Claude Code +and MiniMax Code below. +Environment retrieval supports safe metadata for these environment profiles; +[reusable templates and initial files](environment-templates.md) share inline initialization. +Other populated startup installations remain missing. Live file listing +and local inline/source writes have [partial coverage and explicit local policies](environment-files.md). +See [current coverage](README.md#public-semantics). + +The internal Store now owns a durable Environment association for newly created +`self_hosted` and `openai_hosted` snapshots, atomically with Session creation. +It derives configuration and tenant ownership from the Session; retries preserve +the existing identity. Scoped reads hide associations after Session deletion while +retaining the underlying record. The public text profile reuses this association +and the preparation/admission path below; additional provider profiles remain open. +Missing/`none` configurations and historical internal snapshots gain no backfill. + + +The native Codex registry uses principal executor digest bindings with optional exact-Environment +restrictions, the existing execution owner and scoped Store reads. Registration and current +socket identity are process-local; the returned WebSocket capability expires for +new connections after five minutes. Restart invalidates registrations, causing the +native executor to register again. Replaced socket callbacks cannot clear a newer +connection. Current socket observations now commit `connected`/`disconnected` and +immutable pinned Environment-event snapshots through the leased Store. Replacement +and revision fencing prevent late observations from overwriting successors; startup +reconciliation removes the previous process's connection evidence. Registration +alone is not connection, and connection is not native readiness. The public text +profile and resource reads use this bridge. The canonical +[observation and shutdown rules](../../CONTRIBUTING.md#environment-ownership-and-placement) +cover write failures and recovery. Deleting the owning Session rejects new requests and closes +existing sockets on the next ownership heartbeat. A previous holder of a still-valid +executor credential can register again; permanent exclusion requires revocation. +Execution owners now obtain transient harness credentials through the internal +registry after exact tenant/Environment and execution-lease authorization. Their +owner context spans preparation and the transferred Run; release/cancellation +invalidates the credential and its own grants/pair. Static harness keys are retired. +These credentials obtain short-lived, key-bound connection grants. +The relay pairs one harness with the current executor socket and forwards native +binary frames unchanged. Either peer loss closes both physical connections and +invalidates grants; no queued frames or commands move to a successor. Refresh does +not disturb a healthy pair. See the [operator prerequisite](../../services/agents-api/README.md#native-executor-transport-prerequisite). + +## Basic public Docker-hosted profile + +An explicitly configured default managed Docker provider enables `type=openai_hosted` +for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same +Runtime lifecycle and workspace interfaces with its own native adapter/isolation. +See the [engine profile guides](README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](../../services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +selects the qualified immutable Runtime image; advertised capabilities alone do +not enable admission. An idle or initial-text creation commits Session, Environment +and retry identity before the existing leased Worker provisions its allocation. +A committed creation interrupted before bootstrap is recovered without replaying +an existing allocation's Create. + +Omitted/null network defaults to enabled; explicit enabled and disabled use the +same image with adapter-selected immutable native policy. Unsupported restricted +domains and populated env/packages/setup/plugins/skills/capability +paths fail explicitly. Initial inline/file_id files use the shared hosted initializer. Empty/null installation defaults produce safe empty metadata, +not a live workspace inventory. Hosted MCP combinations remain unimplemented. + +Initial provisioning leaves a Session idle until a Turn starts, with no caller +connection action. The managed scan records authenticated, exactly bound daemon +connections through existing fenced generations. Native preparation remains +separate. Core restart preserves allocation/workspace/native identity; it does not +blindly replay uncertain work. Terminal cleanup revokes authority and settles +pending input atomically before external reclamation. Matching retries preserve +outcomes; new inputs reject terminal Environments. Expiry has no invented SSE +variant. Local failure codes and exact event ordering remain unverified upstream +semantics; this profile does not establish complete Environment compatibility. + +## Basic public E2B-hosted profile + +The [E2B operator configuration](../../services/agents-api/deploy/e2b/README.md) +selects a qualified immutable template/build for the same three harnesses and +`type=openai_hosted` admission. It retains the shared Runtime execution, Files, +Artifacts and recovery paths and the public configuration limits above. Actual +[three-harness E2B acceptance](README.md#e2b-v1-qualification) is separate from +Docker evidence. The Provider's five operations manage allocation, initialization, +lease renewal and cleanup only. A minimum two-hour renewable lease is required; +expiry destroys volatile VM workspace/history and cannot authorize replay or +transparent recreation. Pausing, migration and user-managed enrollment are not +part of this qualified profile. + +## Initial public self-hosted profile + +Create a Session with `environment.type=self_hosted`, an absolute +`workspace_directory` and omitted/null/empty `capability_directories`. Creation +accepts initial text as a string or ordered user-message array. Omitted/null input +creates no Turn or connection action. Configured execution, a validated registry origin, +Codex and supported non-deferred function definitions are validated before persistence. + +Initial text commits a reservation and connection action, then returns the Session +and Environment connection target while offline. Streamed creation sends its +original `created` snapshot before the connection action. The existing Worker +prepares and admits the input; closing the stream leaves committed work intact. +Initial expiry leaves a failed Session, safe error and empty actions without a +Turn or an Environment failure. Creation retries preserve the original identity, +deadline and input. Later live observers do not replay creation events. + +Later text-only batches recover their original reservation or direct receipt under +the Session lock. New active messages append to the current Turn through existing +ordered admission and native delivery; they create no Turn, preparation or reservation. +If no Turn is active, reserve and wait for the existing Worker to retain native +preparation, admit and claim. Return 204 only after that +transaction commits. Connection actions precede a Turn and clear on connection; +connection alone is not readiness. Retries preserve identity and the five-minute +database deadline. HTTP disconnect retains the reservation. Local expired/cancelled +outcomes return 409, lost ownership 503, and deletion 404; exact hosted error +status/body and pending-input crash recovery are unverified. See the +[canonical wait rules](../../CONTRIBUTING.md#environment-ownership-and-placement). + +Cancellation-only batches use the existing locked admission and native delivery. +An idle cancellation creates no Turn, and retry identity preserves the original +target during later work. Pending pre-Turn reservations still block new cancellation. +HTTP 204 confirms admission, not native completion or OS quiescence. A cancellation +before native Session transfer can still lack a final Outcome and conservatively +fail; complete cancellation settlement remains open. +Homogeneous function-result batches reuse scoped locked admission and native +application receipts, without creating a Turn or preparation. Definitions use the +existing function parser and remain fixed through native preparation and continuation; +output/error field presence and ordered content keep their existing semantics. +Retries retain the original call, including during later work. New results cannot +bypass pending input. Function callbacks do not populate Environment installations. +Mixed events, non-text input, nonempty capability directories and other +engine placements are rejected temporary gaps. The current adapter also rejects +workspace paths containing NUL, CR, LF or backslash; broader path/platform support +remains open. Native execution still uses the +scoped upstream-library launcher; arbitrary-domain stock CLI support is not proven. +The built-service acceptance must publicly create and submit, keep a request open +past 30 seconds, and verify two real remote command/file/history Turns through +fixed SDK, raw HTTP and live SSE. Private setup alone is insufficient. + +## Contract inventory + +Paths below follow the SDK resource methods, before the service's `/v1` prefix. +The authoritative fields and unions are linked to the pinned source; this table +is an inventory, not a replacement schema. + +| Resource | Operations | Contract distinctions | +| --- | --- | --- | +| [Environment](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/environments.py) | `GET /agents/environments/{id}` | Created through Session configuration, with no standalone create/list/update/delete method in this resource. Safe metadata includes files, plugins, skills, type and status. | +| [Template](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py) | `POST`, `GET /agents/environments/templates`; `GET`, `POST`, `DELETE /agents/environments/templates/{id}` | Reusable hosted configuration, resolved for each Session. List uses `after`, `limit` and `order`. Supplied update fields replace their value; omitted fields stay unchanged. Deletion includes confidential inputs. | +| [Files](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/files.py) | `POST`, `GET /agents/environments/{id}/files` | Create accepts `file_id` or inline base64 data with an absolute path inside `/workspace`. List uses opaque `page`, not `after`, with stable path/order/limit across pages. | + +These are eight operations, separate from Session creation and live events. +Templates use an `after` cursor and limit 1–100, default 20; file listing has nullable +limit/path, non-null order/page when supplied, and case-sensitive path-component +ordering. Both default to descending order. Do not reuse cursor decoding merely +because both endpoints paginate. + +[Session environment input](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/environment_param.py) +and [output](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/environment.py) +have different shapes: + +- `none` selects no execution environment. +- `self_hosted` input requires `type` and `workspace_directory`; its optional + nullable `capability_directories` defaults to an empty list. `remote_url` is + output-only. The output also includes the Environment ID and capability paths. + The output description's `/workspace` default does not make the input field + optional. +- `openai_hosted` can reference a template and supply capability paths, network, + packages, files, plugins, skills, environment variables and setup commands. + Omitted template-backed values inherit; Session overrides cannot broaden the + template network policy. The service implementing this discriminator owns + provisioning; it does not rename the public discriminator for its provider. + +[Template responses](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/agents/environments/environment_template.py) +expose safe metadata, retaining unresolved skill version selectors and file +references. They do not return inline file/archive contents, environment variables +or setup command bodies. Nullability and replacement behavior must be checked +against each request type, not inferred from these response models. + +| Projection | State vocabulary | +| --- | --- | +| [Environment resource](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/agents/environment_info.py) | `pending`, `connected`, `disconnected`, `expired`, `failed` | +| [Session environment event state](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/beta/agent_session_environment_state.py) | `pending`, `ready`, `connected`, `disconnected`, `failed`; nullable error | + +These projections cannot share an unchecked string cast. Session environment +notifications carry Session/Environment identity and optional Turn identity. +The Session's `required_actions` union includes `environment_connection` with an +Environment ID, separately from function calls. Environment readiness is distinct +from Session and Turn status. + +## Ownership and placement decision + +The canonical [architecture rules](../../CONTRIBUTING.md#environment-ownership-and-placement) +keep Environment lifecycle common while leaving process placement and native +transport to adapters. Logical ownership does not require a machine per object. + +| Object | Responsibility | +| --- | --- | +| API Session and Environment | Durable tenant ownership, configuration, pending interaction and connection observations. | +| Provider allocation | Compute and filesystem lifetime; caller-owned for `self_hosted`, service-owned for hosted provisioning. | +| Device and daemon connection | Authenticated engine-host identity and replaceable internal dispatch transport. | +| Harness process and native Session | Native model/tool loop, execution state and proven history/continuation path. | +| Executor connection | Access to an Environment's filesystem/process capabilities, independently authorized. | + +A co-located daemon/harness/workspace is a proposed placement for engines with +native local tools. A harness using a separate executor is another placement. +Neither proposal establishes public compatibility by itself. Advertising the +specified `self_hosted` flow requires an actual caller-started executor to work; +quietly requiring an extra Parsar daemon installation changes that flow. + +Co-location needs a real credential and isolation design: generated code must not +gain the broader application credential or cross-tenant secrets through a shared +unrestricted process account. A directory binding alone is not isolation. Retain +native history independently of disposable compute, or prove native restoration; +never treat an Environment ID as a filesystem or history backup. Do not silently +move an existing Session away from its bound device. + +## Evidence and interoperability gap + +The current [self-hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/self-hosted) +uses a restricted executor key and both returned values: + +```sh +codex exec-server --remote "$REMOTE_URL" --environment-id "$ENVIRONMENT_ID" +``` + +Keep the broader application key outside that environment. The returned URL is +passed unchanged on reconnect. Each Session has its own Environment ID/executor. +The guide currently installs `@openai/codex@alpha`; it does not pin our native +binary version. Guide observations are supplemental evidence, not a silent SDK or +engine upgrade. + +The pinned native reference is Codex `rust-v0.153.4`, commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`: + +- [Registry messages](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/exec-server/src/environment_registry.rs) + and [remote client](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/exec-server/src/remote.rs) + define executor registration, harness-key connection authorization and validation, + with native `noise_hybrid_ik_v1` transport. Reuse upstream clients and encryption; + implement the missing registry/relay without rebuilding native execution. +- The [CLI authentication check](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/cli/src/main.rs) + restricts API-key registration to HTTPS OpenAI domains or loopback. A third-party + production URL with a service-issued restricted key is therefore not established + by this binary. Resolve a supported upstream path explicitly; do not disable its + credential protection or present local development routing as deployable support. + The public `RemoteEnvironmentConfig::new` accepts a native `SharedAuthProvider`: + the [separate launcher](../../packages/codex-executor/README.md) embeds upstream + execution with an explicit service-scoped credential file. This is a library integration, not a custom-auth flag for the stock CLI + or proof that its documented command works on a third-party production domain. +- The [native Environment manager](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/exec-server/src/environment.rs) + already consumes a registry configuration. Its private settings remain inside the + Codex adapter. `CODEX_EXEC_SERVER_URL` is a harness-side direct transport selector; + it is not the public registration URL or the daemon gateway URL. + +Recorded binary evidence: the earlier stdio probe initialized the executor, +observed command output and exit 7, and received a termination acknowledgement. +The remote registration preflight now confirms third-party-domain rejection and a +loopback POST containing native security-profile/public-key fields and the supplied +synthetic bearer. The probe deliberately returns 503 before relay allocation. +Those preflights did not establish registration success, Noise interoperability or +process termination. Their evidence remains under +`~/.parsar/remediation/20260913/environment-contract/` on `zju_a100_2`. + +A subsequent loopback probe uses the unmodified Codex 0.153.4 executor command, +the matching native `EnvironmentManager`, upstream registry message types and the +upstream opaque relay test helper. It verifies registration/connect/validation, +native remote file write/read, separate stdout/stderr with exit 7, and termination +of a running sleep process through a closed native result with exit 137. A fresh +connection reads the retained file and repeats execution. The relay carries native +encrypted frames; file contents are absent from captured frames. Invalid harness +key authorization, an executor credential used for harness connection and an +unknown Environment ID are rejected. + +This is synthetic-credential protocol verification with zero model calls. It does +not test a valid foreign tenant, production WebSocket authorization/TLS, isolated +filesystems, interrupted-command replay or public API/daemon execution. It leaves +the stock CLI's third-party-domain restriction unresolved. Evidence and runnable +fixture sources are retained under +`~/.parsar/remediation/20260913/environment-executor-interoperability/` on +`zju_a100_2`. The upstream release lock needed only local workspace version labels +aligned to its manifests; third-party versions, sources, checksums and dependency +edges stayed unchanged. Native execution and encryption sources were unchanged. + +The probe supports reusing the native Codex client/executor libraries for this +adapter. The service adapter now combines durable ownership reads, bounded +registration, scoped harness grants and opaque paired forwarding. A separate +real-PostgreSQL/native test uses the unmodified executor and matching +`EnvironmentManager` against the actual Go adapter. It verifies simultaneous +commands and a 128 KiB file, stdout/stderr/exit, termination, non-disruptive same-key +refresh, and recovery of the same process handle after one controlled transport +outage. A single command-start marker proves that this acknowledged-start case did +not repeat its command. A fresh harness reads the retained file. Store tests reject +valid foreign-tenant bindings and deleted Session ownership. Evidence is retained +under `~/.parsar/remediation/20260913/native-harness-relay/` on `zju_a100_2`. + +This remains a transport prerequisite with synthetic credentials and zero model +calls, not public Environment admission, daemon dispatch or real-model Environment +acceptance. Only one independent harness connection per Environment is supported; +arbitrary interrupted-work replay, native crash restoration, TLS deployment and +the stock CLI's production-domain restriction remain open. Other harnesses retain +their own native placement and execution protocols. + +## Native app-server placement prerequisite + +The opt-in [real-provider fixture](../../services/agents-api/tests/native/README.md) +adds stock app-server execution to the accepted PostgreSQL registry/relay. It keeps +local harness history separate from a container-only executor workspace, exercises +real MiniMax shell/file use, and resumes the same native thread after a fresh +app-server. This is native placement evidence, not public API/daemon acceptance. + +The pinned app-server loads registry configuration from its three +`CODEX_EXEC_SERVER_NOISE_*` startup variables. Its native Environment selector is +`remote`; that selector differs from the service Environment UUID used for registry +authorization. Supply executor-native cwd/roots in `thread/start.environments` and +`turn/start.environments`, while the app-server process stays in its local cwd. +Native resume does not restore these selections from history. Do not assume a +completed Turn proves remote readiness or tool execution. + +Native `turn/interrupt` intentionally preserves unified_exec background processes. +The [upstream test](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/core/tests/suite/unified_exec.rs#L2856) +asserts that behavior. To terminate a particular owned process, reuse experimental +`thread/backgroundTerminals/list` and `thread/backgroundTerminals/terminate`. +Correlate both item/process IDs with the original Turn's native events: listing +has no Turn ID, and termination can affect earlier Turns' retained processes. +Its acknowledgement does not wait for OS exit; observe the process and side effects +before claiming quiescence. Harness connection loss has a separate native detached +Session retention/cleanup window. These facts constrain the future cancellation +mapping; they do not independently establish hosted Agents API cancel semantics. +The daemon now supplies the observed native `turnId` (or the explicit empty startup +form) in its interrupt payload. Its applied receipt does not prove final output +settlement or process exit. + +The native shell-policy default retains credential-like variables. The fixture +uses `inherit=core` and `ignore_default_excludes=false`; it separately characterizes +default-policy exposure without printing values. The Noise harness bearer is +non-inheritable, but that rule does not cover every executor launch credential. +No environment-variable policy isolates same-user process memory, `/proc` or files. +Scoped credentials, placement trust and long-Turn reconnect lifetime remain explicit +dispatch prerequisites. Public acceptance must verify those boundaries, readiness +and real API/daemon execution together. + +## Private daemon adapter + +The registered-daemon fixture extends placement through the authenticated gateway, +capability heartbeat and typed remote descriptor. The adapter consumes transient +connection credentials, verifies native readiness and selects the executor on first +and cold-resumed Turns. Local harness history remains separate from remote files. +See [the contributor boundary](../../CONTRIBUTING.md) and +[the real-provider fixture](../../services/agents-api/tests/native/README.md) for +supported native version, rejected combinations and acceptance commands. + +The Codex adapter now separates preparation from prompt start using the same native +RPC resource. It retains initialized environment access without starting a thread +or model work, then transfers its fixed configuration and ownership once to the +normal Session. The existing Factory uses that path. Private daemon controls now +retain it through asynchronous preparation and one start, using a connection-owned +handle, bounded lifetime/capacity and separate gateway response correlation. +Preparation creates no Run subscription; only Start supplies the actual RunID. +The configured service Worker now uses this private primitive; +it does not expose public readiness. See the contributor guide for ownership, retry, +revision and cleanup rules. + +Cancellation still uses the existing best-effort interrupt and harness release. +The fixture measures remote PID exit and stopped side effects independently; +native detached cleanup may delay that exit. Complete resource lifecycle and +complete public cancellation settlement remain separate from the initial text profile. + + +## Shared native filesystem prerequisite + +The opt-in [shared-owner fixture](../../services/agents-api/tests/native/README.md#shared-native-filesystem-owner) +characterizes direct remote file operations alongside the upstream native model/tool +loop. It uses one injected `EnvironmentManager` and one authorized registry pair; +it does not open another harness connection or use stock host-only `fs/*` calls. +Native filesystem metadata supplies actual byte sizes, which the stock app-server +metadata response omits. Follow the [ownership boundary](../../CONTRIBUTING.md). + +The fixture requires idle and active binary/file access, independent remote command +effects, and cold native history. Passing those observations does not establish +lossless delivery under saturation: the pinned embedding transport can drop +notifications without a `Lagged` event. Production event handling, process/credential +lifetime and daemon integration remain prerequisites. Public file create/list, +uploaded file references, workspace path semantics, pagination and installation +inventory remain unimplemented by this experiment. + +## Pending input storage prerequisite + +A private Store reservation can retain one ordered message batch without a Turn, +Items or Turn events. It shares request identity with direct input admission and +preserves the original five-minute database deadline across retries. Promotion +requires the leased writer and atomically creates history, settles the reservation +and claims its Turn as `in_progress`. Only the first non-replay receipts authorize +Start on the retained native preparation. Retries cannot reclaim execution. Startup +reconciliation settles a committed claim interrupted before Start, without replay. +Expiration, targeted cancellation and Session deletion retain their existing +pre-admission or claimed-Turn semantics. + +The initial public idle-text profile uses this primitive. Its message-only scope +and single pending reservation are implementation limits, not claims about the +final protocol. Homogeneous results use the separate existing call-admission path; +active messages use existing input receipts in the same locked admission decision. +Mixed inputs remain required. +The Store reserves initial messages atomically with a new +Environment-bearing Session, preserving the creation cursor and retry identity. +Initial expiry emits a failed Session snapshot with a safe error and no Turn; +later expiry retains idle behavior. Historical reservation origins are not inferred. +The same storage rule covers internal hosted associations without enabling a +provider. Public ordinary and streamed creation reuse this transaction through +the Worker facade, with new-work ownership checks and prompt offline responses. +The Worker settles due reservations in bounded batches even without +devices or available execution slots, skipping contended Session locks and retaining +its current execution ownership. Restart does not reset stored deadlines. This +expiry creates no Turn; only expired initial reservations emit Session failure. +Exact hosted error wording, cancellation and crash behavior remain unverified. See the +[contributor boundary](../../CONTRIBUTING.md) for the prepared connection, expiry +and transaction rules. + +The private Dispatcher now connects these prerequisites for an already bound +Session. It retains one capable daemon peer and preparation, checks the pending +deadline/ownership, then promotes and starts only fresh admission receipts. The +same delivery path journals events, applies later input/cancellation receipts and +persists terminal state and native continuation. A transient connection callback +keeps native registry code outside the execution core. Its credential owner spans +the complete Run; a pending-input deadline does not limit an admitted Turn. +Controlled database/gateway tests cover pre-ready settlement and pending-start +cancellation. The existing Worker selects pending reservations with a live tenant device when +configured with a connection resolver. Unbound Sessions select a capable device +and retain that binding; existing bindings are never moved. Preparation +through Run cleanup shares its four ordinary execution slots, with one active job +per Session and bounded, alternating cursor scans. Private pending selection runs +at most once per five seconds; failed preparation can retry without extending the +original deadline, while claimed/uncertain work is not replayed. The opt-in +real-provider Worker fixture verifies automatic discovery, remote commands, files, +cold continuation and reservation retries. The standalone service wires the resolver +when its daemon gateway and executor URL are configured. The same scheduling and +expiry path owns public initial reservations without a separate execution loop. +Caller keys resolve trusted project/subject identities, with persistent project +bindings verified before startup. New Sessions persist the typed creator and +require it for creation retries; historical unknown creators cannot be claimed. +Executor keys match the recorded project and typed creator. Complete lifecycle +conformance remains unverified. +The current daemon can acknowledge pending-start cancellation without a final +outcome; without an observed final Done, delivery records an unknown failure. +Preparation failure cannot discard a cancellation receipt already being awaited. +Complete cancellation output/Usage and native cleanup remain required work. + +### Pending input activity and Session reads + +The latest relevant reservation now owns a narrow pre-Turn activity projection. +Pending offline input emits `requires_action` with `environment_connection`; +connection arrival clears it to `idle` before native preparation admits a Turn. +Offline Sessions without waiting input request nothing. Newer or active Turns +supersede the reservation. Connection/reservation mutations and immutable activity +events commit together, including captured Usage; reads and SSE share that state. +Cancelling or expiring a non-initial reservation clears its action to `idle`, +without reviving an earlier failed Turn. Exact hosted settlement/error behavior and +initial-input asynchronous failure remain unverified; this policy does not claim +their compatibility. + +With the validated executor origin configured, ordinary Session GET/list/metadata +and live SSE return `self_hosted` Sessions. Their safe +output contains the real Environment ID, unchanged configured `remote_url`, +workspace and capability directories. It excludes private configuration and does +not infer URLs from request headers. Fixed SDK/raw HTTP/live SSE acceptance uses +the returned URL and ID to start the real executor, then observes the existing +Worker's remote first/resumed model workflow. The earlier private-provisioning +fixture remains a separate lower-level regression; the public profile has its own +built-service creation/input acceptance. Environment retrieval uses the same durable +observations through the existing live-Session ownership join. It returns the seven +required fields and empty installed-resource arrays only for the closed supported +self-hosted configuration. No API-managed installation resource exists in that +profile; caller-prepared or model-created workspace files are not this inventory. +Unsupported installation fields/capabilities fail closed. This read does not require +execution/registry configuration or invoke native work. Populated metadata, file +operations beyond the current Files profile, populated hosted output and complete +Environment conformance remain separate work. + +## Dependency-ordered implementation + +1. **Executor interoperability.** Demonstrate the documented unmodified executor + command with supported authentication, then native harness authorization, + encrypted initialization, command output/exit and termination. Verify foreign + tenant and wrong-purpose credential rejection. Do not grow a universal transport + framework or change the protocol pin to get a passing probe. +2. **Durable ownership.** Create tenant/Session/Environment association atomically + with Session creation and retry identity. Separate immutable configuration from + mutable lifecycle/registration. Fence replaced registrations so stale disconnects + cannot overwrite current observations. Implement safe resource reads and explicit + event-state projection together with meaningful lifecycle behavior. +3. **Input and execution integration.** Represent `environment_connection` before + waiting work starts. Connect/readiness gates precede claim; recheck ownership at + dispatch. Pass a typed environment descriptor through the daemon boundary. Keep + the harness's local cwd separate from the executor workspace; never locally + create an executor-only path. Real model acceptance must cover remote file and + command use, cancellation and continuation through API, daemon and native harness. +4. **Additional placement and resources.** Prove native co-location where useful; + no MCP substitute is automatically equivalent to native tools. Add provider, + template, confidential-input and file operations in independently accepted slices + using maintained provider SDKs and existing storage/authorization infrastructure. + +The [lifecycle guide](https://developers.openai.com/api/docs/guides/agents-api/environments/lifecycle) +requests compute through `environment_connection`, before Turn creation; connection +events only report observations. A waiting submission can continue when connection +arrives. The guide describes a five-minute wait and no guaranteed recovery of +pending input after a crash; a late connection does not replay timed-out work. +Session deletion and caller compute shutdown are separate operations. An idle +notification alone is insufficient evidence that compute can safely stop. + +Exact hosted timing/errors, interrupted input recovery, executor replacement, +expiration, native cleanup and unsupported engine placements remain explicit +validation gaps. Preserve those gaps in the board and reassess its complete +priorities after each accepted slice. No placeholder resource, permissive SDK +parse or synthetic execution test establishes this roadmap as implemented. + +### Durable executor credential prerequisite + +The native registry authenticates connect-only executor keys against the target +Session's verified project partition and immutable typed creator. Keys may be +issued before Session creation or restricted to one live Environment. Their stable +management IDs, principal/restriction and digest survive restart. Explicit rotation +or revocation changes current authorization without restarting the registry. +Deleting one Session denies that target without revoking a key shared by other +matching Sessions. Legacy keys remain revoked with unknown principals; no identity +is inferred. See the [operator cutover](../../services/agents-api/README.md#native-executor-transport-prerequisite). + +Existing sockets are checked on heartbeats; disconnection does not establish +process quiescence. Harness keys and five-minute connection grants have separate +purposes and lifetimes. This implements the executor-specific principal prerequisite; +it does not establish a general creator-only Session ACL, hosted key lifecycle/error +parity or stock-command support on arbitrary domains. Public idle-text admission +has separate built-service acceptance. diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md new file mode 100644 index 000000000..f820e2345 --- /dev/null +++ b/contracts/agents-api/harness-onboarding.md @@ -0,0 +1,147 @@ +# Add a native harness to Agent Core + +This reference is for adapter developers using the shared contract on main after +PR #701 (2026-09-19). Start with a working native SDK or machine-readable protocol. +The goal is to register an engine without changing public handlers, storage or the +scheduler. Codex, Claude and additional harnesses have equal architectural status; +each engine qualifies its own supported operations. + +The [harness contract](harnesses.md) is the semantic baseline. The +[contributor guide](../../CONTRIBUTING.md#harness-qualification-and-onboarding) +owns architecture and delivery rules. The pinned external Agents API in +[upstream.json](upstream.json) remains separate from this private adapter contract. + +## Ownership and implementation locations + +| Component | Responsibility | Existing location | +| --- | --- | --- | +| Core | Public protocol, authentication, resource ownership, durable state and scheduling | `services/agents-api` | +| Runtime | Authenticated connection, dispatch, input receipts and preparation ownership | `apps/parsar-daemon/internal/dispatch` | +| Adapter | Native configuration, process/SDK calls, event translation and native restrictions | `apps/parsar-daemon/internal/agent/` | +| Harness | Model/tool loop and native history | Pinned upstream SDK or executable | +| Service profile | Pure validation of qualified placements and option values | `services/agents-api/internal/engine` | +| Runtime registration | Factory and verified capabilities available in this installation | `apps/parsar-daemon/internal/cli` | + +A service profile authorizes supported combinations. A Runtime advertisement says +what that installation can execute. Neither replaces public schema validation, +tenant authorization or the other boundary. Native limits belong in adapters and +profiles, not engine-name branches in Core. + +## Implement the existing interfaces + +Use `internal/agentdaemon/proto` for requests, events, receipts and errors. Do not +introduce a parallel wire protocol or another model/tool loop. + +| Interface | When required | Observable obligation | +| --- | --- | --- | +| `agent.Factory` and `agent.Session` | Every harness | Start a run; cancel idempotently; own output closure and native process lifetime | +| `agent.DurableSteerer` | Current public text execution contract | Distinguish a complete native write from confirmed application; return rejection/inactive/not-ready accurately | +| `agent.PreparationFactory` and `agent.Prepared` | Placements requiring preparation | Prepare without consuming model input; transfer ownership once in Start; retain failed cleanup ownership | +| `agent.PreparedCancellation` | Executable preparations | Cancel the same resource across Start; settle only after native effects and output writes stop | +| `agent.PermissionResponder`, `agent.UserChoiceResponder` | Only when emitting those interactions | Route exact interaction identities and preserve application receipts | +| Other optional interfaces | Only for declared operations | Implement the existing operation semantics and validate native support | + +Read the source comments in `registry.go`, `steering.go`, `preparation.go` and +`interactions.go` before implementing. Base `Session.Cancel` is a cancellation +signal; its return alone is not proof that all effects stopped. Use the existing +router's settlement path and adapter outcome interfaces. Do not give both the +adapter and router ownership of closing the same output channel. + +Preserve event order and native call identities. Emit one run terminal outcome; +never manufacture applied input, usage counters or successful cleanup. Resume only +the native history bound to the execution Session. Missing or ambiguous history +fails before new model input. A disconnected observer does not authorize replay. + +## Register a supported operation set + +1. Pin the upstream source/package version and document the native entry point. +2. Implement the adapter using its SDK or native protocol. Reuse shared process, + credential/configuration and local workspace helpers where applicable. +3. Register its `SupportedAgentKind` and factory with `RegisterKind`; register + preparation afterward with `RegisterPreparation` when supported. Runtime + advertisements must describe behavior verified for that installation. +4. Add a profile to the existing static catalog, or supply an immutable catalog + through service composition. Custom composition supplies the same + `execution.Policy` to `api.WithExecutionPolicy` and `Dispatcher.Policy`. +5. Package the native prerequisites and select the engine through operator + configuration (`AGENTS_API_ENGINE`). Do not invent a public harness field. + +The static registration surface requires a build. Dynamic plugins are outside +this contract. A small adapter does not remove the need for native qualification. +The runnable test-only example is +[`testdata/onboarding/main.go`](../../apps/parsar-daemon/testdata/onboarding/main.go). +It registers a text-only synthetic harness and is never shipped as a real engine. + +## Required behavior versus optional operations + +The current public text path requires durable turns, applied input receipts, +ordered observations, cancellation and enforcement of disabled execution controls. +Check `execution.Policy.engineCapabilities` for the exact current requirements. +An engine without native tools can guarantee their absence; an engine with tools +must actually disable them when requested. Configuration acceptance is not proof +of enforcement. + +MCP, public function calls, image inputs, verbosity controls and other optional +operations do not need to match another engine. Reject unqualified combinations +explicitly and record the gap. Never advertise a capability to bypass selection. + +Hosted workspace execution additionally requires verified preparation, workspace +reads/output export, network behavior and credential/history isolation. Reuse the +same dedicated Runtime binding and shared Files helpers. A native Bash sandbox +alone does not establish isolation for other native file tools. Enable a placement +only after its required security and lifecycle behavior is demonstrated. + +## Acceptance and delivery + +Before implementation, record the operation set, expected results, exclusions and +stopping conditions in the board. A batch ends when its declared operations pass; +it does not expand to match another harness's feature list. + +- Adapter tests: native failures, ordered events, input write/application receipts, + cancellation settlement, strict continuation and unknown-outcome handling. +- Shared integration: public admission, actual Worker/device selection, gateway, + daemon registration/dispatch and durable terminal projection. See + `TestThirdHarnessPublicOnboarding` for a synthetic example, not native evidence. +- Real acceptance: pinned official Python SDK and raw HTTP against our Agents API, + real provider API, native harness and independent execution database. Verify + initial execution, follow-up input, cancellation and restart/continuation. + For hosted qualification also verify Files/Artifacts, workspace identity, + credential protection and foreign-history rejection. +- Regression: existing qualified engines keep working. Run targeted tests during + development, then `make check` and applicable real regressions. API changes + require `make openapi`; query changes require `make sqlc-generate`. +- Review: use a fresh independent Astra high reviewer for shared, lifecycle or + security changes. Supply requirements, criteria, boundaries, rules, repository + and baseline only. Resolve material findings; defer documented low-value work. + +Record exact revisions, image/package versions, commands, results and limits. +Keep keys in private operator files; never commit them or include them in logs or +Feishu. Failed or synthetic runs cannot be counted as real acceptance. Merge the +bounded PR after required checks/review, update its board child and reassess the +full board. Acceptance of one engine is not complete public protocol compatibility. + +## MiniMax Code application + +The upstream project is [MiniMax-AI/minimax-code](https://github.com/MiniMax-AI/minimax-code). +The repository already contains an ACP stdio adapter under `agent/mcode`, including +native session creation/loading, events and human interactions. Its existing +product integration is not Agents API qualification. The current service catalog +registers Codex, Claude Code and MiniMax Code. Text qualification in #702 and +workspace qualification in #703 are separate recorded milestones. + +The implementation reuses that adapter with an explicit native 0.4.12 opt-in for +`environment:none` text execution. It adds native active-input receipts and +cancellation settlement, a pure service profile and verified registration. No +public handler, store schema or scheduler engine branch is needed. Existing +product behavior and accepted Codex/Claude features remain protected. + +See [the MiniMax Code deployment guide](../../services/agents-api/deploy/mcode/README.md) +for setup, real-provider acceptance and limits. Hosted workspace, Files/Artifacts, +public functions and MCP are not qualified by the text profile. Native differences +remain separately tracked work; they do not require feature equality for onboarding. + +The requested MiniMax Code workspace Runtime is qualified separately in +[MCODE-WORKSPACE-V1-001](mcode-workspace-v1.md). Its explicit scope includes +workspace execution and shared Files/Artifacts, cancellation/recovery and independent +Docker deployment. Text-only qualification is an intermediate milestone for that +scope, not completion of the requested Runtime integration. diff --git a/contracts/agents-api/harness-selection.md b/contracts/agents-api/harness-selection.md new file mode 100644 index 000000000..62d5879a3 --- /dev/null +++ b/contracts/agents-api/harness-selection.md @@ -0,0 +1,91 @@ +# Core harness selection extension + +The pinned official Agent contract has no harness selector. Core adds one optional +`x_agents_core` field to saved Agent create/update/read and Session inline/effective +Agent configuration. This is a Core extension, not an upstream field. + +```json +{"x_agents_core":{"harness":"claude_sdk"}} +``` + +Supported identifiers are `codex`, `claude_sdk` (Claude Code), and `mcode` +(MiniMax Code). Unknown identifiers, empty objects and unknown nested fields are +rejected. Omission inherits a saved Agent value, or uses `AGENTS_API_ENGINE` for +an inline Agent. An explicit null clears the saved selection or replaces it for +one Session, restoring deployment-default selection. Agent updates preserve omitted +fields and replace the entire supplied extension. Saved resources do not start +execution and can retain protocol configuration beyond the selected engine's +current execution profile. + +Session creation resolves the extension after saved-Agent overrides, validates the +selected execution profile, and persists the resulting existing `Session.Engine`. +An explicitly selected harness must be enabled by the deployment; it never falls +back to a different engine. When the effective Agent includes the extension, Session +reads report its persisted engine. Sessions without the extension retain the official +Agent response shape, including historical Sessions. Reads never consult current +Agent defaults or the current deployment default. Explicit-selector creation retries +retain caller intent before mutable configuration resolution. Changing a selector +under an existing creation key conflicts. + +The environment remains the separate official Session `environment` parameter. +Environment templates select startup configuration, not engines, containers or +providers. Multiple Sessions using the same Agent/template have separate Environment +resources and native histories. Agent edits do not change accepted Sessions. + +## Operator configuration + +Existing `AGENTS_API_ENGINE` and `default_provider` deployments keep their default +behavior. A deployment enabling multiple hosted harnesses adds `engine_providers` +to `AGENTS_API_MANAGED_RUNTIMES_FILE`: + +```json +{ + "core_url": "http://core:8091/api/v1", + "default_provider": "11111111-1111-4111-8111-111111111111", + "engine_providers": { + "codex": "11111111-1111-4111-8111-111111111111", + "claude_sdk": "22222222-2222-4222-8222-222222222222" + }, + "docker": { + "11111111-1111-4111-8111-111111111111": { + "host": "unix:///var/run/docker.sock", + "image": "sha256:", + "network": "bridge", + "seccomp_file": "/private/seccomp.json" + }, + "22222222-2222-4222-8222-222222222222": { + "host": "unix:///var/run/docker.sock", + "image": "sha256:", + "network": "bridge", + "seccomp_file": "/private/seccomp.json", + "nested_sandbox": true + } + } +} +``` + +Each reference must name an existing qualified provider entry. The default engine +inherits `default_provider` when no explicit mapping exists. Other engines have no +fallback. Admission and initial allocation use the same engine mapping. Once an +allocation exists, its persisted provider identity remains authoritative across +restarts and configuration changes. Keep retained provider entries for cleanup; +changing a provider target requires a new key. No image is qualified merely by +being named in this map. Existing provider security and native capability checks +still apply. This change adds no provider or native harness implementation. + +For multiple engines, use an exclusive `by_harness` object in the private +`AGENTS_API_EXECUTION_OPTIONS_FILE`, with one existing adapter-options object per +engine. No engine inherits another engine's credentials. A legacy flat options +object is usable only by the deployment default engine. Missing options for a +selected engine fail execution. Credentials stay in private operator files and +transient adapter requests, never Agent defaults, metadata or effective responses. +A Session may instead provide the [write-only model execution extension](model-execution.md); +its frozen configuration takes precedence without operator fallback. + +Model names remain explicit `agent.model` values. The selected native adapter uses +its configured provider and rejects unsupported model settings without changing +model identity. Core applies its existing engine/environment/tool/verbosity rules +before creating a Session; provider model availability is checked during native +startup/execution. There is no invented cross-provider model-name catalog. + +Current profile limits remain in the [engine coverage table](README.md#public-engine-profiles). diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md new file mode 100644 index 000000000..6f7b53358 --- /dev/null +++ b/contracts/agents-api/harnesses.md @@ -0,0 +1,134 @@ +# Native harness contract and qualification + +Codex, Claude Code and future harnesses are equal execution engines. Core owns +public protocol, authority and durable state. Each adapter owns native +configuration, transport and process translation; +the native harness owns the model/tool loop. Supporting this contract +means implementing its observable semantics. Harnesses do not need identical +feature sets. Optional native limitations are separate capability work and do not +block completion of otherwise qualified onboarding. + +For implementation steps and interface obligations, see +[Add a native harness](harness-onboarding.md). + +## Integration surface + +1. Implement the existing daemon `agent.Factory`/`Session` and, for prepared + environments, `PreparationFactory`/`Prepared` interfaces. `Session` requires + cancellation; adapters that emit permission or user-choice requests additionally + implement `PermissionResponder` or `UserChoiceResponder`. Other optional + interfaces, such as function results, follow their declared operations. Reuse + `internal/agentdaemon/proto` requests, neutral events, input receipts and errors. +2. Register the factory, preparation factory and verified Runtime capabilities in + the daemon registry. Keep native translation inside the adapter. Dedicated local + environments reuse shared Files/write/export helpers and binding checks. +3. Add a pure qualified profile to `services/agents-api/internal/engine` and its + static catalog (or supply an immutable catalog at service composition). Declare + supported placements, public configuration/result limits + and required Runtime controls. A profile uses existing public/protocol types; + it has no database, credential-decryption or native-process responsibilities. +4. Supply the native deployment prerequisites. Verify common lifecycle behavior + and run public acceptance for each declared operation. Do not require MCP, + functions, images, verbosity control or another engine's optional features simply + to register a harness. + No new handler, store table, scheduler, event projector or model loop is needed + for capabilities already represented by the contract. + +The catalog is the explicit service qualification boundary; a Runtime heartbeat +cannot authorize new public functionality. Unknown profiles fail closed. Schema +validity, qualified service support and the available Runtime remain independent +checks. Additional capability combinations require evidence, not an engine-name +exception. The static registry requires a build to add an implementation; dynamic +plugin loading and untrusted code execution are outside this design. + +New Session selection currently uses the operator's `AGENTS_API_ENGINE` setting; +existing Sessions retain their engine. The default is a deployment convenience, +not a different contract or authority level. There is no invented public `harness` +field. Future selection changes must respect the pinned public protocol. + +## Shared behavioral obligations + +- Preparation holds resources without consuming input; start transfers ownership + once. Unused preparation releases through `Close`; cancellation remains valid + across the transfer and reports settlement only after native effects stop. +- Confirm accepted/applied inputs separately. Preserve ordered public Items and + events, stable call identity and one terminal outcome. Never replay uncertain + work merely because a connection closed. +- Resume only the bound Session's native history. Missing, ambiguous or foreign + history fails closed. Device identity is not native Session ownership. +- Native tools and public Files operate on the same authorized workspace. + Generated code cannot access daemon/model credentials or foreign history. +- Emit verified measurements; absence of native usage detail is not a zero value. + Explicit unsupported operations remain implementation gaps in protocol coverage. + +## Current qualified operations + +The baseline is actual supported behavior on main, not everything Codex accepts +syntactically or everything either upstream harness can theoretically perform. + +| Operation | Codex | Claude Code | +| --- | --- | --- | +| Docker hosted text execution, native local tools | Qualified | Qualified | +| Files, immutable Artifacts, cancellation, restart/history recovery | Qualified | Qualified | +| Public functions in `none` | Qualified | Qualified; object-root schemas and text results | +| Public functions alongside hosted workspace tools | Qualified | Qualified; object-root schemas and text results | +| HTTP MCP and static-bearer Vault credentials in `none` | Qualified | Qualified subset | +| Required MCP initialization | Qualified | Qualified on `none`; native readiness before initial input | +| Hosted HTTP MCP | Gap | Gap | +| Function image results | Supported subset | Gap; currently rejected | +| Non-default verbosity | Native/model-dependent support | No equivalent qualified; medium only | +| Public detailed Usage | Supported native counters | Native raw usage retained; public breakdown gap | +| Official `self_hosted` remote executor path | Existing native Codex path | Not qualified; requires separate design | +| Explicit reasoning, structured output, enabled `multi_agent`, message images | Shared service gaps | Shared service gaps | + +This inventory records supported combinations, not a feature-equality checklist. +Do not silently drop options, fabricate measurements, weaken isolation or remove +working features. Unsupported operations stay explicit; implementing them is a +separate board decision, not an onboarding prerequisite. User-managed colocated Runtime enrollment is a +separate queued feature; it is not a substitute for official `self_hosted`. + +## Common contract acceptance + +The synthetic [third-harness fixture](../../apps/parsar-daemon/testdata/onboarding/main.go) +implements only the current text execution contract: cancellation, durable active +input receipts and strict bound-history continuation. It has no workspace, MCP, +public functions, permissions or user-choice handlers. Its registration is local +to the fixture; production builds never register it. + +`TestThirdHarnessPublicOnboarding` uses a custom immutable `engine.Catalog` in the +same `execution.Policy` supplied to both the API handler and Dispatcher. The zero +policy selects built-ins; an explicitly empty catalog authorizes no engines. +The test runs public Session/input admission, Worker device selection, the real +WebSocket gateway, daemon Registry/Router, neutral events and durable terminal +projection. It checks applied input receipts, saved native identity, continuation, +cancellation, unsupported optional requests and missing mandatory Runtime support. +This proves the integration path, not real native execution or sandbox security. +The existing internal registration functions suffice for this fixture; no dynamic +registry or global mutable test registration is required. + +The current text execution contract still requires durable input/Turn semantics, +ordered observations and enforcement of disabled execution controls. An adapter +without tools or subagents can guarantee their absence; it must not pretend to +apply unsupported requested behavior. Hosted qualification has additional workspace +and isolation obligations. These guarantees are independent of feature equality. + +## Native operation acceptance + +Use the pinned official Python SDK, raw HTTP and real model APIs. The common +`services/agents-api/tests/official_hosted_functions_native.py` assertions exercise +function success/error, native file output and public artifact bytes, same-history +continuation after restart, foreign result rejection and pending-call cancellation. +The operator fixture supplies only deployment/restart and model configuration; +public assertions are shared by adapters that support this operation. Existing workflow, file, artifact +and interruption fixtures remain applicable. Native isolation canaries supplement +these tests; synthetic responses alone do not establish live qualification. + +The 2026-09-19 candidate passed the same hosted-function assertions with Codex +0.153.4 and Claude SDK 0.3.269/native 2.1.269 using real Kimi K3. Each run used +an independent Core, dedicated Agents API database and Docker Runtime. Cold +restart acceptance restores Core before restarting Runtime; daemon startup while +Core is unavailable is not qualified by this test. Evidence is retained under +`~/.parsar/remediation/20260919/harness-parity/` on the validation server. + +The broader protocol inventory remains in [README.md](README.md). Passing one +profile or these shared assertions does not establish complete compatibility. diff --git a/contracts/agents-api/mcode-workspace-v1.md b/contracts/agents-api/mcode-workspace-v1.md new file mode 100644 index 000000000..45aaf3b99 --- /dev/null +++ b/contracts/agents-api/mcode-workspace-v1.md @@ -0,0 +1,158 @@ +# MiniMax Code workspace Runtime qualification + +Status: implementation, public qualification and independent review passed, +2026-09-20. Baseline main +`55502c87c3f722bbefc5449348309e3fcc03a7ee`. Board batch: +`MCODE-WORKSPACE-V1-001`, under `ENGINE-EXTENSIBILITY-001`. + +## Goal and boundaries + +Complete the requested MiniMax Code Docker workspace loop through the existing +Core/Runtime contract: create a Session, prepare its Environment, execute native +tools, upload/list workspace files, export immutable Artifacts, cancel, reconnect +and continue the exact native history. Prove independent deployment without Parsar. +PR #702 qualified text execution only; it does not complete this goal. + +Core owns the public protocol, authorization and durable resources. The Docker +Provider creates and reclaims one dedicated Runtime; daemon, native harness and +local tools share that Runtime. Native configuration and isolation belong in the +adapter. Reuse preparation ownership, localworkspace binding and common file and +export helpers. Do not add engine-name branches to public handlers, persistence or +scheduling, or implement another model/tool loop. + +This batch excludes E2B, public MCP/functions/Subagents, a general plugin or sandbox +framework, product migration and unrelated refactoring. Existing qualified Codex +and Claude behavior remains protected. Optional feature equality is not required; +the explicitly requested workspace and Files/Artifacts loop is required. + +## Design gate before hosted wiring + +The inspected native source is `MiniMax-AI/minimax-code` revision +`33b259bbbeb1c16433390869938191d09bdb0680`, package 0.4.12. A deterministic +Linux ACP probe returned a private synthetic canary through Read with sandboxing +enabled and `denyRead` configured. The disabled control also returned it. This +probe used no real secrets or real model and is not acceptance evidence. + +The native registry supplies sandbox operations to Bash only; native file tools +execute in the harness process that owns model configuration and history. Docker +alone does not separate those tools from that process's private data. + +A first prototype moved six native tools into an isolated worker. Ordinary tool +operations, disabled networking and detached-child cancellation passed in Docker, +as did real Kimi and MiniMax Write/Read/Bash calls. However, the native process +still auto-started a workspace `.mcp.json` command and followed a `CLAUDE.md` +symlink into private data. Both bypasses were reproduced with synthetic canaries. +Native pre-tool file capture also reads paths before tool execution. The registry +patch is therefore not an adequate authority boundary and will not be shipped. + +The revised candidate retains the published CLI and uses its existing MCP client. +The native process and ACP Session use one private control directory. A single +trusted stdio MCP bridge exposes the original Read, Write, Edit, Bash, Grep and +Glob implementations under workspace-prefixed names, using upstream JSON schemas. +The bridge runs tools in the upstream-vendored Linux sandbox. Only this isolated +worker receives the real workspace as its execution root. Automatic native +project loading remains confined to the private control directory; caller files +cannot configure native processes or redirect privileged instruction reads. + +This uses MCP as an adapter-internal transport, not public MCP feature admission. +Keep native model execution, ACP and history. Reuse native tools rather than +reimplement them. Workspace project instructions can be read through the isolated +tools; do not silently import them into the privileged control process. Preserve +accurate workspace guidance in the trusted tool descriptions. + +Before hosted wiring, prove the published CLI actually selects these tools, +rejects private-file reads, ignores workspace MCP/instruction canaries, enforces +network policy and settles all owned workers on cancellation or transport loss. +Repeat real-provider acceptance against this candidate; earlier prototype results +do not qualify it. If the bridge needs a new model loop or a general compatibility +framework, stop and reassess rather than weakening isolation. + +## Acceptance and stop conditions + +| Area | Required observable result | +| --- | --- | +| Shared lifecycle | Preparation consumes no model input; Start transfers ownership once; cancellation/release settle once after effects stop; events stay ordered. | +| Workspace | Real model reads uploaded input, edits/creates files and runs a native command in the bound workspace. | +| Files and Artifacts | Official SDK and raw HTTP verify actual content, path filtering and pagination; exports remain immutable and scoped to their owner. | +| Recovery | Reconnect and cold continuation retain exact native history and workspace; no duplicate execution; missing or foreign history rejects before model input. | +| Isolation | Tenant/auth checks, daemon/model credentials, native history, staging and cross-Session paths remain protected; tools cannot escape through filesystem or process aliases. | +| Network and cancellation | Each exposed network mode is enforced; cancellation leaves no late file writes or surviving detached tool children. | +| Deployment | Separate Core and execution database with Docker-managed Runtime, no Parsar service or product database dependency. | +| Validation | Targeted/race checks, real Kimi and MiniMax API runs, `make check`, applicable generated artifacts and a fresh independent Astra high blind review. | + +Synthetic fixtures may isolate failures but cannot replace real provider acceptance. +Record exact source/image versions, commands, results and unverified behavior. Keep +credentials in private operator files, outside commits and reports. New mechanisms +must address this batch's demonstrated functional or security risk. + +The batch ends when this loop passes and its bounded PR is merged. Record +nonblocking issues in the board without expanding the batch. Do not close the +parent protocol objective or claim complete official protocol compatibility. + +## Qualification evidence + +The initial qualification candidate uses published CLI 0.4.12, the source revision above, Node.js +22.23.1 and Docker image +`sha256:9bfcf2c3a1bcf2ebf844878897d561dacb6a525c938c5d059645d0f88e1fdb99`. +The npm registry still reported 0.4.12 as latest on 2026-09-20. The Core was built +with `scripts/build-agents-api.sh` and run from a source-free package with its own +PostgreSQL database and credentials. + +| Check | Result | +| --- | --- | +| Official SDK 3.13.0 and raw HTTP, real Kimi, standalone Docker workspace | Passed: Session admission/auth/tenant scope, Files upload/list/filter/order/pages, native input reads, both network policies, protected paths, Core restart, delayed Runtime reconnect, cancellation with no late writes. | +| Public Artifacts, real Kimi and MiniMax independently | Passed: binary/empty/nested/source-file exports, exact content, immutable versions, ownership, restart, cancelled-Turn exclusion, Environment expiry and orphan-free deletion. | +| Core SIGKILL during execution, real Kimi | Passed: failed Turn queries, no automatic/idempotent replay, stopped effects, exact history continuation, retained Items/Artifacts and foreign-Session isolation. | +| Runtime SIGKILL during execution, real MiniMax | Passed the same recovery checks, including continuation that did not execute the interrupted command again. | +| Lifecycle and repository checks | `make check`, targeted Go race tests and the existing published-CLI product ACP regression passed. `make openapi` produced no generated changes; no query/schema change requires sqlc regeneration. | + +Evidence is retained on `zju_a100_2` under +`~/.parsar/remediation/20260919/mcode-workspace/`: `public-hosted-latest.json`, +`public-artifacts-latest.json`, `public-minimax-artifacts-latest.json`, +`public-recovery-latest.json`, `public-minimax-recovery-diagnostic-latest.json`, +`race.log`, `make-check.log` and `product-native-regression.log`. These private +fixtures reuse the fixed official client and existing public Files/Artifacts +acceptance helpers; they do not substitute model responses. A locally cached base +image matching the pinned Node manifest was used with Docker's legacy builder; +only its temporary build context omitted BuildKit chmod syntax. + +Kimi's Runtime-kill continuation did not pass the no-repeat assertion: after a new +input, a new tool-call identity executed the interrupted command again. The +pre-input recovery and idempotency checks passed. Keep the failed evidence and +source inspection confirms ACP preserves native call IDs and the native loop +executes only calls from a fresh model response after the new input. This was a +new model-issued command, not automatic recovery replay. The passing MiniMax run +does not erase the Kimi failure. Model obedience is not an exactly-once execution +guarantee; no model-output patch or public command filter was added. + +The initial-image real MiniMax isolation probe also passed: workspace MCP and +instruction-symlink canaries did not affect the privileged process; native file +tools and `/proc` aliases could not read synthetic credentials/history; visible +process environments contained no privileged values. Missing and foreign native +Session loads rejected before any model request. All task-owned probe containers +were removed. Evidence: `final-isolation/docker-final-isolation-result.json`. + +The final candidate is +`sha256:da8ab6840fe33bf493771419d68fe271ad155ff34f2c258e43bd6cd0a79260ec`. +It directs native temporary files to the existing writable scratch directory and +includes the upstream tool license. The earlier image failed the same native +temporary-file regression; the final image passed under both network policies: +`mktemp`, Node temporary paths, 272 KB Bash output spill and native Read. Private +canaries remained unreadable, real private directories were unchanged, the control +directory stayed read-only, and network/seccomp restrictions remained enforced. +MCP cancellation and EOF stopped detached children with no delayed writes. + +On this final image, real MiniMax public Artifacts and both Core/Runtime SIGKILL +recovery suites passed with no cleanup errors. Full `make check`, focused race +tests and OpenAPI generation also passed after the change. The default full check +skips the opt-in packaged native test; both actual Docker runs above executed it. +The broader initial-image acceptance remains evidence for unchanged public wiring +and isolation, rather than a claim that every suite reran on the final image. +Final evidence: `public-minimax-recovery-latest.json`, +`public-minimax-artifacts-latest.json`, and `proof/temp-regression/`. + +A fresh independent GPT-6 Astra high review of the complete diff found no blocking +issues. Its independent mcode/CLI/engine/execution race tests and companion checks +passed; the reviewer did not rerun the full gate or real-provider deployment. +Those results above were run by the implementation owner. This qualification does +not establish complete protocol compatibility. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md new file mode 100644 index 000000000..0f9acb1e5 --- /dev/null +++ b/contracts/agents-api/model-execution.md @@ -0,0 +1,53 @@ +# Session model execution extension + +Core accepts optional top-level `x_agents_core.model_provider` on Session creation. +This is a Core extension, not part of the pinned upstream protocol. It supplies +execution input only: there is no Provider CRUD, catalog, model alias resolution or +product permission model in Core. + +```json +{ + "agent": {"model": "exact-provider-model", "x_agents_core": {"harness": "mcode"}}, + "environment": {"type": "openai_hosted"}, + "x_agents_core": { + "model_provider": { + "protocol": "anthropic", + "base_url": "https://provider.example/anthropic", + "api_key": "", + "context_window": 200000, + "max_output_tokens": 8000 + } + } +} +``` + +`protocol` is `anthropic` for Claude Code/MiniMax Code or `responses` for Codex. +The endpoint must use HTTPS without embedded credentials, a query or a fragment. +Keys must be nonempty, at most 16 KiB, and contain no NUL/CR/LF. Unknown fields and +unsupported protocol/Harness/environment combinations are rejected before creating +a Session. Context/output limits are optional nonnegative integers, with output no +larger than context; both must be positive for MiniMax Code. Use the actual model's +limits. Native provider availability is checked during execution, not by a new probe. +`agent.model` retains its exact meaning; this extension never changes model identity. + +The entire supplied configuration is frozen and encrypted in the Session creation +transaction, with a distinct credential-crypto purpose and tenant/Session binding. +Creation retries include this intent in their request hash; changing the key or +endpoint under the same idempotency key conflicts. Recovery reads the committed +Session before mutable Agent/template resolution. No public Session, Agent, +Environment, event or ordinary configuration contains the key. The top-level +extension is write-only and has no update endpoint. + +At dispatch, Core resolves its encrypted snapshot into the existing native adapter +options. It does not fall back to operator credentials when a snapshot is missing +or cannot decrypt. Omission preserves the existing operator-options behavior. +Core needs its configured credential encryption key to accept and resume these +Sessions; retaining the same key is required across restarts. Native harness homes +may contain private provider configuration under the existing qualified hosted +isolation rules; tools and public Files must not access those homes. This extension +does not qualify a new runtime placement or self-hosted credential path. + +Parsar manages its own workspace catalog and encrypted keys, sends this extension +only on the first Core Session request, and retains a private encrypted snapshot +for uncertain creation retries. Catalog updates and deletion affect new Sessions; +existing Sessions retain their original model, endpoint and key. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml new file mode 100644 index 000000000..9dd30cf08 --- /dev/null +++ b/contracts/agents-api/openapi.yaml @@ -0,0 +1,3977 @@ +basePath: /v1 +definitions: + v1.APIError: + properties: + code: + type: string + message: + type: string + param: + type: string + x-nullable: true + type: + type: string + required: + - code + - message + - type + type: object + v1.Agent: + properties: + id: + type: string + instructions: + type: string + x-nullable: true + model: + type: string + multi_agent: + $ref: '#/definitions/v1.MultiAgentConfig' + name: + type: string + x-nullable: true + reasoning: + $ref: '#/definitions/v1.Reasoning' + service_tier: + enum: + - auto + type: string + text: + $ref: '#/definitions/v1.TextConfig' + tools: + items: + type: object + type: array + x_agents_core: + allOf: + - $ref: '#/definitions/v1.AgentsCore' + x-nullable: true + required: + - id + - model + - multi_agent + - reasoning + - service_tier + - text + - tools + type: object + v1.AgentDeleted: + properties: + deleted: + enum: + - true + type: boolean + id: + type: string + object: + enum: + - agent.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.AgentsCore: + properties: + harness: + enum: + - codex + - claude_sdk + - mcode + type: string + required: + - harness + type: object + v1.CreateAgentRequest: + properties: + instructions: + type: string + x-nullable: true + metadata: + additionalProperties: + type: string + type: object + x-nullable: true + model: + type: string + multi_agent: + type: object + x-nullable: true + name: + maxLength: 128 + type: string + x-nullable: true + reasoning: + allOf: + - $ref: '#/definitions/v1.Reasoning' + x-nullable: true + service_tier: + enum: + - auto + - default + - flex + - priority + - fast + type: string + x-nullable: true + text: + allOf: + - $ref: '#/definitions/v1.SavedAgentTextInput' + x-nullable: true + tools: + items: + type: object + type: array + x-nullable: true + x_agents_core: + allOf: + - $ref: '#/definitions/v1.AgentsCore' + x-nullable: true + required: + - model + type: object + v1.CreateCredentialRequest: + properties: + auth: + $ref: '#/definitions/v1.StaticBearerCredentialInput' + name: + type: string + required: + - auth + - name + type: object + v1.CreateEventsRequest: + properties: + events: + items: + $ref: '#/definitions/v1.SessionInput' + type: array + required: + - events + type: object + v1.CreateSessionRequest: + properties: + agent: + $ref: '#/definitions/v1.InlineAgent' + agent_id: + type: string + environment: + $ref: '#/definitions/v1.Environment' + input: + description: |- + Input accepts a string or an ordered array of user InputMessage objects. + Omission and null create an idle Session; non-text content is not supported yet. + x-nullable: true + metadata: + additionalProperties: + type: string + type: object + x-nullable: true + stream: + default: false + type: boolean + vault_ids: + items: + type: string + type: array + x_agents_core: + $ref: '#/definitions/v1.SessionExecutionInput' + required: + - environment + type: object + v1.CreateVaultRequest: + properties: + metadata: + additionalProperties: + type: string + type: object + x-nullable: true + name: + type: string + type: object + v1.Credential: + properties: + auth: + $ref: '#/definitions/v1.StaticBearerCredentialAuth' + created_at: + type: integer + id: + type: string + name: + type: string + object: + enum: + - vault.credential + type: string + updated_at: + type: integer + vault_id: + type: string + required: + - auth + - created_at + - id + - name + - object + - updated_at + - vault_id + type: object + v1.CredentialDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - vault.credential.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.CredentialList: + properties: + data: + items: + $ref: '#/definitions/v1.Credential' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.Environment: + properties: + capability_directories: + items: + type: string + type: array + x-nullable: true + env: + additionalProperties: + type: string + type: object + x-nullable: true + environment_template_id: + type: string + files: + items: + type: object + type: array + x-nullable: true + network: + allOf: + - $ref: '#/definitions/v1.EnvironmentNetworkInput' + x-nullable: true + packages: + allOf: + - $ref: '#/definitions/v1.EnvironmentPackages' + x-nullable: true + setup_commands: + items: + type: object + type: array + x-nullable: true + skills: + items: + type: object + type: array + x-nullable: true + type: + enum: + - none + - self_hosted + - openai_hosted + type: string + workspace_directory: + type: string + required: + - type + type: object + v1.EnvironmentFile: + properties: + environment_id: + type: string + object: + enum: + - agent.environment.file + type: string + path: + type: string + size_bytes: + minimum: 0 + type: integer + required: + - environment_id + - object + - path + - size_bytes + type: object + v1.EnvironmentFileCreateRequest: + properties: + data: + type: string + file_id: + type: string + path: + type: string + type: + enum: + - inline + - file_id + type: string + required: + - path + - type + type: object + v1.EnvironmentFileList: + properties: + data: + items: + $ref: '#/definitions/v1.EnvironmentFile' + type: array + next: + type: string + x-nullable: true + required: + - data + type: object + v1.EnvironmentInfo: + properties: + files: + items: + type: object + type: array + id: + type: string + object: + enum: + - agent.environment + type: string + plugins: + items: + type: object + type: array + skills: + items: + type: object + type: array + status: + enum: + - pending + - connected + - disconnected + - expired + - failed + type: string + type: + enum: + - openai_hosted + - self_hosted + type: string + required: + - files + - id + - object + - plugins + - skills + - status + - type + type: object + v1.EnvironmentNetwork: + properties: + access: + enum: + - enabled + - disabled + - restricted + type: string + allowed_domains: + items: + type: string + type: array + required: + - access + - allowed_domains + type: object + v1.EnvironmentNetworkInput: + properties: + access: + enum: + - enabled + - disabled + - restricted + type: string + allowed_domains: + items: + type: string + type: array + x-nullable: true + required: + - access + type: object + v1.EnvironmentPackages: + properties: + npm: + items: + type: string + type: array + python: + items: + type: string + type: array + system: + items: + type: string + type: array + required: + - npm + - python + - system + type: object + v1.EnvironmentPackagesInput: + properties: + npm: + items: + type: string + type: array + x-nullable: true + python: + items: + type: string + type: array + x-nullable: true + system: + items: + type: string + type: array + x-nullable: true + type: object + v1.EnvironmentTemplate: + properties: + capability_directories: + items: + type: string + type: array + created_at: + type: integer + files: + items: + type: object + type: array + id: + type: string + name: + type: string + x-nullable: true + network: + $ref: '#/definitions/v1.EnvironmentNetwork' + object: + enum: + - agent.environment.template + type: string + packages: + $ref: '#/definitions/v1.EnvironmentPackages' + plugins: + items: + type: object + type: array + skills: + items: + type: object + type: array + updated_at: + type: integer + required: + - capability_directories + - created_at + - files + - id + - network + - object + - packages + - plugins + - skills + - updated_at + type: object + v1.EnvironmentTemplateDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - agent.environment.template.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.EnvironmentTemplateList: + properties: + data: + items: + $ref: '#/definitions/v1.EnvironmentTemplate' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.EnvironmentTemplateRequest: + properties: + capability_directories: + items: + type: string + type: array + x-nullable: true + env: + additionalProperties: + type: string + type: object + x-nullable: true + files: + items: + type: object + type: array + x-nullable: true + name: + type: string + x-nullable: true + network: + allOf: + - $ref: '#/definitions/v1.EnvironmentNetworkInput' + x-nullable: true + packages: + allOf: + - $ref: '#/definitions/v1.EnvironmentPackagesInput' + x-nullable: true + plugins: + items: + type: object + type: array + x-nullable: true + setup_commands: + items: + type: object + type: array + x-nullable: true + skills: + items: + type: object + type: array + x-nullable: true + type: object + v1.ErrorResponse: + properties: + error: + $ref: '#/definitions/v1.APIError' + required: + - error + type: object + v1.InlineAgent: + properties: + instructions: + type: string + x-nullable: true + model: + type: string + multi_agent: + type: object + x-nullable: true + reasoning: + allOf: + - $ref: '#/definitions/v1.Reasoning' + x-nullable: true + service_tier: + enum: + - auto + - default + - flex + - priority + - fast + type: string + x-nullable: true + text: + allOf: + - $ref: '#/definitions/v1.SavedAgentTextInput' + x-nullable: true + tools: + items: + type: object + type: array + x-nullable: true + x_agents_core: + allOf: + - $ref: '#/definitions/v1.AgentsCore' + x-nullable: true + type: object + v1.InputContent: + properties: + text: + type: string + type: + enum: + - input_text + type: string + required: + - text + - type + type: object + v1.InputMessage: + properties: + content: + items: + $ref: '#/definitions/v1.InputContent' + type: array + role: + enum: + - user + type: string + type: + enum: + - message + type: string + required: + - content + - role + type: object + v1.InputTokenDetails: + properties: + cached_tokens: + type: integer + required: + - cached_tokens + type: object + v1.Item: + properties: + action: + $ref: '#/definitions/v1.WebSearchAction' + arguments: {} + call_id: + type: string + command: + type: string + content: + items: + $ref: '#/definitions/v1.ItemContent' + type: array + cwd: + type: string + duration_ms: + type: integer + error: {} + exit_code: + type: integer + id: + type: string + name: + type: string + output: {} + phase: + enum: + - commentary + - final_answer + type: string + role: + enum: + - user + - assistant + type: string + server_label: + type: string + status: + enum: + - in_progress + - completed + - failed + - incomplete + type: string + turn_id: + type: string + type: + enum: + - message + - command_execution + - mcp_call + - function_call + - function_call_output + - web_search_call + type: string + required: + - id + - status + - turn_id + - type + type: object + v1.ItemContent: + properties: + image_url: + type: string + text: + type: string + type: + enum: + - input_text + - output_text + - input_image + type: string + required: + - type + type: object + v1.ItemList: + properties: + data: + items: + $ref: '#/definitions/v1.Item' + type: array + has_more: + type: boolean + required: + - data + - has_more + type: object + v1.ModelProviderInput: + properties: + api_key: + type: string + base_url: + type: string + context_window: + type: integer + max_output_tokens: + type: integer + protocol: + enum: + - anthropic + - responses + type: string + type: object + v1.MultiAgentConfig: + properties: + enabled: + type: boolean + max_concurrent_subagents: + type: integer + x-nullable: true + required: + - enabled + type: object + v1.OutputTokenDetails: + properties: + reasoning_tokens: + type: integer + required: + - reasoning_tokens + type: object + v1.Reasoning: + properties: + effort: + type: string + x-nullable: true + summary: + type: string + x-nullable: true + type: object + v1.RequiredAction: + properties: + arguments: {} + call_id: + type: string + environment_id: + type: string + name: + type: string + turn_id: + type: string + type: + enum: + - function_call + - environment_connection + type: string + required: + - type + type: object + v1.SavedAgent: + properties: + created_at: + type: integer + id: + type: string + instructions: + type: string + x-nullable: true + metadata: + additionalProperties: + type: string + type: object + model: + type: string + multi_agent: + $ref: '#/definitions/v1.MultiAgentConfig' + name: + type: string + x-nullable: true + object: + enum: + - agent + type: string + reasoning: + $ref: '#/definitions/v1.Reasoning' + service_tier: + enum: + - auto + - default + - flex + - priority + - fast + type: string + text: + $ref: '#/definitions/v1.SavedAgentText' + tools: + items: + type: object + type: array + updated_at: + type: integer + x_agents_core: + allOf: + - $ref: '#/definitions/v1.AgentsCore' + x-nullable: true + required: + - created_at + - id + - metadata + - model + - multi_agent + - object + - reasoning + - service_tier + - text + - tools + - updated_at + type: object + v1.SavedAgentList: + properties: + data: + items: + $ref: '#/definitions/v1.SavedAgent' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.SavedAgentText: + properties: + format: + $ref: '#/definitions/v1.SavedAgentTextFormat' + verbosity: + enum: + - low + - medium + - high + type: string + required: + - format + - verbosity + type: object + v1.SavedAgentTextFormat: + properties: + schema: + type: object + type: + enum: + - text + - json_schema + type: string + required: + - type + type: object + v1.SavedAgentTextInput: + properties: + format: + type: object + x-nullable: true + verbosity: + enum: + - low + - medium + - high + type: string + x-nullable: true + type: object + v1.Session: + properties: + agent: + $ref: '#/definitions/v1.Agent' + created_at: + type: integer + environment: + $ref: '#/definitions/v1.SessionEnvironment' + error: + type: string + x-nullable: true + id: + type: string + last_active_at: + type: integer + metadata: + additionalProperties: + type: string + type: object + object: + enum: + - agent.session + type: string + required_actions: + items: + $ref: '#/definitions/v1.RequiredAction' + type: array + status: + enum: + - idle + - in_progress + - requires_action + - failed + type: string + usage: + allOf: + - $ref: '#/definitions/v1.TokenUsage' + x-nullable: true + vault_ids: + items: + type: string + type: array + required: + - agent + - created_at + - environment + - id + - last_active_at + - metadata + - object + - required_actions + - status + - vault_ids + type: object + v1.SessionArtifact: + properties: + created_at: + type: integer + environment_id: + type: string + id: + type: string + object: + enum: + - agent.session.artifact + type: string + path: + type: string + session_id: + type: string + size_bytes: + type: integer + turn_id: + type: string + required: + - created_at + - environment_id + - id + - object + - path + - session_id + - size_bytes + - turn_id + type: object + v1.SessionArtifactDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - agent.session.artifact.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.SessionArtifactList: + properties: + data: + items: + $ref: '#/definitions/v1.SessionArtifact' + type: array + has_more: + type: boolean + required: + - data + - has_more + type: object + v1.SessionDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - agent.session.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.SessionEnvironment: + properties: + capability_directories: + items: + type: string + type: array + files: + items: + type: object + type: array + id: + type: string + network: + $ref: '#/definitions/v1.EnvironmentNetwork' + packages: + $ref: '#/definitions/v1.EnvironmentPackages' + plugins: + items: + type: object + type: array + remote_url: + type: string + skills: + items: + type: object + type: array + type: + enum: + - none + - self_hosted + - openai_hosted + type: string + workspace_directory: + type: string + required: + - type + type: object + v1.SessionEnvironmentState: + properties: + error: + allOf: + - $ref: '#/definitions/v1.StreamError' + x-nullable: true + id: + type: string + status: + enum: + - pending + - ready + - connected + - disconnected + - failed + type: string + type: + type: string + required: + - id + - status + - type + type: object + v1.SessionEvent: + properties: + content_index: + type: integer + delta: + type: string + environment: + $ref: '#/definitions/v1.SessionEnvironmentState' + error: + $ref: '#/definitions/v1.StreamError' + event_id: + type: string + item: + $ref: '#/definitions/v1.Item' + item_id: + type: string + output_index: + type: integer + part: + $ref: '#/definitions/v1.ItemContent' + session: + $ref: '#/definitions/v1.Session' + session_id: + type: string + text: + type: string + turn: + $ref: '#/definitions/v1.Turn' + turn_id: + type: string + type: + type: string + required: + - event_id + - type + type: object + v1.SessionExecutionInput: + properties: + model_provider: + $ref: '#/definitions/v1.ModelProviderInput' + type: object + v1.SessionInput: + properties: + call_id: + type: string + error: + type: string + x-nullable: true + input: + items: + $ref: '#/definitions/v1.InputMessage' + type: array + output: + x-nullable: true + success: + type: boolean + turn_id: + type: string + type: + enum: + - agent.session.input.message + - agent.session.input.cancel + - agent.session.input.tool_result + type: string + required: + - type + type: object + v1.SessionList: + properties: + data: + items: + $ref: '#/definitions/v1.Session' + type: array + has_more: + type: boolean + required: + - data + - has_more + type: object + v1.SourceFile: + properties: + bytes: + minimum: 0 + type: integer + created_at: + type: integer + expires_at: + type: integer + x-nullable: true + filename: + type: string + id: + type: string + object: + enum: + - file + type: string + purpose: + enum: + - user_data + type: string + status: + enum: + - processed + type: string + status_details: + type: string + x-nullable: true + required: + - bytes + - created_at + - filename + - id + - object + - purpose + - status + type: object + v1.SourceFileDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - file + type: string + required: + - deleted + - id + - object + type: object + v1.SourceFileList: + properties: + data: + items: + $ref: '#/definitions/v1.SourceFile' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.StaticBearerCredentialAuth: + properties: + mcp_server_url: + type: string + type: + enum: + - static_bearer + type: string + required: + - mcp_server_url + - type + type: object + v1.StaticBearerCredentialInput: + properties: + mcp_server_url: + type: string + token: + type: string + type: + enum: + - static_bearer + type: string + required: + - mcp_server_url + - token + - type + type: object + v1.StaticBearerCredentialReplacement: + properties: + token: + type: string + type: + enum: + - static_bearer + type: string + required: + - token + - type + type: object + v1.StreamError: + properties: + code: + type: string + message: + type: string + type: + type: string + type: object + v1.TextConfig: + properties: + format: + $ref: '#/definitions/v1.TextFormat' + verbosity: + enum: + - low + - medium + - high + type: string + required: + - format + - verbosity + type: object + v1.TextFormat: + properties: + type: + enum: + - text + type: string + required: + - type + type: object + v1.TokenUsage: + properties: + input_tokens: + type: integer + input_tokens_details: + $ref: '#/definitions/v1.InputTokenDetails' + output_tokens: + type: integer + output_tokens_details: + $ref: '#/definitions/v1.OutputTokenDetails' + total_tokens: + type: integer + required: + - input_tokens + - input_tokens_details + - output_tokens + - output_tokens_details + - total_tokens + type: object + v1.Turn: + properties: + agent_id: + type: string + completed_at: + type: integer + x-nullable: true + created_at: + type: integer + error: + allOf: + - $ref: '#/definitions/v1.TurnError' + x-nullable: true + id: + type: string + object: + enum: + - agent.session.turn + type: string + session_id: + type: string + started_at: + type: integer + x-nullable: true + status: + enum: + - queued + - in_progress + - waiting + - completed + - failed + - cancelled + type: string + usage: + allOf: + - $ref: '#/definitions/v1.TokenUsage' + x-nullable: true + required: + - agent_id + - created_at + - id + - object + - session_id + - status + type: object + v1.TurnError: + properties: + code: + enum: + - internal_error + type: string + message: + type: string + required: + - code + - message + type: object + v1.TurnList: + properties: + data: + items: + $ref: '#/definitions/v1.Turn' + type: array + has_more: + type: boolean + required: + - data + - has_more + type: object + v1.UpdateAgentRequest: + properties: + instructions: + type: string + x-nullable: true + metadata: + additionalProperties: + type: string + type: object + x-nullable: true + model: + type: string + multi_agent: + type: object + x-nullable: true + name: + maxLength: 128 + type: string + x-nullable: true + reasoning: + allOf: + - $ref: '#/definitions/v1.Reasoning' + x-nullable: true + service_tier: + enum: + - auto + - default + - flex + - priority + - fast + type: string + x-nullable: true + text: + allOf: + - $ref: '#/definitions/v1.SavedAgentTextInput' + x-nullable: true + tools: + items: + type: object + type: array + x-nullable: true + x_agents_core: + allOf: + - $ref: '#/definitions/v1.AgentsCore' + x-nullable: true + type: object + v1.UpdateCredentialRequest: + properties: + auth: + $ref: '#/definitions/v1.StaticBearerCredentialReplacement' + required: + - auth + type: object + v1.UpdateSessionRequest: + properties: + metadata: + additionalProperties: + type: string + type: object + x-nullable: true + type: object + v1.Vault: + properties: + created_at: + type: integer + id: + type: string + metadata: + additionalProperties: + type: string + type: object + name: + type: string + x-nullable: true + object: + enum: + - vault + type: string + required: + - created_at + - id + - metadata + - object + type: object + v1.VaultDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - vault.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.VaultList: + properties: + data: + items: + $ref: '#/definitions/v1.Vault' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.WebSearchAction: + properties: + pattern: + type: string + queries: + items: + type: string + type: array + query: + type: string + type: + enum: + - search + - open_page + - find_in_page + - other + type: string + url: + type: string + required: + - type + type: object +info: + contact: {} + description: Supported single-Agent execution resources from the pinned openai-python + beta/agents contract. Bearer keys bind an execution principal to one project; + optional OpenAI-Organization and OpenAI-Project headers must match that binding. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + title: Agents API + version: "1" +paths: + /agents: + get: + description: Lists only the authenticated tenant's saved Agents, independently + of Sessions. Positive int64 limits are accepted; each page returns at most + 100 resources with continuation. The local default is 20; exact upstream default/cap, + empty cursor fields and error conformance remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Last Agent ID from the previous page + in: query + name: after + type: string + - description: Maximum requested resources; pages contain at most 100 + in: query + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SavedAgentList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List reusable Agents + tags: + - Agents + post: + consumes: + - application/json + description: 'Persists configuration independently of execution. Supports model/name/instructions/metadata, + explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling + and HTTP MCP with nullable credential_id and explicit service origin and boolean + required defaulting to false. Saving credential_id grants no access: Session + admission checks attached Vault ownership and destination. MCP allowed_tools + preserves null versus empty; saved HTTP transport includes empty headers. + Model-derived reasoning defaults, other MCP variants, web_search and public + retry conformance remain incomplete. Session execution admits only its supported + configuration subset.' + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Reusable Agent configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateAgentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SavedAgent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a reusable Agent + tags: + - Agents + /agents/{agent_id}: + delete: + description: Deletes only the authenticated tenant's saved configuration. Existing + Session snapshots, history and recorded creation retry identities remain independent. + Missing and repeated deletion locally return404; exact hosted error and in-flight + creation/deletion semantics remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Agent ID + in: path + name: agent_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.AgentDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a reusable Agent + tags: + - Agents + get: + description: Reads the saved resource owned by the authenticated tenant, independently + of execution Sessions. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Agent ID + in: path + name: agent_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SavedAgent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve a reusable Agent + tags: + - Agents + post: + consumes: + - application/json + description: Preserves omitted fields and replaces supplied fields using shared + saved-configuration validation. Null name/instructions clear; null or empty + metadata clears all pairs. Existing Session snapshots are unchanged. Nested + replacement/null defaults, model-derived reasoning and exact hosted error/no-op + timestamp behavior remain incompletely verified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Agent ID + in: path + name: agent_id + required: true + type: string + - description: Supplied reusable Agent fields + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpdateAgentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SavedAgent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Update a reusable Agent + tags: + - Agents + /agents/environments/{environment_id}: + get: + description: Returns durable connection status and safe installed metadata for + supported self_hosted and basic openai_hosted profiles. Initial files expose + frozen safe metadata without content; empty plugins/skills describe the absence + of API-managed installations, not the contents or discovered capabilities + of the caller's machine. Unsupported installation configurations remain implementation + gaps. This read does not prepare execution, start compute or require an enabled + execution worker. Session deletion removes the associated Environment from + public reads; project-shared read authorization is unchanged. Connection status + does not prove native readiness or process quiescence. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Environment ID + in: path + name: environment_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentInfo' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve an execution Environment + tags: + - Environments + /agents/environments/{environment_id}/files: + get: + description: Lists direct regular files in one authorized self_hosted or qualified + local workspace directory. Local paths use the public /workspace root. This + partial implementation defaults to the workspace root and limit 20; recursive + scope, directory/symlink treatment and these defaults are not verified upstream + semantics. Sorts by case-sensitive path components, descending by default. + Keep the same path, order and limit when using page. Each page rereads the + complete bounded directory; changed file paths/sizes invalidate continuation + locally with 400. There is no snapshot guarantee. Truncated or uncertain native + results fail with 503 without returning a partial page. This read never starts + a Turn or admits model input. Actual transport disconnect/reconnect events + remain observable. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Environment ID + in: path + name: environment_id + required: true + type: string + - description: Absolute directory inside the Environment workspace + in: query + name: path + type: string + - description: Maximum file count; local default 20 + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Case-sensitive path-component order + enum: + - asc + - desc + in: query + name: order + type: string + - description: Opaque continuation token; keep path, order and limit unchanged + in: query + name: page + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentFileList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List live Environment files + tags: + - Environments + post: + consumes: + - application/json + description: Uploads standard Base64 bytes to a file beneath /workspace in a + qualified local Environment. Accepts inline bytes or a project-owned source + file_id through the same write path. Basic public hosted creation requires + explicit managed Runtime configuration. A private 50 MiB decoded-content limit + applies. The parent directory must exist. Replacement installs a new mode-0600 + inode; upstream overwrite metadata semantics remain unverified. Idle writes + exclude execution. Missing receipts return unavailable and retain a durable + mutation gate without automatic replay. Error/timing parity with upstream + remains unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Environment ID + in: path + name: environment_id + required: true + type: string + - description: Inline bytes or source file ID and absolute workspace path + in: body + name: request + required: true + schema: + $ref: '#/definitions/v1.EnvironmentFileCreateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentFile' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create an Environment file from inline bytes or a source file + tags: + - Environments + /agents/environments/templates: + get: + description: Lists tenant-owned safe template metadata in creation order with + ID tie-breaking. Defaults to limit 20 and descending order; limit must be + 1–100. Foreign and missing cursors reject identically. Concurrent-page and + exact hosted error behavior remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Previous Template ID + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplateList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List Environment Templates + tags: + - Environment Templates + post: + consumes: + - application/json + description: Saves tenant-owned basic hosted configuration. Supports nullable + name, enabled/disabled network, initial inline/file_id files, confidential + env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. + Omitted/null network defaults to enabled. Other populated installations and + restricted network are rejected before persistence without echoing input. + No compute is allocated. Exact hosted error/retry semantics remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Reusable configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.EnvironmentTemplateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create an Environment Template + tags: + - Environment Templates + /agents/environments/templates/{environment_template_id}: + delete: + description: Deletes the tenant-owned reusable configuration without changing + or deleting existing Sessions and their frozen configuration. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplateDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete an Environment Template + tags: + - Environment Templates + get: + description: Returns safe tenant-owned configuration metadata without allocating + compute. Missing and foreign resources return the same not-found response. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve an Environment Template + tags: + - Environment Templates + post: + consumes: + - application/json + description: Supplied fields replace atomically; omitted fields remain unchanged. + Null name clears and null network resets to the pinned enabled default. Existing + Session snapshots and creation retries remain unchanged. Initial files replace + as a list; null/empty clears. File data is encrypted separately and excluded + from response metadata. Skills replace as a list; null/empty clears. Skill + archives are encrypted separately and omitted from responses. Other populated + installations are unsupported. Exact hosted no-op timestamp behavior remains + unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + - description: Configuration replacements + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.EnvironmentTemplateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Update an Environment Template + tags: + - Environment Templates + /agents/sessions: + get: + description: Cursor and results are scoped to the authenticated execution tenant. + Optional agent_id matches the immutable root Agent ID, including inline Agents + and historical Sessions whose saved source was updated or deleted. Omission + lists all Agents. Returns the same Environment and pending-input activity + projection as Session retrieval, including self_hosted Sessions. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Root Agent ID whose Sessions to return + in: query + name: agent_id + type: string + - description: Last Session ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List execution Sessions + tags: + - Sessions + post: + consumes: + - application/json + description: Supports inline configuration or a tenant-owned saved agent_id + with per-Session field replacements. Execution supports model/instructions, + text verbosity, non-deferred function tools, disabled multi_agent, implicit + reasoning, service tier auto and environment type none, subject to the configured + engine. Codex additionally supports HTTP MCP with explicit service origin, + native allowed_tools and boolean required defaulting to false. Session vault_ids + attach only project-owned Vaults; credential_id selects an attached static + bearer credential for the exact HTTPS URL, while null/omission selects a unique + match or remains anonymous. Ambiguous selection rejects creation. Frozen private + selections never populate an omitted public credential_id; missing decryption + configuration fails dispatch without anonymous fallback. Required initialization + uses native startup before the first native Turn, including cold resume, and + requires a separately advertised capability; exact hosted creation timing + and error parity remain unverified. Other MCP origins and OAuth remain unsupported. + The self_hosted profile requires Codex, an absolute workspace_directory and + empty capability_directories, with optional non-deferred function tools and + HTTP MCP using explicit service origin, optionally authenticated by the attached + Vault rules. Remote MCP and remote Bearer authentication each require separately + advertised combination support; old peers cannot receive unsupported work. + Omitted/null capability_directories use the empty-list default; self_hosted + requires configured execution plus executor registry. Claude SDK currently + requires medium verbosity and object-root function schemas. It supports anonymous + or attached static-bearer service-origin HTTP MCP on none with boolean required + and separately advertised MCP/bearer/required runtime support. Required servers + must be connected before the first native input is released; pending or failed + startup rejects execution. The shared Vault selection and immutable binding + rules apply; unsupported native labels/tool names reject before persistence. + An attached Vault with no matching credential may remain anonymous; missing + keys or failed credential lookup/decryption never fall back to anonymous execution. + Omitted stream defaults to false; stream and agent_id cannot be null. Metadata + may be null, but its values must be strings. Initial input accepts a string + or user-message array containing text. None initial input atomically starts + a Turn; self_hosted initial input is reserved while returning its Environment + connection target, with execution deferred to native readiness and Session + failure on initial timeout. Omitted or null input creates an idle Session. + With stream=true, returns live Session events starting at creation; disconnect + does not cancel execution. New Sessions retain their authenticated creator; + all creation retries require the same typed subject, including across key + rotation. Saved-Agent retries and inline requests using Vault attachments + or credential references retain caller intent independently of later resource + changes; unrelated inline retries preserve resolved/default equivalences. + Unknown historical creators reject retries; known creators without recorded + intent retain resolved-snapshot retry rules. These conflict policies are local + and not verified hosted parity. Creation retries observe future events without + replay; retry with stream=false to retrieve the Session. Non-text initial + input remains unsupported. Basic Codex and Claude SDK openai_hosted creation + requires an explicitly configured managed provider. The Claude workspace profile + supports non-deferred function tools with text results alongside native workspace + tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; + initial provisioning has no caller connection action. Network defaults to + enabled; disabled is also supported, while restricted domains and remaining + unsupported startup installations are rejected. Confidential env, system/npm/Python + packages and ordered setup commands use the shared initialization lifecycle; + requested network applies after setup. Initial inline and tenant-owned file_id + files freeze encrypted bytes before provisioning, then install through the + common Core lifecycle before native execution or live Files access. Referenced + files/env/packages/setup overrides are rejected pending semantic verification. + Tenant-owned environment_template_id references inherit omitted network and + allow only narrowing overrides. Referenced network:null is explicitly unsupported + pending semantic verification. Core freezes effective configuration; template + updates/deletion do not alter Session snapshots or same-intent creation retries. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Creation retry key, up to 128 bytes + in: header + name: Idempotency-Key + type: string + - description: Session configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateSessionRequest' + produces: + - application/json + - text/event-stream + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Session' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create an execution Session + tags: + - Sessions + /agents/sessions/{session_id}: + delete: + description: Removes the Session and its history from the public API. Active + work receives a cancellation request; confirmation does not guarantee native + execution has stopped. Internal records and native history are retained pending + separate physical cleanup. Missing/repeated deletion locally returns 404; + exact hosted errors and overlapping stream timing remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete an execution Session + tags: + - Sessions + get: + description: Returns supported none, self_hosted and basic openai_hosted Session + environments. Self-hosted pending input can require a caller connection before + a Turn exists. Hosted initial provisioning remains idle until a Turn starts; + connection observations are not native execution readiness. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Session' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve an execution Session + tags: + - Sessions + post: + consumes: + - application/json + description: Omit metadata to leave it unchanged, send null or {} to clear it, + or supply an object to replace all pairs. Up to 16 string pairs, with keys + at most 64 characters and values at most 512 characters. Execution configuration + and activity are unchanged. Returns the same safe Environment and pending-input + activity projection as Session retrieval. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Session metadata + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpdateSessionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Session' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Update execution Session metadata + tags: + - Sessions + /agents/sessions/{session_id}/artifacts: + get: + description: Lists published outputs independently of Environment availability. + Sorting uses publication time and ID. The local default page size is 20; exact + upstream defaults and error parity remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Producing Environment ID + in: query + name: environment_id + type: string + - description: Last immutable artifact ID + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Publication order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionArtifactList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List immutable Session artifacts + tags: + - Artifacts + /agents/sessions/{session_id}/artifacts/{artifact_id}: + delete: + description: Deletes the published copy without modifying its original workspace + file. Already admitted content reads may finish; later reads reject. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Artifact ID + in: path + name: artifact_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionArtifactDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a published artifact + tags: + - Artifacts + get: + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Artifact ID + in: path + name: artifact_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionArtifact' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve immutable artifact metadata + tags: + - Artifacts + /agents/sessions/{session_id}/artifacts/{artifact_id}/content: + get: + description: Streams stored bytes after tenant and Session authorization, including + after Environment expiration. Exact upstream headers and Range behavior remain + unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Artifact ID + in: path + name: artifact_id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Download immutable artifact bytes + tags: + - Artifacts + /agents/sessions/{session_id}/events: + get: + description: Live-only events, including command output fragments from capable + Codex peers as agent.output.command_execution_output.delta with stable Item/output + indexes. Native text conversion and output quotas apply; completion snapshots + remain authoritative. Reconnect through Session, Turn and Items reads; missed + events are not replayed. A lagging stream closes with an error when its bounded + buffer is exceeded. Session activity includes immutable pending-input connection + actions before Turn creation; self_hosted environments use the same safe output + as Session retrieval. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + produces: + - text/event-stream + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionEvent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Stream live Session events + tags: + - Events + post: + consumes: + - application/json + description: For environment none, atomically accepts text messages, cancellation + and function results. Messages steer active work or start a queued Turn. The + supported self_hosted and openai_hosted profiles accept text-only batches. + Under the Session lock, matching retries retain their original target; new + active messages append to the current Turn, while idle messages reserve work + and wait up to the original five-minute connection/admission deadline. Return + 204 only after durable admission, without claiming native application; active + messages create no Turn or reservation. Cancellation-only prepared-environment + batches use existing durable cancellation admission and return 204 without + waiting for native exit; a new cancellation conflicts while a pre-Turn reservation + is pending. Homogeneous tool_result-only prepared-environment batches reuse + existing scoped result admission and application receipts without creating + a Turn or bypassing a pending reservation. Mixed prepared-environment batches + remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled + errors; exact hosted failure mapping is unverified. Losing execution ownership + returns 503. The response write deadline accommodates the admission window + for either prepared Environment, independently of new-hosted-admission and + executor URL settings. Disconnecting the waiting HTTP request does not cancel + retained work or restart its deadline. Retry keys identify the whole ordered + batch. Function output accepts text or ordered text/image parts subject to + engine support; Claude SDK currently accepts text results only. Message images + are not supported yet. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Retry key, up to 128 bytes + in: header + name: Idempotency-Key + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Ordered input events + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateEventsRequest' + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Submit Session input events + tags: + - Sessions + /agents/sessions/{session_id}/items: + get: + description: Returns supported message and tool Items in first-observation order. + Native engine fields are projected explicitly; unfinished Items on terminal + Turns are incomplete. Cursors belong to the same tenant and Session. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Last Item ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.ItemList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List persisted execution Items + tags: + - Items + /agents/sessions/{session_id}/turns: + get: + description: Returns persisted state in creation order. The cursor belongs to + the same Session and tenant. Usage contains the latest recorded complete token + breakdown; missing measurements remain null. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Last Turn ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.TurnList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List execution Turns + tags: + - Turns + /agents/sessions/{session_id}/turns/{turn_id}: + get: + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Turn ID + in: path + name: turn_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Turn' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve an execution Turn + tags: + - Turns + /files: + get: + description: Lists project-owned Files without reading their bodies. Supports + the pinned after, limit, order and purpose query surface. The limit defaults + to 10000 and must be 1–10000. Equal creation times use ID ordering. Current + storage contains only user_data; exact hosted default order, invalid-cursor + errors and concurrent-page behavior remain unverified. No Beta header is required. + parameters: + - description: Last File ID from the previous page + in: query + name: after + type: string + - default: 10000 + description: Maximum page size, 1–10000 + in: query + maximum: 10000 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + - description: Only return Files with this purpose + in: query + name: purpose + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SourceFileList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List source files + tags: + - Files + post: + consumes: + - multipart/form-data + description: Accepts one multipart file and purpose=user_data in either order, + with a private 512 MiB content limit and 64 KiB envelope allowance. Commits + only after the entire request validates. The source is project-owned, independent + of Sessions and workspace copies. No Beta header is required. Other purposes, + expires_after, listing, resumable Uploads, quotas/rate-limit and complete + hosted error/status parity remain unsupported or unverified. + parameters: + - description: Source bytes + in: formData + name: file + required: true + type: file + - description: user_data + enum: + - user_data + in: formData + name: purpose + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SourceFile' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Upload a source file + tags: + - Files + /files/{file_id}: + delete: + description: Atomically deletes project-owned metadata and stored bytes. Already-admitted + reads or copies may finish. Workspace copies remain independent. Historical + WAL/backups are not erased. No Beta header is required; exact hosted concurrent + deletion/error semantics remain unverified. + parameters: + - description: Source file ID + in: path + name: file_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SourceFileDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a source file + tags: + - Files + get: + description: Returns immutable project-owned user_data file metadata. No Beta + header is required. Other purposes, expiration and full hosted status/error + semantics remain unimplemented or unverified. + parameters: + - description: Source file ID + in: path + name: file_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SourceFile' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve source file metadata + tags: + - Files + /files/{file_id}/content: + get: + description: Streams an authorized immutable source snapshot. Already-admitted + reads may finish after deletion; later reads reject. No Beta header is required. + Range requests and exact hosted headers/error behavior are not implemented + or verified. + parameters: + - description: Source file ID + in: path + name: file_id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Download source file bytes + tags: + - Files + /vaults: + get: + description: Lists project-owned Vaults independently of execution. Includes + active and archived records by default. Status accepts a scalar or the SDK's + status[] array; mixed encodings and repeated scalars are rejected locally. + Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; + exact hosted errors and concurrent-page behavior remain unverified. Archive/delete + lifecycle is not implemented. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Last Vault ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Requested page size, clamped to 1–100 + in: query + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + - description: Scalar status filter + enum: + - active + - archived + in: query + name: status + type: string + - collectionFormat: multi + description: Array status filter; cannot be combined with status + in: query + items: + enum: + - active + - archived + type: string + name: status[] + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.VaultList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List Vaults + tags: + - Vaults + post: + consumes: + - application/json + description: Creates a project-owned Vault independently of execution. Omitted + name stays null; a supplied string is trimmed and must contain 1–256 UTF-8 + bytes. Explicit null name is invalid. Omitted/null metadata becomes an empty + object; values must be strings. Metadata has a local 64 KiB encoded storage + bound. Credentials, Session binding and hosted error/retry parity remain incomplete. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault name and metadata + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateVaultRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Vault' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a Vault + tags: + - Vaults + /vaults/{vault_id}: + delete: + description: Atomically removes the authenticated project's Vault and all its + stored Credentials without an encryption key, decryption or external requests. + Existing Session snapshots, history and recorded retries retain their frozen + identities; subsequent credential lookups fail without reselection or anonymous + fallback. Already-resolved tokens and running Sessions are not revoked or + cancelled. Missing/repeated deletion locally returns 404. Exact hosted archive, + post-delete visibility and concurrent/error semantics remain unverified; physical + erasure from native history, WAL or backups is not established. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.VaultDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a Vault and all its Credentials + tags: + - Vaults + get: + description: Reads a Vault owned by the authenticated project without resolving + credentials, Sessions or execution devices. Missing and foreign IDs share + the same not-found response; exact hosted error semantics remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Vault' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve a Vault + tags: + - Vaults + /vaults/{vault_id}/credentials: + get: + description: Lists only metadata from the authenticated project's requested + Vault, without decryption or execution. Includes active and archived Credentials + by default, independently of Vault status. Status accepts a scalar or SDK + status[] array; mixed encodings and repeated scalars are rejected locally. + Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering. + Hosted errors, concurrent-page behavior and archive/delete lifecycle remain + unverified or unimplemented. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + - description: Last Credential ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Requested page size, clamped to 1–100 + in: query + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + - description: Scalar status filter + enum: + - active + - archived + in: query + name: status + type: string + - collectionFormat: multi + description: Array status filter; cannot be combined with status + in: query + items: + enum: + - active + - archived + type: string + name: status[] + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.CredentialList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List safe Vault Credential metadata + tags: + - Credentials + post: + consumes: + - application/json + description: Stores the write-only token as execution-owned authenticated ciphertext. + Required name is trimmed to 1–256 UTF-8 bytes; auth requires static_bearer, + an HTTPS mcp_server_url and a string token. Token bytes are preserved, including + empty strings; hosted token edge validation is unverified. The initial URL + profile excludes userinfo and fragments, preserves queries and makes no network + request. Public responses contain only safe metadata. Missing encryption configuration + returns local 503. Session admission can bind static credentials from attached + Vaults to exact HTTPS MCP destinations; secret decryption occurs only at dispatch. + OAuth, storage-key rotation and exact hosted error/retry semantics remain + gaps. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + - description: Write-only static bearer credential + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateCredentialRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Credential' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a static-bearer Vault Credential + tags: + - Credentials + /vaults/{vault_id}/credentials/{credential_id}: + delete: + description: Removes one Credential and its encrypted token within the authenticated + project and owning Vault, without an encryption key or secret decryption. + Subsequent metadata reads, updates and dispatch lookups cannot use it. Existing + Session snapshots and history retain their frozen identities; already-resolved + tokens and running Sessions are not revoked or cancelled. This local policy + removes the row rather than defining archived lifecycle; missing/repeated + deletion returns 404. Exact hosted archive, post-delete visibility and retry/error + semantics remain unverified. Provider revocation and physical erasure from + native history, WAL or backups are separate concerns. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + - description: Credential ID + in: path + name: credential_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.CredentialDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a Vault Credential + tags: + - Credentials + get: + description: Reads only non-secret metadata scoped to the authenticated project + and owning Vault. No token decryption, network request or execution is performed. + Unknown, foreign, wrong-Vault and malformed IDs use the same local not-found + response; hosted error parity remains unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + - description: Credential ID + in: path + name: credential_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Credential' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve safe Vault Credential metadata + tags: + - Credentials + post: + consumes: + - application/json + description: Requires auth with type=static_bearer and a string token; empty + and opaque token bytes are preserved. Only the write-only secret and updated_at + change, atomically within the authenticated project and owning Vault. Identity, + name, auth type, exact destination, created_at and Session bindings remain + unchanged. Responses contain only safe metadata; no old-token decryption or + network call occurs. Missing encryption configuration returns local 503 without + modifying the credential. Subsequent dispatch reads use the committed replacement; + already-resolved requests may retain the old token. OAuth, storage-key rotation, + hot reload/revocation and exact hosted concurrent-update/retry/timestamp semantics + remain gaps. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Vault ID + in: path + name: vault_id + required: true + type: string + - description: Credential ID + in: path + name: credential_id + required: true + type: string + - description: Write-only static bearer replacement + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpdateCredentialRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Credential' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Replace a static-bearer Vault Credential token + tags: + - Credentials +schemes: +- http +- https +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/contracts/agents-api/source-files.md b/contracts/agents-api/source-files.md new file mode 100644 index 000000000..ef809d1bc --- /dev/null +++ b/contracts/agents-api/source-files.md @@ -0,0 +1,86 @@ +# Referenced source Files + +Environment `file_id` refers to a general Files API upload, not a local path or +an external provider's file. The contract uses the same SDK/source pin as +[upstream.json](upstream.json): [Files resource](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py), +[create parameters](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/file_create_params.py) +and [FileObject](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/types/file_object.py). + +## Implemented source workflow + +| Operation | Current behavior | +| --- | --- | +| `POST /files` | Multipart `file` and `purpose=user_data`; either part order; immutable bytes and metadata commit after full validation | +| `GET /files` | Project-scoped metadata listing with `after`, `limit`, `order` and `purpose`; deterministic creation-time/ID keysets | +| `GET /files/{id}` | Project-owned metadata, without reading the body | +| `GET /files/{id}/content` | Immutable binary stream with declared length; incomplete transfer aborts rather than returning a JSON error as file content | +| `DELETE /files/{id}` | Atomic metadata removal and body unlink; `id`, `object: file`, `deleted: true` | + +The configured SDK base URL includes `/v1`. These routes reuse bearer and optional +organization/project header validation but do not require `OpenAI-Beta`. Existing +Agents/Vault routes retain their Beta check. User and service-account keys in the +same configured project share the source resource. Every read/delete/copy lookup +uses that project partition; missing and foreign IDs return the same safe 404. + +Listing defaults to 10,000 resources and rejects limits outside the pinned +1–10,000 range. Omitted order uses descending creation order; `asc` and `desc` +use the stored timestamp plus ID as a deterministic keyset. The response includes +`object`, `data`, `first_id`, `last_id` and `has_more`; empty pages use null IDs. +The cursor must name a currently visible File in the same project. The optional +purpose filter is exact; unsupported purposes return an empty page because this +profile stores only `user_data`. Exact hosted default order, invalid/deleted cursor +errors and pagination during concurrent mutation remain unverified local policies. + +Metadata includes `id`, `object: file`, `bytes`, Unix-second `created_at`, +`filename`, `purpose: user_data`, deprecated `status: processed`, and nullable +`expires_at`/`status_details`. Here processed means stored bytes are available, +not parsed, indexed or scanned. Filename is metadata only and never a filesystem +path. The current service bounds it to 1–1024 UTF-8 bytes without NUL. + +The pinned general upload documentation states 512 MB. This implementation uses +512 MiB with a separate 64 KiB multipart-envelope allowance; exact hosted size-unit +and overhead/error parity are unverified. Streams use bounded chunks and a +five-minute transfer deadline. Complete multipart validation rejects missing, +duplicate, unknown or unsupported parts, invalid purpose and incomplete bodies. +Empty file bytes are valid. No partially validated upload is published. + +## Persistence and deletion + +The execution database owns source metadata and PostgreSQL large objects through +the already-pinned pgx driver. Upload and deletion are single transactions; a +rollback does not orphan a body or publish partial metadata. Object OIDs are private +and cannot be supplied through the API. No product tables, temporary local upload +directory, external object-storage service or model invocation are required. +Backups must include PostgreSQL large objects. Physical deletion from the live +database does not erase historical WAL/backups; database maintenance governs +reclamation. Downgrade refuses to drop a populated source table. + +An admitted content read uses an immutable database snapshot and may finish after +deletion. Later source lookups reject. Environment copy resolves up to its existing +50 MiB destination limit before invoking the same durable writer used by inline +uploads. Source deletion does not undo an admitted or completed workspace copy. +These concurrency/error choices are local policies, not verified hosted parity. +Ambiguous upload commits are not automatically retried; clients may need to retain +their source request evidence. Destination unknown-write handling remains unchanged. + +## Remaining scope and verification + +Other upload purposes, `expires_after`, resumable Uploads, quotas, +rate-limit parity, Artifacts and full status/error/header compatibility remain +unimplemented or unverified. Unsupported purposes/expiration are rejected. The +pinned request accepts `evals` while FileObject's purpose union omits it; this +discrepancy is recorded, not resolved by inventing a new contract. Current online +Agents limits require separate version qualification before changing the pinned +baseline. This workflow does not enable public hosted Session provisioning. + +Store tests use actual PostgreSQL for rollback, project isolation, independent +reads and concurrent deletion. The opt-in 512 MiB test exercises streaming storage. +API tests cover multipart ordering/validation and response/authentication behavior. +The fixed SDK and raw HTTP list regression covers default and bounded pages, +automatic continuation, purpose filtering, same-project sharing, foreign-project +isolation, restart and exact list envelopes against real PostgreSQL. +`official_environment_files_create.py` includes the fixed SDK/raw HTTP source +workflow through `official_source_files.py`; its invoking native fixture must +verify copied hashes, retained copies after source deletion, absence of leaked +database objects and real-model consumption. Controlled tests or SDK parsing alone +do not establish that execution acceptance or complete protocol compatibility. diff --git a/contracts/agents-api/upstream.json b/contracts/agents-api/upstream.json new file mode 100644 index 000000000..adc71d0bb --- /dev/null +++ b/contracts/agents-api/upstream.json @@ -0,0 +1,8 @@ +{ + "repository": "https://github.com/openai/openai-python", + "commit": "d7c41efee1b0802b79f3f88a678ef2052b06e9ce", + "sdk_version": "3.13.0", + "resource_path": "src/openai/resources/beta/agents", + "type_path": "src/openai/types/beta", + "beta_header": "agents=v1" +} diff --git a/contracts/agents-api/v1/agents.go b/contracts/agents-api/v1/agents.go new file mode 100644 index 000000000..232082f95 --- /dev/null +++ b/contracts/agents-api/v1/agents.go @@ -0,0 +1,85 @@ +package v1 + +import "encoding/json" + +// CreateAgentRequest describes reusable configuration, not an execution request. +// MCP/web-search tools and model-derived reasoning defaults remain incomplete. +type CreateAgentRequest struct { + XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + Model *string `json:"model" binding:"required"` + Name *string `json:"name,omitempty" extensions:"x-nullable" maxLength:"128"` + Instructions *string `json:"instructions,omitempty" extensions:"x-nullable"` + Metadata map[string]*string `json:"metadata,omitempty" swaggertype:"object,string" extensions:"x-nullable"` + MultiAgent json.RawMessage `json:"multi_agent,omitempty" swaggertype:"object" extensions:"x-nullable"` + Reasoning *Reasoning `json:"reasoning,omitempty" extensions:"x-nullable"` + ServiceTier *string `json:"service_tier,omitempty" enums:"auto,default,flex,priority,fast" extensions:"x-nullable"` + Text *SavedAgentTextInput `json:"text,omitempty" extensions:"x-nullable"` + Tools []json.RawMessage `json:"tools,omitempty" swaggertype:"array,object" extensions:"x-nullable"` +} + +// UpdateAgentRequest replaces supplied fields and preserves omitted fields. +type UpdateAgentRequest struct { + XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + Model *string `json:"model,omitempty"` + Name *string `json:"name,omitempty" extensions:"x-nullable" maxLength:"128"` + Instructions *string `json:"instructions,omitempty" extensions:"x-nullable"` + Metadata map[string]*string `json:"metadata,omitempty" swaggertype:"object,string" extensions:"x-nullable"` + MultiAgent json.RawMessage `json:"multi_agent,omitempty" swaggertype:"object" extensions:"x-nullable"` + Reasoning *Reasoning `json:"reasoning,omitempty" extensions:"x-nullable"` + ServiceTier *string `json:"service_tier,omitempty" enums:"auto,default,flex,priority,fast" extensions:"x-nullable"` + Text *SavedAgentTextInput `json:"text,omitempty" extensions:"x-nullable"` + Tools []json.RawMessage `json:"tools,omitempty" swaggertype:"array,object" extensions:"x-nullable"` +} + +type SavedAgentTextInput struct { + Format json.RawMessage `json:"format,omitempty" swaggertype:"object" extensions:"x-nullable"` + Verbosity *string `json:"verbosity,omitempty" enums:"low,medium,high" extensions:"x-nullable"` +} + +type SavedAgentText struct { + Format SavedAgentTextFormat `json:"format" binding:"required"` + Verbosity string `json:"verbosity" binding:"required" enums:"low,medium,high"` +} + +type SavedAgentTextFormat struct { + Type string `json:"type" binding:"required" enums:"text,json_schema"` + Schema json.RawMessage `json:"schema,omitempty" swaggertype:"object"` +} + +// SavedAgentConfiguration excludes resource identity and mutable metadata. It is +// not the immutable effective configuration of an execution Session. +type SavedAgentConfiguration struct { + XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + Model string `json:"model" binding:"required"` + Name *string `json:"name" extensions:"x-nullable"` + Instructions *string `json:"instructions" extensions:"x-nullable"` + MultiAgent MultiAgentConfig `json:"multi_agent" binding:"required"` + Reasoning Reasoning `json:"reasoning" binding:"required"` + ServiceTier string `json:"service_tier" binding:"required" enums:"auto,default,flex,priority,fast"` + Text SavedAgentText `json:"text" binding:"required"` + Tools []json.RawMessage `json:"tools" binding:"required" swaggertype:"array,object"` +} + +type SavedAgent struct { + SavedAgentConfiguration + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent"` + Metadata map[string]string `json:"metadata" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + UpdatedAt int64 `json:"updated_at" binding:"required"` +} + +type SavedAgentList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []SavedAgent `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +// AgentDeleted is the pinned successful saved-resource deletion response. +type AgentDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.deleted"` + Deleted bool `json:"deleted" binding:"required" enums:"true"` +} diff --git a/contracts/agents-api/v1/core_extension.go b/contracts/agents-api/v1/core_extension.go new file mode 100644 index 000000000..74d1eb531 --- /dev/null +++ b/contracts/agents-api/v1/core_extension.go @@ -0,0 +1,20 @@ +package v1 + +import "fmt" + +// AgentsCore selects an existing Core harness independently of model identity. +type AgentsCore struct { + Harness string `json:"harness" enums:"codex,claude_sdk,mcode" binding:"required"` +} + +func (x *AgentsCore) Validate() error { + if x == nil { + return nil + } + switch x.Harness { + case "codex", "claude_sdk", "mcode": + return nil + default: + return fmt.Errorf("x_agents_core.harness must be codex, claude_sdk or mcode") + } +} diff --git a/contracts/agents-api/v1/credentials.go b/contracts/agents-api/v1/credentials.go new file mode 100644 index 000000000..b8050f78d --- /dev/null +++ b/contracts/agents-api/v1/credentials.go @@ -0,0 +1,54 @@ +package v1 + +// CreateCredentialRequest is the initial static-bearer resource profile. OAuth +// remains a separate missing union member, not a change to the upstream target. +type CreateCredentialRequest struct { + Name *string `json:"name" binding:"required"` + Auth *StaticBearerCredentialInput `json:"auth" binding:"required"` +} + +type StaticBearerCredentialInput struct { + Type string `json:"type" binding:"required" enums:"static_bearer"` + MCPServerURL *string `json:"mcp_server_url" binding:"required"` + Token *string `json:"token" binding:"required"` +} + +// UpdateCredentialRequest implements static token replacement. The pinned OAuth +// replacement variant remains a separate missing union member. +type UpdateCredentialRequest struct { + Auth *StaticBearerCredentialReplacement `json:"auth" binding:"required"` +} + +type StaticBearerCredentialReplacement struct { + Type string `json:"type" binding:"required" enums:"static_bearer"` + Token *string `json:"token" binding:"required"` +} + +type StaticBearerCredentialAuth struct { + Type string `json:"type" binding:"required" enums:"static_bearer"` + MCPServerURL string `json:"mcp_server_url" binding:"required"` +} + +type Credential struct { + ID string `json:"id" binding:"required"` + VaultID string `json:"vault_id" binding:"required"` + Name string `json:"name" binding:"required"` + Object string `json:"object" binding:"required" enums:"vault.credential"` + Auth StaticBearerCredentialAuth `json:"auth" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + UpdatedAt int64 `json:"updated_at" binding:"required"` +} + +type CredentialList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []Credential `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +type CredentialDeleted struct { + ID string `json:"id" binding:"required"` + Deleted bool `json:"deleted" binding:"required"` + Object string `json:"object" binding:"required" enums:"vault.credential.deleted"` +} diff --git a/contracts/agents-api/v1/environment_events.go b/contracts/agents-api/v1/environment_events.go new file mode 100644 index 000000000..e7808b836 --- /dev/null +++ b/contracts/agents-api/v1/environment_events.go @@ -0,0 +1,10 @@ +package v1 + +// SessionEnvironmentState is the pinned event snapshot, not EnvironmentInfo. +// The event vocabulary includes ready; the resource vocabulary instead includes expired. +type SessionEnvironmentState struct { + ID string `json:"id" binding:"required"` + Type string `json:"type" binding:"required"` + Status string `json:"status" enums:"pending,ready,connected,disconnected,failed" binding:"required"` + Error *StreamError `json:"error" extensions:"x-nullable"` +} diff --git a/contracts/agents-api/v1/environment_files.go b/contracts/agents-api/v1/environment_files.go new file mode 100644 index 000000000..8272328c9 --- /dev/null +++ b/contracts/agents-api/v1/environment_files.go @@ -0,0 +1,21 @@ +package v1 + +// EnvironmentFileCreateRequest represents the pinned inline/file_id union. +type EnvironmentFileCreateRequest struct { + Type string `json:"type" binding:"required" enums:"inline,file_id"` + Data *string `json:"data,omitempty"` + FileID *string `json:"file_id,omitempty"` + Path *string `json:"path" binding:"required"` +} + +type EnvironmentFile struct { + EnvironmentID string `json:"environment_id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.file"` + Path string `json:"path" binding:"required"` + SizeBytes int64 `json:"size_bytes" binding:"required" minimum:"0"` +} + +type EnvironmentFileList struct { + Data []EnvironmentFile `json:"data" binding:"required"` + Next *string `json:"next" extensions:"x-nullable"` +} diff --git a/contracts/agents-api/v1/environment_templates.go b/contracts/agents-api/v1/environment_templates.go new file mode 100644 index 000000000..05e013312 --- /dev/null +++ b/contracts/agents-api/v1/environment_templates.go @@ -0,0 +1,53 @@ +package v1 + +import "encoding/json" + +// EnvironmentTemplateRequest exposes pinned input fields; only qualified installations execute. +type EnvironmentTemplateRequest struct { + Name *string `json:"name,omitempty" extensions:"x-nullable"` + Network *EnvironmentNetworkInput `json:"network,omitempty" extensions:"x-nullable"` + CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` + Env map[string]string `json:"env,omitempty" extensions:"x-nullable"` + Files []json.RawMessage `json:"files,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Packages *EnvironmentPackagesInput `json:"packages,omitempty" extensions:"x-nullable"` + Plugins []json.RawMessage `json:"plugins,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + SetupCommands []json.RawMessage `json:"setup_commands,omitempty" extensions:"x-nullable" swaggertype:"array,object"` +} + +// EnvironmentPackagesInput keeps optional nullable request defaults separate from +// the complete package lists returned by resource responses. +type EnvironmentPackagesInput struct { + NPM []string `json:"npm,omitempty" extensions:"x-nullable"` + Python []string `json:"python,omitempty" extensions:"x-nullable"` + System []string `json:"system,omitempty" extensions:"x-nullable"` +} + +// EnvironmentTemplate returns safe configuration metadata only. +type EnvironmentTemplate struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.template"` + Name *string `json:"name" extensions:"x-nullable"` + CreatedAt int64 `json:"created_at" binding:"required"` + UpdatedAt int64 `json:"updated_at" binding:"required"` + CapabilityDirectories []string `json:"capability_directories" binding:"required"` + Network EnvironmentNetwork `json:"network" binding:"required"` + Packages EnvironmentPackages `json:"packages" binding:"required"` + Files []json.RawMessage `json:"files" binding:"required" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins" binding:"required" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` +} + +type EnvironmentTemplateList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []EnvironmentTemplate `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +type EnvironmentTemplateDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.template.deleted"` + Deleted bool `json:"deleted" binding:"required"` +} diff --git a/contracts/agents-api/v1/environments.go b/contracts/agents-api/v1/environments.go new file mode 100644 index 000000000..85f1861a5 --- /dev/null +++ b/contracts/agents-api/v1/environments.go @@ -0,0 +1,32 @@ +package v1 + +import "encoding/json" + +// EnvironmentInfo contains safe installed metadata; populated installation variants remain unsupported. +type EnvironmentInfo struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment"` + Type string `json:"type" binding:"required" enums:"openai_hosted,self_hosted"` + Status string `json:"status" binding:"required" enums:"pending,connected,disconnected,expired,failed"` + Files []json.RawMessage `json:"files" binding:"required" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins" binding:"required" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` +} + +// EnvironmentNetwork is the effective hosted network policy. +type EnvironmentNetwork struct { + Access string `json:"access" binding:"required" enums:"enabled,disabled,restricted"` + AllowedDomains []string `json:"allowed_domains" binding:"required"` +} + +// EnvironmentNetworkInput preserves the optional request domain list. +type EnvironmentNetworkInput struct { + Access string `json:"access" binding:"required" enums:"enabled,disabled,restricted"` + AllowedDomains []string `json:"allowed_domains,omitempty" extensions:"x-nullable"` +} + +type EnvironmentPackages struct { + NPM []string `json:"npm" binding:"required"` + Python []string `json:"python" binding:"required"` + System []string `json:"system" binding:"required"` +} diff --git a/contracts/agents-api/v1/events.go b/contracts/agents-api/v1/events.go new file mode 100644 index 000000000..38b32ee0f --- /dev/null +++ b/contracts/agents-api/v1/events.go @@ -0,0 +1,26 @@ +package v1 + +// SessionEvent contains the supported live event variants of the pinned protocol. +type SessionEvent struct { + Type string `json:"type" binding:"required"` + EventID string `json:"event_id" binding:"required"` + SessionID string `json:"session_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` + Session *Session `json:"session,omitempty"` + Turn *Turn `json:"turn,omitempty"` + Item *Item `json:"item,omitempty"` + ItemID string `json:"item_id,omitempty"` + OutputIndex *int32 `json:"output_index,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + Part *ItemContent `json:"part,omitempty"` + Delta *string `json:"delta,omitempty"` + Text *string `json:"text,omitempty"` + Error *StreamError `json:"error,omitempty"` + Environment *SessionEnvironmentState `json:"environment,omitempty"` +} + +type StreamError struct { + Code string `json:"code"` + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/contracts/agents-api/v1/function_actions.go b/contracts/agents-api/v1/function_actions.go new file mode 100644 index 000000000..4d90bfc50 --- /dev/null +++ b/contracts/agents-api/v1/function_actions.go @@ -0,0 +1,10 @@ +package v1 + +// FunctionCallAction is the supported function-call variant of required_actions. +type FunctionCallAction struct { + Arguments any `json:"arguments" binding:"required"` + CallID string `json:"call_id" binding:"required"` + Name string `json:"name" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" enums:"function_call" binding:"required"` +} diff --git a/contracts/agents-api/v1/function_tools.go b/contracts/agents-api/v1/function_tools.go new file mode 100644 index 000000000..ad5aea81e --- /dev/null +++ b/contracts/agents-api/v1/function_tools.go @@ -0,0 +1,12 @@ +package v1 + +import "encoding/json" + +// FunctionToolInput is the supported application-defined tool configuration. +type FunctionToolInput struct { + Type string `json:"type" enums:"function" binding:"required"` + Name *string `json:"name" binding:"required"` + Description *string `json:"description" binding:"required"` + Parameters json.RawMessage `json:"parameters" swaggertype:"object" binding:"required"` + DeferLoading json.RawMessage `json:"defer_loading,omitempty" swaggertype:"boolean"` +} diff --git a/contracts/agents-api/v1/inputs.go b/contracts/agents-api/v1/inputs.go new file mode 100644 index 000000000..d7a81c011 --- /dev/null +++ b/contracts/agents-api/v1/inputs.go @@ -0,0 +1,29 @@ +package v1 + +import "encoding/json" + +// SessionInput contains the message, cancellation and function-result event variants. +type SessionInput struct { + Type string `json:"type" enums:"agent.session.input.message,agent.session.input.cancel,agent.session.input.tool_result" binding:"required"` + Input []InputMessage `json:"input,omitempty"` + CallID string `json:"call_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` + Success *bool `json:"success,omitempty"` + Error json.RawMessage `json:"error,omitempty" swaggertype:"string" extensions:"x-nullable"` + Output any `json:"output,omitempty" extensions:"x-nullable"` +} + +type InputMessage struct { + Type string `json:"type,omitempty" enums:"message"` + Role string `json:"role" enums:"user" binding:"required"` + Content []InputContent `json:"content" binding:"required"` +} + +type InputContent struct { + Type string `json:"type" enums:"input_text" binding:"required"` + Text string `json:"text" binding:"required"` +} + +type CreateEventsRequest struct { + Events []SessionInput `json:"events" binding:"required"` +} diff --git a/contracts/agents-api/v1/items.go b/contracts/agents-api/v1/items.go new file mode 100644 index 000000000..d8db4d126 --- /dev/null +++ b/contracts/agents-api/v1/items.go @@ -0,0 +1,87 @@ +package v1 + +import ( + "bytes" + "encoding/json" +) + +// Item contains the supported variants of the pinned Session Item union. +type Item struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"message,command_execution,mcp_call,function_call,function_call_output,web_search_call"` + Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` + Role string `json:"role,omitempty" enums:"user,assistant"` + Phase string `json:"phase,omitempty" enums:"commentary,final_answer"` + Content []ItemContent `json:"content,omitempty"` + Command string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + Name string `json:"name,omitempty"` + CallID string `json:"call_id,omitempty"` + ServerLabel string `json:"server_label,omitempty"` + Arguments any `json:"arguments,omitempty"` + Output any `json:"output,omitempty"` + Error any `json:"error,omitempty"` + Action *WebSearchAction `json:"action,omitempty"` +} + +type ItemContent struct { + Type string `json:"type" binding:"required" enums:"input_text,output_text,input_image"` + Text *string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` +} + +type WebSearchAction struct { + Type string `json:"type" binding:"required" enums:"search,open_page,find_in_page,other"` + Query *string `json:"query,omitempty"` + Queries []string `json:"queries,omitempty"` + URL *string `json:"url,omitempty"` + Pattern *string `json:"pattern,omitempty"` +} + +type ItemList struct { + Data []Item `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +// UnmarshalJSON preserves integer precision in tool arguments and structured results. +func (i *Item) UnmarshalJSON(raw []byte) error { + type wire Item + var value wire + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return err + } + if value.Type == "function_call_output" { + var fields struct { + Output json.RawMessage + Error json.RawMessage + } + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + if value.Output == nil && len(fields.Output) > 0 { + value.Output = fields.Output + } + if value.Error == nil && len(fields.Error) > 0 { + value.Error = fields.Error + } + } + // MCP has required nullable output/error fields. + if value.Type == "mcp_call" { + if value.Output == nil { + value.Output = json.RawMessage(`null`) + } + if value.Error == nil { + value.Error = json.RawMessage(`null`) + } + } + if value.Arguments == nil && (value.Type == "mcp_call" || value.Type == "function_call") { + value.Arguments = json.RawMessage(`null`) + } + *i = Item(value) + return nil +} diff --git a/contracts/agents-api/v1/mcp_tools.go b/contracts/agents-api/v1/mcp_tools.go new file mode 100644 index 000000000..c8cd2adf7 --- /dev/null +++ b/contracts/agents-api/v1/mcp_tools.go @@ -0,0 +1,34 @@ +package v1 + +import "encoding/json" + +// MCPToolInput preserves presence for the pinned MCP configuration union. +type MCPToolInput struct { + Type string `json:"type"` + ServerLabel *string `json:"server_label"` + Transport json.RawMessage `json:"transport"` + AllowedTools json.RawMessage `json:"allowed_tools"` + ConnectionOrigin *string `json:"connection_origin"` + CredentialID *string `json:"credential_id"` + RequestMetadata json.RawMessage `json:"request_metadata"` + Required json.RawMessage `json:"required"` +} + +// MCPTool is the supported HTTP resource shape. Headers belong only to saved +// configuration; effective Session transports expose type and server_url. +type MCPTool struct { + Type string `json:"type"` + ServerLabel string `json:"server_label"` + Transport MCPHTTPTransport `json:"transport"` + AllowedTools *[]string `json:"allowed_tools"` + ConnectionOrigin string `json:"connection_origin"` + CredentialID *string `json:"credential_id"` + RequestMetadata map[string]json.RawMessage `json:"request_metadata"` + Required bool `json:"required"` +} + +type MCPHTTPTransport struct { + Type string `json:"type"` + ServerURL string `json:"server_url"` + Headers *map[string]string `json:"headers,omitempty"` +} diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go new file mode 100644 index 000000000..c529ddf3f --- /dev/null +++ b/contracts/agents-api/v1/model_execution.go @@ -0,0 +1,60 @@ +package v1 + +import ( + "errors" + "net/url" + "strings" +) + +// SessionExecutionInput is a write-only execution extension, not a provider resource. +type SessionExecutionInput struct { + ModelProvider *ModelProviderInput `json:"model_provider"` +} + +type ModelProviderInput struct { + Protocol string `json:"protocol" enums:"anthropic,responses"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + ContextWindow int32 `json:"context_window,omitempty"` + MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` +} + +func (p *ModelProviderInput) Validate() error { + if p == nil { + return errors.New("model_provider is required") + } + u, err := url.Parse(p.BaseURL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(p.BaseURL, "\x00\r\n") { + return errors.New("model provider requires an HTTPS base_url without credentials, query or fragment") + } + if p.Protocol != "anthropic" && p.Protocol != "responses" { + return errors.New("unsupported model provider protocol") + } + if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { + return errors.New("invalid model provider API key") + } + if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || (p.MaxOutputTokens > p.ContextWindow) { + return errors.New("invalid model token limits") + } + return nil +} + +func (p *ModelProviderInput) ValidateHarness(harness string) error { + if err := p.Validate(); err != nil { + return err + } + if err := ValidateModelProtocol(p.Protocol, harness); err != nil { + return err + } + if harness == "mcode" && (p.ContextWindow == 0 || p.MaxOutputTokens == 0) { + return errors.New("MiniMax Code requires model context_window and max_output_tokens") + } + return nil +} + +func ValidateModelProtocol(protocol, harness string) error { + if (harness == "codex" && protocol == "responses") || ((harness == "claude_sdk" || harness == "mcode") && protocol == "anthropic") { + return nil + } + return errors.New("selected harness does not support this model provider protocol") +} diff --git a/contracts/agents-api/v1/model_execution_test.go b/contracts/agents-api/v1/model_execution_test.go new file mode 100644 index 000000000..b2fe5f398 --- /dev/null +++ b/contracts/agents-api/v1/model_execution_test.go @@ -0,0 +1,23 @@ +package v1 + +import "testing" + +func TestModelExecutionValidation(t *testing.T) { + for _, tc := range []struct { + protocol, harness string + valid bool + }{{"anthropic", "claude_sdk", true}, {"anthropic", "mcode", true}, {"responses", "codex", true}, {"responses", "mcode", false}, {"anthropic", "codex", false}, {"responses", "", false}} { + p := ModelProviderInput{Protocol: tc.protocol, BaseURL: "https://example.com/v1", APIKey: "secret", ContextWindow: 200000, MaxOutputTokens: 8000} + if (p.ValidateHarness(tc.harness) == nil) != tc.valid { + t.Fatalf("incorrect protocol validation: %s/%s", tc.protocol, tc.harness) + } + } + for _, url := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://"} { + if (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate() == nil { + t.Fatal("unsafe provider URL accepted") + } + } + if (&ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.com", APIKey: "secret"}).ValidateHarness("mcode") == nil { + t.Fatal("MiniMax Code accepted unknown model limits") + } +} diff --git a/contracts/agents-api/v1/required_actions.go b/contracts/agents-api/v1/required_actions.go new file mode 100644 index 000000000..1567b1a05 --- /dev/null +++ b/contracts/agents-api/v1/required_actions.go @@ -0,0 +1,47 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "errors" +) + +// RequiredAction contains the supported variants of the Session action union. +type RequiredAction struct { + Type string `json:"type" enums:"function_call,environment_connection" binding:"required"` + Arguments any `json:"arguments,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + TurnID string `json:"turn_id,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` +} + +type EnvironmentConnectionAction struct { + EnvironmentID string `json:"environment_id" binding:"required"` + Type string `json:"type" enums:"environment_connection" binding:"required"` +} + +// MarshalJSON preserves required null function arguments without exposing function fields on connection actions. +func (a RequiredAction) MarshalJSON() ([]byte, error) { + switch a.Type { + case "function_call": + return json.Marshal(FunctionCallAction{Type: a.Type, Arguments: a.Arguments, CallID: a.CallID, Name: a.Name, TurnID: a.TurnID}) + case "environment_connection": + return json.Marshal(EnvironmentConnectionAction{Type: a.Type, EnvironmentID: a.EnvironmentID}) + default: + return nil, errors.New("unsupported required action type") + } +} + +// UnmarshalJSON preserves function argument integer precision. +func (a *RequiredAction) UnmarshalJSON(raw []byte) error { + type wire RequiredAction + var value wire + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return err + } + *a = RequiredAction(value) + return nil +} diff --git a/contracts/agents-api/v1/required_actions_test.go b/contracts/agents-api/v1/required_actions_test.go new file mode 100644 index 000000000..116d13ad9 --- /dev/null +++ b/contracts/agents-api/v1/required_actions_test.go @@ -0,0 +1,20 @@ +package v1 + +import ( + "encoding/json" + "testing" +) + +func TestRequiredActionFunctionArgumentsRoundTrip(t *testing.T) { + for _, arguments := range []string{`null`, `{"n":9007199254740993}`, `[1,"value"]`, `false`} { + original := `{"arguments":` + arguments + `,"call_id":"call","name":"lookup","turn_id":"turn","type":"function_call"}` + var action RequiredAction + if err := json.Unmarshal([]byte(original), &action); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(action) + if err != nil || string(raw) != original { + t.Fatal("function argument presence or precision changed", string(raw), err) + } + } +} diff --git a/contracts/agents-api/v1/session_artifacts.go b/contracts/agents-api/v1/session_artifacts.go new file mode 100644 index 000000000..78158a951 --- /dev/null +++ b/contracts/agents-api/v1/session_artifacts.go @@ -0,0 +1,23 @@ +package v1 + +type SessionArtifact struct { + ID string `json:"id" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + EnvironmentID string `json:"environment_id" binding:"required"` + Object string `json:"object" enums:"agent.session.artifact" binding:"required"` + Path string `json:"path" binding:"required"` + SessionID string `json:"session_id" binding:"required"` + SizeBytes int64 `json:"size_bytes" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` +} + +type SessionArtifactList struct { + Data []SessionArtifact `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +type SessionArtifactDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" enums:"agent.session.artifact.deleted" binding:"required"` + Deleted bool `json:"deleted" binding:"required"` +} diff --git a/contracts/agents-api/v1/session_deletion.go b/contracts/agents-api/v1/session_deletion.go new file mode 100644 index 000000000..f95bacc98 --- /dev/null +++ b/contracts/agents-api/v1/session_deletion.go @@ -0,0 +1,7 @@ +package v1 + +type SessionDeleted struct { + ID string `json:"id" binding:"required"` + Deleted bool `json:"deleted" binding:"required"` + Object string `json:"object" enums:"agent.session.deleted" binding:"required"` +} diff --git a/contracts/agents-api/v1/session_environment.go b/contracts/agents-api/v1/session_environment.go new file mode 100644 index 000000000..21be2a704 --- /dev/null +++ b/contracts/agents-api/v1/session_environment.go @@ -0,0 +1,17 @@ +package v1 + +import "encoding/json" + +// SessionEnvironment contains supported output variants, independently of request admission. +type SessionEnvironment struct { + Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` + ID string `json:"id,omitempty"` + CapabilityDirectories *[]string `json:"capability_directories,omitempty"` + RemoteURL string `json:"remote_url,omitempty"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + Network *EnvironmentNetwork `json:"network,omitempty"` + Packages *EnvironmentPackages `json:"packages,omitempty"` + Files *[]json.RawMessage `json:"files,omitempty" swaggertype:"array,object"` + Plugins *[]json.RawMessage `json:"plugins,omitempty" swaggertype:"array,object"` + Skills *[]json.RawMessage `json:"skills,omitempty" swaggertype:"array,object"` +} diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go new file mode 100644 index 000000000..5cb59ee0d --- /dev/null +++ b/contracts/agents-api/v1/sessions.go @@ -0,0 +1,118 @@ +// Package v1 contains the supported wire types from the pinned Agents API. +package v1 + +import "encoding/json" + +// CreateSessionRequest supports inline configuration or a saved Agent reference. +// Initial text input is accepted with ordinary or streaming responses. +type CreateSessionRequest struct { + XAgentsCore *SessionExecutionInput `json:"x_agents_core,omitempty"` + Agent *InlineAgent `json:"agent,omitempty"` + AgentID *string `json:"agent_id,omitempty"` + Environment *Environment `json:"environment" binding:"required"` + // Input accepts a string or an ordered array of user InputMessage objects. + // Omission and null create an idle Session; non-text content is not supported yet. + Input any `json:"input,omitempty" extensions:"x-nullable"` + Metadata map[string]string `json:"metadata,omitempty" extensions:"x-nullable"` + Stream bool `json:"stream,omitempty" default:"false"` + VaultIDs []string `json:"vault_ids,omitempty"` +} + +type UpdateSessionRequest struct { + Metadata map[string]string `json:"metadata,omitempty" extensions:"x-nullable"` +} + +// InlineAgent supplies a complete inline configuration or per-Session overrides. +// With agent_id, omitted fields inherit and supplied fields replace saved values. +type InlineAgent struct { + XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + Model *string `json:"model,omitempty"` + Instructions *string `json:"instructions,omitempty" extensions:"x-nullable"` + MultiAgent json.RawMessage `json:"multi_agent,omitempty" swaggertype:"object" extensions:"x-nullable"` + Reasoning *Reasoning `json:"reasoning,omitempty" extensions:"x-nullable"` + ServiceTier *string `json:"service_tier,omitempty" enums:"auto,default,flex,priority,fast" extensions:"x-nullable"` + Text *SavedAgentTextInput `json:"text,omitempty" extensions:"x-nullable"` + Tools []json.RawMessage `json:"tools,omitempty" swaggertype:"array,object" extensions:"x-nullable"` +} + +// Environment contains supported request variants; self-hosted creation requires a workspace directory. +type Environment struct { + Skills []json.RawMessage `json:"skills,omitempty" swaggertype:"array,object" extensions:"x-nullable"` + Env map[string]string `json:"env,omitempty" extensions:"x-nullable"` + SetupCommands []json.RawMessage `json:"setup_commands,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Packages *EnvironmentPackages `json:"packages,omitempty" extensions:"x-nullable"` + Files []json.RawMessage `json:"files,omitempty" swaggertype:"array,object" extensions:"x-nullable"` + EnvironmentTemplateID string `json:"environment_template_id,omitempty"` + Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` + Network *EnvironmentNetworkInput `json:"network,omitempty" extensions:"x-nullable"` +} + +type Agent struct { + XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + ID string `json:"id" binding:"required"` + Instructions *string `json:"instructions" extensions:"x-nullable"` + Model string `json:"model" binding:"required"` + MultiAgent MultiAgentConfig `json:"multi_agent" binding:"required"` + Name *string `json:"name" extensions:"x-nullable"` + Reasoning Reasoning `json:"reasoning" binding:"required"` + ServiceTier string `json:"service_tier" enums:"auto" binding:"required"` + Text TextConfig `json:"text" binding:"required"` + Tools []json.RawMessage `json:"tools" swaggertype:"array,object" binding:"required"` +} + +type MultiAgentConfig struct { + Enabled bool `json:"enabled" binding:"required"` + MaxConcurrentSubagents *int `json:"max_concurrent_subagents" extensions:"x-nullable"` +} + +type Reasoning struct { + Effort *string `json:"effort,omitempty" extensions:"x-nullable"` + Summary *string `json:"summary,omitempty" extensions:"x-nullable"` +} + +type TextConfigInput struct { + Format *TextFormat `json:"format,omitempty" extensions:"x-nullable"` + Verbosity *string `json:"verbosity,omitempty" enums:"low,medium,high" extensions:"x-nullable"` +} + +type TextConfig struct { + Format TextFormat `json:"format" binding:"required"` + Verbosity string `json:"verbosity" enums:"low,medium,high" binding:"required"` +} + +type TextFormat struct { + Type string `json:"type" enums:"text" binding:"required"` +} + +type Session struct { + ID string `json:"id" binding:"required"` + Agent Agent `json:"agent" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + Environment SessionEnvironment `json:"environment" binding:"required"` + Error *string `json:"error" extensions:"x-nullable"` + LastActiveAt int64 `json:"last_active_at" binding:"required"` + Metadata map[string]string `json:"metadata" binding:"required"` + Object string `json:"object" enums:"agent.session" binding:"required"` + RequiredActions []RequiredAction `json:"required_actions" binding:"required"` + Status string `json:"status" enums:"idle,in_progress,requires_action,failed" binding:"required"` + Usage *TokenUsage `json:"usage" extensions:"x-nullable"` + VaultIDs []string `json:"vault_ids" binding:"required"` +} + +type SessionList struct { + Data []Session `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +type ErrorResponse struct { + Error APIError `json:"error" binding:"required"` +} + +type APIError struct { + Message string `json:"message" binding:"required"` + Type string `json:"type" binding:"required"` + Code string `json:"code" binding:"required"` + Param *string `json:"param" extensions:"x-nullable"` +} diff --git a/contracts/agents-api/v1/source_files.go b/contracts/agents-api/v1/source_files.go new file mode 100644 index 000000000..09fcc9ac3 --- /dev/null +++ b/contracts/agents-api/v1/source_files.go @@ -0,0 +1,27 @@ +package v1 + +type SourceFile struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"file"` + Bytes int64 `json:"bytes" binding:"required" minimum:"0"` + CreatedAt int64 `json:"created_at" binding:"required"` + Filename string `json:"filename" binding:"required"` + Purpose string `json:"purpose" binding:"required" enums:"user_data"` + Status string `json:"status" binding:"required" enums:"processed"` + ExpiresAt *int64 `json:"expires_at" extensions:"x-nullable"` + StatusDetails *string `json:"status_details" extensions:"x-nullable"` +} + +type SourceFileList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []SourceFile `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +type SourceFileDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"file"` + Deleted bool `json:"deleted" binding:"required"` +} diff --git a/contracts/agents-api/v1/turns.go b/contracts/agents-api/v1/turns.go new file mode 100644 index 000000000..7e5712c23 --- /dev/null +++ b/contracts/agents-api/v1/turns.go @@ -0,0 +1,24 @@ +package v1 + +type Turn struct { + ID string `json:"id" binding:"required"` + AgentID string `json:"agent_id" binding:"required"` + SessionID string `json:"session_id" binding:"required"` + Object string `json:"object" enums:"agent.session.turn" binding:"required"` + Status string `json:"status" enums:"queued,in_progress,waiting,completed,failed,cancelled" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + StartedAt *int64 `json:"started_at" extensions:"x-nullable"` + CompletedAt *int64 `json:"completed_at" extensions:"x-nullable"` + Error *TurnError `json:"error" extensions:"x-nullable"` + Usage *TokenUsage `json:"usage" extensions:"x-nullable"` +} + +type TurnError struct { + Code string `json:"code" enums:"internal_error" binding:"required"` + Message string `json:"message" binding:"required"` +} + +type TurnList struct { + Data []Turn `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} diff --git a/contracts/agents-api/v1/usage.go b/contracts/agents-api/v1/usage.go new file mode 100644 index 000000000..d74752ec0 --- /dev/null +++ b/contracts/agents-api/v1/usage.go @@ -0,0 +1,15 @@ +package v1 + +type TokenUsage struct { + InputTokens int64 `json:"input_tokens" binding:"required"` + InputTokensDetails InputTokenDetails `json:"input_tokens_details" binding:"required"` + OutputTokens int64 `json:"output_tokens" binding:"required"` + OutputTokensDetails OutputTokenDetails `json:"output_tokens_details" binding:"required"` + TotalTokens int64 `json:"total_tokens" binding:"required"` +} +type InputTokenDetails struct { + CachedTokens int64 `json:"cached_tokens" binding:"required"` +} +type OutputTokenDetails struct { + ReasoningTokens int64 `json:"reasoning_tokens" binding:"required"` +} diff --git a/contracts/agents-api/v1/vaults.go b/contracts/agents-api/v1/vaults.go new file mode 100644 index 000000000..8ba62afc8 --- /dev/null +++ b/contracts/agents-api/v1/vaults.go @@ -0,0 +1,30 @@ +package v1 + +// CreateVaultRequest creates a project-owned credential container. An omitted +// name stays null; a supplied name must be a string with 1–256 UTF-8 bytes after trimming. +type CreateVaultRequest struct { + Name *string `json:"name,omitempty"` + Metadata map[string]*string `json:"metadata,omitempty" swaggertype:"object,string" extensions:"x-nullable"` +} + +type Vault struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"vault"` + CreatedAt int64 `json:"created_at" binding:"required"` + Name *string `json:"name" extensions:"x-nullable"` + Metadata map[string]string `json:"metadata" binding:"required"` +} + +type VaultList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []Vault `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +type VaultDeleted struct { + ID string `json:"id" binding:"required"` + Deleted bool `json:"deleted" binding:"required"` + Object string `json:"object" binding:"required" enums:"vault.deleted"` +} diff --git a/contracts/agents-api/workspace-placement.md b/contracts/agents-api/workspace-placement.md new file mode 100644 index 000000000..a5c87991f --- /dev/null +++ b/contracts/agents-api/workspace-placement.md @@ -0,0 +1,127 @@ +# Two-engine workspace placement + +This decision serves the Codex/Claude single-Agent milestone. It does not enable +a public profile or change the pinned [Environment contract](environments.md). +Ownership rules remain in [CONTRIBUTING.md](../../CONTRIBUTING.md#environment-ownership-and-placement). + +## Current implementation and missing prerequisites + +| Boundary | Codex | Claude Agent SDK | +| --- | --- | --- | +| Native pin | 0.153.4, commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a` | SDK 0.3.269, native 2.1.269 | +| Public execution | `none` and the accepted `self_hosted` remote-executor profile | `none`, with built-in command/file tools disabled | +| Workspace placement | Separate native executor; harness cwd is not the remote workspace | Private typed factory binding inside a separately qualified outer placement; changing cwd alone is insufficient | +| Preparation | Ready before input promotion; Start retains the same native preparation | Private SDK/Go prepare-start ownership is qualified; public workspace admission remains closed | +| History | Retained native history on the bound device, separately from executor workspace | Managed native state and exact resume; private workspace continuation has explicit real-provider acceptance | +| Files | A shared native manager was proven privately; production transport/lifetime composition remains missing | Native tools can access a local workspace; public Files and an authorized idle owner remain missing | +| Cancellation | Owned-command cancellation verified; auxiliary process cleanup still has a recorded failure | Private workspace factory acceptance checks cancellation and effect cessation; arbitrary escaped descendants are not qualified | + +Keep Codex's accepted remote path. Qualify Claude's maintained `query()` entry +inside a dedicated execution environment, retaining the native model/tool loop. +Do not force every adapter through Codex's transport or introduce a replacement +loop. A public `self_hosted` implementation must still support the documented +caller-started executor flow; a private daemon URL or an extra installation step +cannot silently replace it. + +## Claude isolation prerequisite + +There are two separate boundaries. The deployment excludes broader application, +daemon and other-tenant credentials from the harness environment and mounted +files. Within that deployment, native controls prevent generated operations from +reading selected model/MCP credentials or rewriting native history. Directory +bindings, a custom process spawner and permission callbacks alone prove neither. + +The pinned SDK exposes `SandboxSettings`, replacement `Options.env`, `Options.settings` +and `query()`. Linux Bash uses bubblewrap, separate user/PID namespaces and the +native executable's bundled seccomp helper. Native Read/Edit run in the trusted +harness and use permission rules. A sandbox `denyRead` entry is not automatically +a Read permission deny; the documented merge works in the opposite direction. + +The first bounded profile must qualify these native controls together: + +- Explicit runtime environment; only the selected credentials enter native code. + Deny their variable names to sandboxed commands. Keep secret and history roots + outside the workspace, with both sandbox read/write denies and native Read/Edit + denies. Check workspace symlinks as well as direct paths. +- Empty user/project/local setting sources, fixed native tool inventory, explicit + outside-workspace read restrictions and no bypass permission mode. Operator + policy remains relevant; absence of project settings is not absence of policy. +- Sandbox enabled with `failIfUnavailable`, no unsandboxed fallback, no excluded + commands and no weaker nested/network isolation. Verify the actual seccomp + helper and socket restrictions; a warning-only dependency check is insufficient. +- Separate persistent workspace and protected native history. Check history before + resume, preserve the native Session identity and fail before new work when + required history is absent. An Environment ID is not a backup. + +These are supported native configuration surfaces, not completed production +isolation. The source review used the pinned `sdk.d.ts` and shipped native binary +(SHA-256 `25e44883f54419569a3d739f38cbbdaebe83b09895da0f343e1b003710a4775b`). +Upstream [sandbox documentation](https://code.claude.com/docs/en/sandboxing) and +[deployment guidance](https://code.claude.com/docs/en/agent-sdk/secure-deployment) +provide context; current documentation does not replace the pinned source. + +## Execution and file ownership + +`execution.RunEnvironmentInput` currently owns a connection through preparation +and one Run, then releases it. That is not an idle file owner. Existing durable +connection generations fence observations; they do not revoke an old process or +an already-dispatched file write. + +Determine prerequisites for each public operation under the +[Core/Runtime rules](../../CONTRIBUTING.md#environment-ownership-and-placement). +Environment metadata retrieval already reads durable resources without preparing +execution. Files.list needs live path/size metadata from the authorized workspace; +the private bounded byte reader alone does not implement that route. Reuse native +directory/metadata access and existing lifecycle interfaces through a thin adapter. +Bound a live read to its exact authorized Environment/workspace context, including +when idle, and retain permission, path-isolation and unavailable-runtime checks. +Do not require a complete write, replacement or retirement mechanism for this read. + +For file mutations and owner replacement, reject superseded ownership, including +at the executor. Lease loss stops new mutations and closes the transport; uncertain +effects remain unknown rather than being replayed. A replacement socket alone never +authorizes overlap. Define capacity and release for the lifetime actually used by +the operation; a permanent idle owner is not a universal prerequisite. Releasing +transient credentials must not delete caller-owned files or required native history. + +The tracked exact-pin raw-runner hook now publishes its stock-built manager; +private Files/execution/cancel/history composition is qualified. The injectable +in-process route can drop notifications on saturation, while the maintained raw +socket client's consumer queue is unbounded. The optional private harness artifact +therefore uses stock raw stdio with the existing Go RPC and a separate local +metadata socket into the same manager. It does not create a second executor pair +or call host-local `fs/*` for a remote path. Patch ownership, exact builds and +acceptance are defined in the [artifact guide](../../packages/codex-harness/README.md). +This does not enable public Files, a reusable idle owner or full transport bounds. + +## Acceptance and next slice + +Start with synthetic credential/file/socket canaries using the pinned native +tools. Stop on disclosure, bypass or fallback; never widen access to obtain a +passing result. Then use a real provider for native command/file effects, fresh +process continuation of the same workspace/history, cancellation with separately +observed process/effect cessation, and missing-history safe failure. This private +prerequisite does not establish public Session preparation, Files or deployment. + +The temporary `mx` placement qualified the pinned native controls and real +workspace/history continuation under a locked runtime identity and explicit +mount/PID boundary. It is not a production placement: CPU/memory/pids limits, +disk quotas, provider-only trusted-harness egress and concurrent tenants remain +unqualified. Default Docker and the tested gVisor version failed the strict native +sandbox prerequisite; do not reuse either unchanged as a supported placement. + +The private typed Claude factory profile composes those controls in the existing +bridge, retaining `none` behavior. Its separate acceptance must use that actual +factory and bridge; the earlier direct native proof alone is insufficient. +Native command/file observations, public preparation and an idle Files owner +are later independently accepted slices. +Use the same fixed SDK/raw HTTP and real execution acceptance for both public +engines before claiming the complete milestone. + +`NATIVE-COMMAND-OUTPUT-001` remains a material Codex retained-output gap. The user +has deferred it from the current principal-workflow milestone acceptance on the +task board; this does not establish complete output fidelity. The recorded failed real run +is not fixed by a later gated success. No production-ready maintained remedy was +verified in the checked upstream sources; do not fabricate output, repair model +prose or silently adopt a native fork. Reassess that dependency from the full +board alongside other material security, state and data-loss issues. diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..2e4a5dfb4 --- /dev/null +++ b/go.mod @@ -0,0 +1,68 @@ +module github.com/MiniMax-AI-Dev/parsar + +go 1.25.13 + +require ( + connectrpc.com/connect v1.18.1 + github.com/BurntSushi/toml v1.6.0 + github.com/containerd/errdefs v1.0.0 + github.com/go-chi/chi/v5 v5.3.2 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 + github.com/moby/moby/api v1.56.0 + github.com/moby/moby/client v0.6.0 + github.com/openai/openai-go/v3 v3.61.0 + github.com/pressly/goose/v3 v3.27.3 + golang.org/x/crypto v0.55.0 + google.golang.org/protobuf v1.36.12 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect +) + +require ( + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.36.0 // indirect +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..864415fa8 --- /dev/null +++ b/go.sum @@ -0,0 +1,157 @@ +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/openai/openai-go/v3 v3.61.0 h1:nMLuGFdKBF0sB3qFVNwE8kpBpenVN1ucYRoKcx7E1u0= +github.com/openai/openai-go/v3 v3.61.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/go.work b/go.work new file mode 100644 index 000000000..463cff599 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.25.13 + +use . diff --git a/internal/agentdaemon/device/credential.go b/internal/agentdaemon/device/credential.go new file mode 100644 index 000000000..fc9a26be1 --- /dev/null +++ b/internal/agentdaemon/device/credential.go @@ -0,0 +1,24 @@ +package device + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// Credential is the minimal device identity needed for gateway authentication. +// CredentialHash is never sent over the daemon protocol. +type Credential struct { + ID string + WorkspaceID string + Name string + Type string + CredentialHash string +} + +// HashCredential preserves the paired runtime bearer format, including trimming +// whitespace appended when operators paste tokens. +func HashCredential(plaintext string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(plaintext))) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go new file mode 100644 index 000000000..e576e4bef --- /dev/null +++ b/internal/agentdaemon/device/state.go @@ -0,0 +1,112 @@ +// Package device defines persistence data shared by daemon gateways and their stores. +package device + +import "time" + +const OwnerStatusConnected = "connected" +const OwnerStatusDraining = "draining" +const OwnerStatusExpired = "expired" + +// Owner is the persisted view of the current +// WebSocket owner for one agent_daemon device. Generation is a fencing +// token: renewal/release paths must carry it so stale pods can't act. +type Owner struct { + DeviceID string + WorkspaceID string + OwnerPodID string + OwnerURL string + Generation int64 + Status string + ConnectedAt time.Time + LastSeenAt time.Time + LeaseExpiresAt time.Time + UpdatedAt time.Time +} + +type ClaimOwner struct { + DeviceID string + WorkspaceID string + OwnerPodID string + OwnerURL string + LeaseExpiresAt time.Time + Now time.Time +} + +type RenewOwner struct { + DeviceID string + OwnerPodID string + Generation int64 + LeaseExpiresAt time.Time + Now time.Time +} + +type ReleaseOwner struct { + DeviceID string + OwnerPodID string + Generation int64 +} + +// HeartbeatStatus is the post-heartbeat liveness the runner uses to +// detect state changes. +type HeartbeatStatus struct { + Liveness string + // Deleted is true when the heartbeat UPDATE matched zero rows, + // meaning the runtime was soft-deleted (or never existed). The + // gateway uses this to send a permanent WS close frame so the + // daemon stops reconnecting. + Deleted bool +} + +// KindCapabilities mirrors the daemon heartbeat capability +// shape after gateway-level normalization. Persistence stays separate from wire protocol structs. +type KindCapabilities struct { + Streaming bool `json:"streaming,omitempty"` + Permissions bool `json:"permissions,omitempty"` + Usage bool `json:"usage,omitempty"` + Resume bool `json:"resume,omitempty"` + NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` + Steering bool `json:"steering,omitempty"` + MessageItems bool `json:"message_items,omitempty"` + ToolItems bool `json:"tool_items,omitempty"` + ToolObservations bool `json:"tool_observations,omitempty"` + EnvironmentNone bool `json:"environment_none,omitempty"` + RemoteEnvironment bool `json:"remote_environment,omitempty"` + LocalEnvironment bool `json:"local_environment,omitempty"` + LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` + Preparation bool `json:"preparation,omitempty"` + WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` + WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` + WebSearchControl bool `json:"web_search_control,omitempty"` + // ExecutionControls supports typed search and verbosity controls. + ExecutionControls bool `json:"execution_controls,omitempty"` + TextVerbosity bool `json:"text_verbosity,omitempty"` + SubagentControl bool `json:"subagent_control,omitempty"` + FunctionTools bool `json:"function_tools,omitempty"` + MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` + MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` + MCPHTTPRemoteEnvironment bool `json:"mcp_http_remote_environment,omitempty"` + MCPHTTPRemoteBearerAuth bool `json:"mcp_http_remote_bearer_auth,omitempty"` + MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` + DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` + DurableTurns bool `json:"durable_turns,omitempty"` + WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` +} + +// SupportedAgentKind is the sanitized runtime.config view +// of one daemon-side agent_kind. +type SupportedAgentKind struct { + Kind string `json:"kind"` + Available bool `json:"available"` + Version string `json:"version,omitempty"` + Capabilities KindCapabilities `json:"capabilities,omitempty"` +} + +// Heartbeat is the WebSocket daemon heartbeat +// payload after gateway normalization. +type Heartbeat struct { + RuntimeID string + DaemonVersion string + ActiveRequests int + HeartbeatTimestamp int64 + SupportedAgentKinds []SupportedAgentKind +} diff --git a/internal/agentdaemon/gateway/auth.go b/internal/agentdaemon/gateway/auth.go new file mode 100644 index 000000000..9709f64e1 --- /dev/null +++ b/internal/agentdaemon/gateway/auth.go @@ -0,0 +1,91 @@ +package gateway + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" +) + +// RuntimeTypeAgentDaemon is the value runtimes.type takes for rows +// that back an agent_daemon device. The DB has no CHECK constraint on +// runtimes.type so this is a Go-side invariant; the listRuntimes admin +// endpoint's allowlist must be updated alongside it. +const RuntimeTypeAgentDaemon = "agent_daemon" + +var ErrAuthMissingParams = errors.New("agentdaemon auth: missing device_id / token / version") + +var ErrAuthUnknownDevice = errors.New("agentdaemon auth: unknown device") + +// ErrAuthWrongRuntimeType is returned when the runtimes row exists +// but is registered as a different runtime_type. +var ErrAuthWrongRuntimeType = errors.New("agentdaemon auth: runtime_type mismatch") + +// ErrAuthBadCredential folds "no credential on row" together with +// "wrong credential" so probes can't distinguish them. +var ErrAuthBadCredential = errors.New("agentdaemon auth: bad credential") + +var ErrAuthIncompatibleVersion = errors.New("agentdaemon auth: incompatible protocol version") + +// RuntimeStore supplies device credentials without exposing product runtime records. +type RuntimeStore interface { + GetDeviceCredential(ctx context.Context, runtimeID string) (device.Credential, bool, error) +} + +// AuthenticatedRuntime is the result of a successful credential check. +type AuthenticatedRuntime struct { + DeviceID string + WorkspaceID string + Name string +} + +// Authenticator validates the (device_id, token, version) trio that +// the daemon presents on /agent-daemon/ws upgrade and on +// /agent-daemon/bootstrap. +type Authenticator struct { + store RuntimeStore +} + +func NewAuthenticator(store RuntimeStore) *Authenticator { + return &Authenticator{store: store} +} + +// AuthenticateBearer runs the runtime credential check. Returns a +// populated AuthenticatedRuntime on success or one of the typed Err* +// sentinels on failure so the caller can map them to the appropriate +// WS close code / HTTP status. +func (a *Authenticator) AuthenticateBearer(ctx context.Context, deviceID, bearer string) (AuthenticatedRuntime, error) { + if a == nil || a.store == nil { + return AuthenticatedRuntime{}, fmt.Errorf("agentdaemon auth: authenticator not configured") + } + if deviceID == "" || bearer == "" { + return AuthenticatedRuntime{}, ErrAuthMissingParams + } + rt, ok, err := a.store.GetDeviceCredential(ctx, deviceID) + if err != nil { + return AuthenticatedRuntime{}, fmt.Errorf("agentdaemon auth: store: %w", err) + } + if !ok { + return AuthenticatedRuntime{}, ErrAuthUnknownDevice + } + if rt.Type != RuntimeTypeAgentDaemon { + return AuthenticatedRuntime{}, ErrAuthWrongRuntimeType + } + storedHash := rt.CredentialHash + if storedHash == "" { + // Pairing never completed, or someone wiped the credential + // out-of-band. Fail closed. + return AuthenticatedRuntime{}, ErrAuthBadCredential + } + presented := device.HashCredential(bearer) + if subtle.ConstantTimeCompare([]byte(presented), []byte(storedHash)) != 1 { + return AuthenticatedRuntime{}, ErrAuthBadCredential + } + return AuthenticatedRuntime{ + DeviceID: rt.ID, + WorkspaceID: rt.WorkspaceID, + Name: rt.Name, + }, nil +} diff --git a/internal/agentdaemon/gateway/auth_test.go b/internal/agentdaemon/gateway/auth_test.go new file mode 100644 index 000000000..9cfadf322 --- /dev/null +++ b/internal/agentdaemon/gateway/auth_test.go @@ -0,0 +1,96 @@ +package gateway + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" +) + +type stubRuntimeStore struct { + row device.Credential + ok bool + getErr error +} + +func (s *stubRuntimeStore) GetDeviceCredential(_ context.Context, _ string) (device.Credential, bool, error) { + return s.row, s.ok, s.getErr +} + +func TestAuthenticator_RejectsMissingParams(t *testing.T) { + auth := NewAuthenticator(&stubRuntimeStore{}) + _, err := auth.AuthenticateBearer(context.Background(), "", "tok") + if !errors.Is(err, ErrAuthMissingParams) { + t.Fatalf("missing device_id: got %v", err) + } + _, err = auth.AuthenticateBearer(context.Background(), "dev", "") + if !errors.Is(err, ErrAuthMissingParams) { + t.Fatalf("missing bearer: got %v", err) + } +} + +func TestAuthenticator_RejectsUnknownDevice(t *testing.T) { + auth := NewAuthenticator(&stubRuntimeStore{ok: false}) + _, err := auth.AuthenticateBearer(context.Background(), "missing", "tok") + if !errors.Is(err, ErrAuthUnknownDevice) { + t.Fatalf("got %v", err) + } +} + +func TestAuthenticator_RejectsWrongRuntimeType(t *testing.T) { + // A legacy local runtime row trying to dial in as agent_daemon + // must fail — otherwise a paired local credential could open an + // agent_daemon WS and bypass the device picker. + row := device.Credential{ + ID: "dev-1", + Type: "local", + CredentialHash: device.HashCredential("tok"), + } + auth := NewAuthenticator(&stubRuntimeStore{row: row, ok: true}) + _, err := auth.AuthenticateBearer(context.Background(), "dev-1", "tok") + if !errors.Is(err, ErrAuthWrongRuntimeType) { + t.Fatalf("got %v", err) + } +} + +func TestAuthenticator_RejectsBadCredential(t *testing.T) { + row := device.Credential{ + ID: "dev-1", + Type: RuntimeTypeAgentDaemon, + CredentialHash: device.HashCredential("real-tok"), + } + auth := NewAuthenticator(&stubRuntimeStore{row: row, ok: true}) + _, err := auth.AuthenticateBearer(context.Background(), "dev-1", "wrong-tok") + if !errors.Is(err, ErrAuthBadCredential) { + t.Fatalf("got %v", err) + } + // Missing hash also folds into bad_credential so an attacker + // can't distinguish "row exists but credential never stored" + // from "credential mismatch". + rowNoHash := row + rowNoHash.CredentialHash = "" + auth = NewAuthenticator(&stubRuntimeStore{row: rowNoHash, ok: true}) + _, err = auth.AuthenticateBearer(context.Background(), "dev-1", "any-tok") + if !errors.Is(err, ErrAuthBadCredential) { + t.Fatalf("no-hash should fold to bad_credential, got %v", err) + } +} + +func TestAuthenticator_AcceptsValidCredential(t *testing.T) { + row := device.Credential{ + ID: "dev-1", + WorkspaceID: "wks-1", + Name: "alice-mac", + Type: RuntimeTypeAgentDaemon, + CredentialHash: device.HashCredential("real-tok"), + } + auth := NewAuthenticator(&stubRuntimeStore{row: row, ok: true}) + got, err := auth.AuthenticateBearer(context.Background(), "dev-1", "real-tok") + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if got.DeviceID != "dev-1" || got.WorkspaceID != "wks-1" || got.Name != "alice-mac" { + t.Fatalf("unexpected AuthenticatedRuntime: %+v", got) + } +} diff --git a/internal/agentdaemon/gateway/functions_test.go b/internal/agentdaemon/gateway/functions_test.go new file mode 100644 index 000000000..1de61472c --- /dev/null +++ b/internal/agentdaemon/gateway/functions_test.go @@ -0,0 +1,18 @@ +package gateway + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" +) + +func TestFunctionCapabilitySurvivesHeartbeatMapping(t *testing.T) { + for _, supported := range []bool{false, true} { + kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{FunctionTools: supported}}}}) + s := &Session{} + s.setSupportedAgentKinds(kinds) + info, found, known := s.AgentKindStatus("codex") + if !found || !known || info.Capabilities.FunctionTools != supported { + t.Fatal(info, found, known) + } + } +} diff --git a/internal/agentdaemon/gateway/handler.go b/internal/agentdaemon/gateway/handler.go new file mode 100644 index 000000000..0d64273ae --- /dev/null +++ b/internal/agentdaemon/gateway/handler.go @@ -0,0 +1,351 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// HeartbeatTouch is the persistence interface the gateway uses to +// bump last_heartbeat_at / promote pending_pairing -> online when a +// daemon connects. +type HeartbeatTouch interface { + TouchRuntimeHeartbeat(ctx context.Context, runtimeID string) (device.HeartbeatStatus, error) + TouchAgentDaemonHeartbeat(ctx context.Context, input device.Heartbeat) (device.HeartbeatStatus, error) + MarkRuntimeOffline(ctx context.Context, runtimeID string) error +} + +// HandlerConfig wires the gateway's HTTP/WS handlers. nil values panic +// on use; the gateway only ever runs in a fully-configured production +// server, so loud failure beats silent fall-through. +type HandlerConfig struct { + // Authenticator validates the bearer credential on /agent-daemon/ws + // upgrade and on /agent-daemon/bootstrap. + Authenticator *Authenticator + + Registry *Registry + + // Heartbeat flips pending_pairing -> online and keeps + // last_heartbeat_at fresh. nil tracks liveness in-process only. + Heartbeat HeartbeatTouch + + // PublicWSURL is the wss://... URL returned in the bootstrap + // response so deployments behind a TLS terminator can advertise + // the externally-reachable URL. + PublicWSURL string + + // OwnerStore enables multi-pod WebSocket ownership. When set, every + // successful daemon WS dial-in claims device_id -> owner_pod_id in + // Postgres and receives a generation fencing token. nil preserves + // the legacy single-pod in-memory Registry behavior. + OwnerStore DeviceOwnerStore + OwnerPodID string + OwnerURL string + + // OwnerLeaseTTL controls how long the owner row stays valid without + // a renewing inbound daemon frame. Zero uses the package default. + OwnerLeaseTTL time.Duration + + // HeartbeatInterval overrides DefaultHeartbeatInterval. Zero -> default. + HeartbeatInterval time.Duration + + // Log is the gateway's leveled-ish log sink. nil silences logs. + Log SessionLogger +} + +// Handler exposes the agent_daemon HTTP endpoints. +type Handler struct { + cfg HandlerConfig + upgrader websocket.Upgrader +} + +// NewHandler panics on missing Authenticator / Registry — a +// misconfigured gateway is a bug we'd rather catch at boot than at the +// first dial-in. +func NewHandler(cfg HandlerConfig) *Handler { + if cfg.Authenticator == nil { + panic("agentdaemon gateway: HandlerConfig.Authenticator is required") + } + if cfg.Registry == nil { + panic("agentdaemon gateway: HandlerConfig.Registry is required") + } + if cfg.HeartbeatInterval <= 0 { + cfg.HeartbeatInterval = DefaultHeartbeatInterval + } + cfg.OwnerLeaseTTL = normalizeOwnerTTL(cfg.OwnerLeaseTTL) + if cfg.Log == nil { + cfg.Log = func(string, ...any) {} + } + return &Handler{ + cfg: cfg, + upgrader: websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + // Daemon is a non-browser client and sends no Origin; + // the bearer in the query param is the actual auth boundary. + CheckOrigin: func(*http.Request) bool { return true }, + }, + } +} + +// WS is the websocket upgrade entry point. Errors before the upgrade +// return JSON 4xx; errors during the WS read loop fall to Session.Close +// which fans synthetic error/done to every active subscriber. +// +// @Summary Agent-daemon WebSocket upgrade +// @Description Long-lived duplex channel for daemon runtimes. Authenticated by the runner bearer passed as a query param since websockets have no header stage before upgrade. +// @Tags agent-daemon +// @ID agentDaemonWebsocket +// @Param device_id query string true "device id" +// @Param token query string true "runner bearer credential" +// @Param version query string true "daemon protocol version" +// @Success 101 {string} string "protocol switched" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 403 {object} map[string]interface{} +// @Failure 426 {object} map[string]interface{} "incompatible protocol version" +// @Router /agent-daemon/ws [get] +func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + deviceID := q.Get("device_id") + token := q.Get("token") + version := q.Get("version") + if deviceID == "" || token == "" || version == "" { + writeAuthError(w, http.StatusBadRequest, "missing_params", "device_id, token, version are required") + return + } + if !proto.VersionCompatible(version) { + writeAuthError(w, http.StatusUpgradeRequired, "incompatible_version", + "daemon protocol "+version+" incompatible with server "+proto.Version) + return + } + auth, err := h.cfg.Authenticator.AuthenticateBearer(r.Context(), deviceID, token) + if err != nil { + status, code := mapAuthError(err) + // Without this log line a WS-upgrade 401 leaves no server-side + // trace; the credential itself stays out of the log. + h.cfg.Log("agentdaemon gateway: ws auth rejected device_id=%s code=%s status=%d err=%v", + deviceID, code, status, err) + writeAuthError(w, status, code, err.Error()) + return + } + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + // Upgrader has already written a response. + h.cfg.Log("agentdaemon gateway: ws upgrade: %v", err) + return + } + if h.cfg.Heartbeat != nil { + // First inbound action — promote pending_pairing -> online. + // Best-effort; a transient DB blip shouldn't refuse the + // upgrade since we already accepted the credential. + if _, hbErr := h.cfg.Heartbeat.TouchRuntimeHeartbeat(r.Context(), auth.DeviceID); hbErr != nil { + h.cfg.Log("agentdaemon gateway: heartbeat on connect: %v", hbErr) + } + } + var lease *ownerLease + if h.cfg.OwnerStore != nil { + now := time.Now().UTC() + owner, ownerErr := h.cfg.OwnerStore.ClaimAgentDaemonDeviceOwner(r.Context(), device.ClaimOwner{ + DeviceID: auth.DeviceID, + WorkspaceID: auth.WorkspaceID, + OwnerPodID: h.cfg.OwnerPodID, + OwnerURL: h.cfg.OwnerURL, + Now: now, + LeaseExpiresAt: now.Add(h.cfg.OwnerLeaseTTL), + }) + if ownerErr != nil { + h.cfg.Log("agentdaemon gateway: owner claim failed: %v", ownerErr) + _ = conn.Close() + return + } + lease = &ownerLease{ + store: h.cfg.OwnerStore, + deviceID: auth.DeviceID, + ownerPodID: owner.OwnerPodID, + ownerURL: owner.OwnerURL, + generation: owner.Generation, + ttl: h.cfg.OwnerLeaseTTL, + } + } + sess := NewSessionWithOwner(conn, auth.DeviceID, auth.WorkspaceID, version, h.cfg.Registry, h.cfg.Log, lease) + sess.heartbeat = h.cfg.Heartbeat + h.cfg.Log("agentdaemon gateway: ws upgrade ok, registering device_id=%s owner_pod=%s waiters=%d", + auth.DeviceID, h.cfg.OwnerPodID, len(h.cfg.Registry.PendingWaiters(auth.DeviceID))) + if prev := h.cfg.Registry.Register(sess); prev != nil { + // Latest dial-in wins; close the zombie out-of-band. + prev.Close("preempted by newer connection from same device_id") + } + h.cfg.Log("agentdaemon gateway: device_id=%s registered in registry, starting session", auth.DeviceID) + sess.Start() +} + +// Bootstrap is the daemon's first HTTP call after pairing. Validating +// the bearer in a separate HTTP step (rather than folded into the WS +// upgrade) lets the daemon fail fast on credential problems with a +// real HTTP status rather than the opaque WS close code. +// +// @Summary Bootstrap agent-daemon +// @Description Verifies the runner credential and returns the WebSocket URL, heartbeat interval, and protocol version the daemon should use. +// @Tags agent-daemon +// @ID bootstrapAgentDaemon +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer " +// @Param body body object{device_id=string} true "device_id" +// @Success 200 {object} map[string]interface{} "device_id, workspace_id, ws_url, heartbeat_seconds, protocol_version" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 405 {object} map[string]interface{} +// @Router /agent-daemon/bootstrap [post] +func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeAuthError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + return + } + bearer := bearerFromAuthHeader(r) + if bearer == "" { + writeAuthError(w, http.StatusUnauthorized, "missing_bearer", "Authorization: Bearer required") + return + } + var body struct { + DeviceID string `json:"device_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeAuthError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if body.DeviceID == "" { + writeAuthError(w, http.StatusBadRequest, "missing_device_id", "request body must contain device_id") + return + } + auth, err := h.cfg.Authenticator.AuthenticateBearer(r.Context(), body.DeviceID, bearer) + if err != nil { + status, code := mapAuthError(err) + h.cfg.Log("agentdaemon gateway: bootstrap auth rejected device_id=%s code=%s status=%d err=%v", + body.DeviceID, code, status, err) + writeAuthError(w, status, code, err.Error()) + return + } + resp := map[string]any{ + "device_id": auth.DeviceID, + "workspace_id": auth.WorkspaceID, + "ws_url": h.cfg.PublicWSURL, + "heartbeat_seconds": int(h.cfg.HeartbeatInterval.Seconds()), + "protocol_version": proto.Version, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) +} + +// DeviceStatus is a lightweight liveness probe the daemon hits before +// the WS dial. +// +// @Summary Agent-daemon device status +// @Description Reports whether the caller's device has a live WebSocket registration and, when configured, the current owner-pod lease. +// @Tags agent-daemon +// @ID getAgentDaemonDeviceStatus +// @Produce json +// @Param Authorization header string true "Bearer " +// @Param device_id query string true "device id" +// @Success 200 {object} map[string]interface{} "device_id, online, owner" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /agent-daemon/device-status [get] +func (h *Handler) DeviceStatus(w http.ResponseWriter, r *http.Request) { + bearer := bearerFromAuthHeader(r) + if bearer == "" { + writeAuthError(w, http.StatusUnauthorized, "missing_bearer", "") + return + } + deviceID := r.URL.Query().Get("device_id") + if deviceID == "" { + writeAuthError(w, http.StatusBadRequest, "missing_device_id", "device_id query param required") + return + } + auth, err := h.cfg.Authenticator.AuthenticateBearer(r.Context(), deviceID, bearer) + if err != nil { + status, code := mapAuthError(err) + h.cfg.Log("agentdaemon gateway: device-status auth rejected device_id=%s code=%s status=%d err=%v", + deviceID, code, status, err) + writeAuthError(w, status, code, err.Error()) + return + } + _, regErr := h.cfg.Registry.LookupDevice(auth.DeviceID) + online := regErr == nil + var owner map[string]any + if h.cfg.OwnerStore != nil { + if current, ok, ownerErr := h.cfg.OwnerStore.GetAgentDaemonDeviceOwner(r.Context(), auth.DeviceID); ownerErr != nil { + h.cfg.Log("agentdaemon gateway: device-status owner lookup failed: %v", ownerErr) + } else if ok { + leaseOnline := current.Status == device.OwnerStatusConnected && current.LeaseExpiresAt.After(time.Now().UTC()) + online = online || leaseOnline + owner = map[string]any{ + "owner_pod_id": current.OwnerPodID, + "owner_url": current.OwnerURL, + "generation": current.Generation, + "status": current.Status, + "lease_expires_at": current.LeaseExpiresAt, + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_id": auth.DeviceID, + "online": online, + "owner": owner, + }) +} + +// ---------------------------------------------------------------------- +// helpers +// ---------------------------------------------------------------------- + +func bearerFromAuthHeader(r *http.Request) string { + raw := r.Header.Get("Authorization") + if raw == "" { + return "" + } + if !strings.HasPrefix(raw, "Bearer ") { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(raw, "Bearer ")) +} + +func writeAuthError(w http.ResponseWriter, status int, code, detail string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": code, + "detail": detail, + }) +} + +// mapAuthError translates a typed auth error into (HTTP status, +// machine-readable code). Keeps the wire response stable across handlers. +func mapAuthError(err error) (int, string) { + switch { + case errors.Is(err, ErrAuthMissingParams): + return http.StatusBadRequest, "missing_params" + case errors.Is(err, ErrAuthUnknownDevice): + return http.StatusUnauthorized, "unknown_device" + case errors.Is(err, ErrAuthWrongRuntimeType): + return http.StatusForbidden, "wrong_runtime_type" + case errors.Is(err, ErrAuthBadCredential): + return http.StatusUnauthorized, "bad_credential" + case errors.Is(err, ErrAuthIncompatibleVersion): + return http.StatusUpgradeRequired, "incompatible_version" + default: + return http.StatusInternalServerError, "internal" + } +} diff --git a/internal/agentdaemon/gateway/mcp_bearer_fixture_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_fixture_linux_test.go new file mode 100644 index 000000000..28ec63d09 --- /dev/null +++ b/internal/agentdaemon/gateway/mcp_bearer_fixture_linux_test.go @@ -0,0 +1,151 @@ +//go:build linux + +package gateway + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type mcpBearerFixture struct { + private, anonymous *httptest.Server + caFile, memory string + mu sync.Mutex + accepted, rejected, anonymousRequests, crossed int + calls map[string]int +} + +func newMCPBearerFixture(t *testing.T, root, token string) *mcpBearerFixture { + t.Helper() + f := &mcpBearerFixture{memory: "REMEMBER_" + mcpBearerNonce(t), calls: make(map[string]int)} + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("cannot create owned TLS key") + } + now := time.Now() + cert := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: "Owned MCP acceptance CA"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + t.Fatal("cannot create owned TLS certificate") + } + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("cannot create owned TLS leaf key") + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano() + 1), Subject: pkix.Name{CommonName: "Owned MCP HTTPS endpoint"}, NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}} + leafDER, err := x509.CreateCertificate(rand.Reader, leaf, cert, &leafKey.PublicKey, key) + if err != nil { + t.Fatal("cannot sign owned HTTPS leaf certificate") + } + // Keep the host's provider trust roots alongside the owned MCP CA. + var roots []byte + for _, path := range []string{"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"} { + if data, err := os.ReadFile(path); err == nil { + roots = data + break + } + } + if len(roots) == 0 { + t.Fatal("system CA bundle required for real provider TLS") + } + roots = append(append(roots, '\n'), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) + f.caFile = filepath.Join(root, "trusted-ca.pem") + if err := os.WriteFile(f.caFile, roots, 0600); err != nil { + t.Fatal("cannot save owned trust bundle") + } + start := func(anonymous bool) *httptest.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "owned-bearer-acceptance", Version: "1"}, nil) + names := []string{"remember", "fail"} + if anonymous { + names = []string{"ping"} + } + for _, name := range names { + mcp.AddTool(server, &mcp.Tool{Name: name, Description: map[string]string{"remember": "Return the unpredictable value to remember.", "fail": "Return an intentional ordinary tool error. Do not retry.", "ping": "Confirm this separate anonymous MCP server works."}[name]}, func(_ context.Context, _ *mcp.CallToolRequest, args struct { + Tag string `json:"tag" jsonschema:"The requested verification tag"` + }) (*mcp.CallToolResult, any, error) { + f.mu.Lock() + f.calls[name]++ + f.mu.Unlock() + text := f.memory + if name == "fail" { + text = "INTENTIONAL_MCP_TOOL_ERROR:" + args.Tag + } + if name == "ping" { + text = "ANONYMOUS_OK" + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}, IsError: name == "fail"}, nil, nil + }) + } + transport := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + allowed := len(r.Header.Values("Authorization")) == 1 && r.Header.Get("Authorization") == "Bearer "+token + if anonymous { + f.anonymousRequests++ + allowed = len(r.Header.Values("Authorization")) == 0 + if !allowed { + f.crossed++ + } + } else if allowed { + f.accepted++ + } else { + f.rejected++ + } + f.mu.Unlock() + if !allowed { + w.WriteHeader(http.StatusUnauthorized) + return + } + transport.ServeHTTP(w, r) + }) + s := httptest.NewUnstartedServer(handler) + s.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, der}, PrivateKey: leafKey}}, MinVersion: tls.VersionTLS12} + s.StartTLS() + t.Cleanup(s.Close) + return s + } + f.private, f.anonymous = start(false), start(true) + for _, authorization := range []string{"", "Bearer wrong-" + mcpBearerNonce(t)} { + req, _ := http.NewRequest(http.MethodPost, f.private.URL, strings.NewReader("{}")) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + response, err := f.private.Client().Do(req) + if err != nil { + t.Fatal("owned HTTPS authorization probe failed") + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatal("missing or wrong bearer was accepted") + } + } + return f +} + +func (f *mcpBearerFixture) observations() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + calls := make(map[string]int, len(f.calls)) + for name, count := range f.calls { + calls[name] = count + } + return map[string]any{"authenticated_requests": f.accepted, "rejected_requests": f.rejected, "anonymous_requests": f.anonymousRequests, "cross_forwarded_authorization": f.crossed, "tool_calls": calls} +} diff --git a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go new file mode 100644 index 000000000..1920a9079 --- /dev/null +++ b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go @@ -0,0 +1,193 @@ +//go:build linux + +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type mcpBearerTurn struct { + Events []proto.Envelope `json:"events"` + Done proto.DonePayload `json:"done"` + NativeLaunches int `json:"native_launches"` + BearerEnvironmentReference string `json:"bearer_environment_reference"` +} + +func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { + daemon, native, provider, root := mcpBearerSettings(t) + t.Logf("private MCP bearer evidence: %s", root) + token, runner := mcpBearerNonce(t), mcpBearerNonce(t) + fixture := newMCPBearerFixture(t, root, token) + capture := &mcpBearerLog{} + proof := map[string]any{"scope": "Private gateway -> built daemon -> pinned Codex -> real MiniMax with owned HTTPS MCP; no public API or Vault execution claim", "model": "MiniMax-M3", "provider_url": "https://api.minimax.cn/v1", "daemon_sha256": mcpBearerBinaryHash(t, daemon), "codex_sha256": mcpBearerBinaryHash(t, native)} + var turns []*mcpBearerTurn + t.Cleanup(func() { + mcpBearerSafeWrite(t, filepath.Join(root, "captured.log"), capture.snapshot(), token, provider) + histories := mcpBearerScanArtifacts(t, root, token, provider) + if !t.Failed() && histories == 0 { + t.Error("native history artifact missing") + } + proof["native_history_files"], proof["mcp"], proof["turns"] = histories, fixture.observations(), turns + proof["passed"] = !t.Failed() + data, err := json.MarshalIndent(proof, "", " ") + if err != nil { + t.Error("cannot encode safe acceptance evidence") + return + } + mcpBearerSafeWrite(t, filepath.Join(root, "proof.json"), data, token, provider) + }) + id := uuid.NewString() + registry := NewRegistry() + auth := NewAuthenticator(&stubRuntimeStore{ok: true, row: device.Credential{ID: id, WorkspaceID: uuid.NewString(), Type: RuntimeTypeAgentDaemon, CredentialHash: device.HashCredential(runner)}}) + router := chi.NewRouter() + server := httptest.NewServer(router) + t.Cleanup(server.Close) + handler := NewHandler(HandlerConfig{Authenticator: auth, Registry: registry, PublicWSURL: "ws" + strings.TrimPrefix(server.URL, "http") + "/agent-daemon/ws", Log: func(format string, args ...any) { _, _ = fmt.Fprintf(capture, format+"\n", args...) }}) + RegisterRoutes(router, handler) + mcpBearerStartDaemon(t, root, daemon, native, provider, fixture.caFile, server.URL, id, runner, capture) + ctx, cancel := context.WithTimeout(t.Context(), 6*time.Minute) + defer cancel() + peer, err := registry.WaitForDevice(ctx, id, 30*time.Second) + if err != nil { + t.Fatal("built daemon did not connect through the real gateway") + } + t.Cleanup(func() { peer.Close("owned MCP acceptance finished") }) + ready := time.Now().Add(30 * time.Second) + for { + info, found, known := peer.AgentKindStatus("codex") + if known && found && info.Available { + if !info.Capabilities.MCPHTTPTools || !info.Capabilities.MCPHTTPBearerAuth || !info.Capabilities.ToolObservations || !info.Capabilities.DurableTurns || !info.Capabilities.EnvironmentNone { + t.Fatal("built daemon did not advertise the required private execution capabilities") + } + proof["codex_descriptor"] = info + break + } + if time.Now().After(ready) { + t.Fatal("pinned Codex capability discovery did not complete") + } + time.Sleep(50 * time.Millisecond) + } + allowed, anonymousTools := []string{"remember", "fail"}, []string{"ping"} + servers := []proto.MCPHTTPServer{{ServerLabel: "private_mcp", ServerURL: fixture.private.URL, AllowedTools: &allowed, BearerToken: &token}, {ServerLabel: "anonymous_mcp", ServerURL: fixture.anonymous.URL, AllowedTools: &anonymousTools}} + run := func(prompt, resume string, expected map[string]string) *mcpBearerTurn { + t.Helper() + turn := &mcpBearerTurn{} + turns = append(turns, turn) + runID := uuid.NewString() + request := proto.PromptRequestPayload{AgentKind: "codex", ConversationID: "mcp-bearer-acceptance", RunID: runID, Prompt: prompt, AgentStateKey: "mcp-bearer-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveTools: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "MiniMax-M3"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} + sub, err := peer.SubscribeDurable(runID) + if err != nil { + t.Fatal("cannot subscribe before real daemon dispatch") + } + defer peer.Unsubscribe(runID) + envelope, err := proto.NewEnvelope(proto.TypePromptRequest, runID, request) + if err != nil || peer.Send(ctx, envelope) != nil { + t.Fatal("cannot dispatch the private MCP request") + } + mcpBearerCollectTurn(t, ctx, sub, runID, turn, expected, token, provider) + turn.NativeLaunches = mcpBearerReleased(t, root) + turn.BearerEnvironmentReference = mcpBearerConfigReference(t, root, token) + return turn + } + first := run("Call private_mcp remember exactly once with tag first. Also call anonymous_mcp ping exactly once with tag first. Reply with the exact remembered value and the ping result. Do not use any other tool.", "", map[string]string{"remember": fixture.memory, "ping": "ANONYMOUS_OK"}) + nativeID, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + if nativeID == "" || !strings.Contains(first.Done.Content, fixture.memory) { + t.Fatal("first real model Turn did not return its native identity and unpredictable tool result") + } + second := run("Recall the exact remembered value from the preceding tool result. Call private_mcp fail exactly once with tag cold-followup. It intentionally reports an ordinary tool error; do not retry. Reply with the earlier remembered value and the exact error text. Do not call remember, ping or any other tool.", nativeID, map[string]string{"fail": "INTENTIONAL_MCP_TOOL_ERROR:cold-followup"}) + if second.Done.Metadata[proto.DoneMetaAgentSessionID] != nativeID || !strings.Contains(second.Done.Content, fixture.memory) || !strings.Contains(second.Done.Content, "INTENTIONAL_MCP_TOOL_ERROR:cold-followup") { + t.Fatal("cold native continuation lost history, identity or ordinary error output") + } + if second.NativeLaunches <= first.NativeLaunches || second.BearerEnvironmentReference == first.BearerEnvironmentReference { + t.Fatal("cold continuation did not create a fresh native process and bearer environment reference") + } + fixture.mu.Lock() + valid := fixture.accepted > 0 && fixture.rejected == 2 && fixture.anonymousRequests > 0 && fixture.crossed == 0 && fixture.calls["remember"] == 1 && fixture.calls["ping"] == 1 && fixture.calls["fail"] == 1 + fixture.mu.Unlock() + if !valid { + t.Fatal("HTTPS authorization, per-server separation or expected tool-call counts failed") + } +} + +func mcpBearerCollectTurn(t *testing.T, ctx context.Context, sub *Subscription, runID string, turn *mcpBearerTurn, expected map[string]string, secrets ...string) { + t.Helper() + before, after := make(map[string]string), make(map[string]int) + for { + var event proto.Envelope + select { + case value, ok := <-sub.Events: + if !ok { + t.Fatal("real daemon subscription closed before Done") + } + event = value + case <-ctx.Done(): + t.Fatal("real MiniMax MCP Turn timed out") + } + data, _ := json.Marshal(event) + for _, secret := range secrets { + if bytes.Contains(data, []byte(secret)) { + t.Fatal("secret appeared in a daemon event") + } + } + if event.ID != runID { + t.Fatal("real daemon event changed Run identity") + } + turn.Events = append(turn.Events, event) + switch event.Type { + case proto.TypeError, proto.TypePermissionRequest, proto.TypePromptForUserChoice: + t.Fatal("unexpected execution failure or interaction during private MCP acceptance") + case proto.TypeToolCall: + var call proto.ToolCallPayload + if event.DecodePayload(&call) != nil || call.Observation == nil { + t.Fatal("missing normalized native tool observation") + } + obs := call.Observation + output, wanted := expected[obs.Name] + server := "private_mcp" + if obs.Name == "ping" { + server = "anonymous_mcp" + } + if !wanted || obs.Kind != "mcp" || obs.Server != server || call.ID == "" { + t.Fatal("unexpected native tool identity or server") + } + if call.Stage == "before" { + if obs.Status != "in_progress" || before[call.ID] != "" { + t.Fatal("invalid native tool start observation") + } + before[call.ID] = obs.Name + continue + } + status := "completed" + if obs.Name == "fail" { + status = "failed" + } + if call.Stage != "after" || before[call.ID] != obs.Name || obs.Status != status || !bytes.Contains(obs.Output, []byte(output)) || (len(obs.Error) != 0 && string(obs.Error) != "null") { + t.Fatal("native tool result, lifecycle or ordinary error semantics changed") + } + after[obs.Name]++ + case proto.TypeDone: + if event.DecodePayload(&turn.Done) != nil || sub.Err() != nil || turn.Done.Content == "" || turn.Done.Metadata[proto.DoneMetaAgentSessionType] != "codex_thread" { + t.Fatal("invalid native Done or incomplete gateway delivery") + } + for name := range expected { + if after[name] != 1 { + t.Fatal("expected exactly one complete native observation per requested tool") + } + } + return + } + } +} diff --git a/internal/agentdaemon/gateway/mcp_bearer_process_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_process_linux_test.go new file mode 100644 index 000000000..1b14db28c --- /dev/null +++ b/internal/agentdaemon/gateway/mcp_bearer_process_linux_test.go @@ -0,0 +1,296 @@ +//go:build linux + +package gateway + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +func mcpBearerSettings(t *testing.T) (daemon, native, provider, root string) { + t.Helper() + names := []string{"PARSAR_MCP_BEARER_DAEMON_BIN", "PARSAR_MCP_BEARER_CODEX_BIN", "PARSAR_MCP_BEARER_MODEL_KEY_FILE", "PARSAR_MCP_BEARER_PROOF_DIR"} + for _, name := range names { + if os.Getenv(name) == "" { + t.Skip("real MCP bearer acceptance requires all four explicit binary, provider-file and proof settings") + } + if !filepath.IsAbs(os.Getenv(name)) { + t.Fatal("MCP bearer acceptance settings must be absolute paths") + } + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal("cannot resolve managed runtime home") + } + proof, err := filepath.EvalSymlinks(os.Getenv(names[3])) + if err != nil { + t.Fatal("explicit proof directory must already exist") + } + managed, err := filepath.EvalSymlinks(filepath.Join(home, ".parsar")) + if err != nil || !strings.HasPrefix(proof, managed+string(os.PathSeparator)) { + t.Fatal("proof directory must be below ~/.parsar") + } + root, err = os.MkdirTemp(proof, "mcp-bearer-") + if err != nil { + t.Fatal("cannot allocate owned proof directory") + } + key, err := os.ReadFile(os.Getenv(names[2])) + if err != nil { + t.Fatal("cannot read explicitly supplied provider key file") + } + provider = strings.TrimSpace(string(key)) + if provider == "" { + t.Fatal("explicit provider key file is empty") + } + return os.Getenv(names[0]), os.Getenv(names[1]), provider, root +} + +func mcpBearerNonce(t *testing.T) string { + t.Helper() + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + t.Fatal("cannot generate unpredictable acceptance value") + } + return hex.EncodeToString(value) +} + +type mcpBearerLog struct { + mu sync.Mutex + data bytes.Buffer +} + +func (w *mcpBearerLog) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.data.Write(p) +} +func (w *mcpBearerLog) snapshot() []byte { + w.mu.Lock() + defer w.mu.Unlock() + return bytes.Clone(w.data.Bytes()) +} + +func mcpBearerStartDaemon(t *testing.T, root, daemon, native, provider, caFile, base, id, runner string, log *mcpBearerLog) { + t.Helper() + for _, dir := range []string{"home", "tmp", "runtime/parsar-daemon/execution"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { + t.Fatal("cannot create owned daemon directories") + } + } + auth, _ := json.Marshal(map[string]string{"server_url": base, "runtime_id": id, "runner_credential": runner}) + if err := os.WriteFile(filepath.Join(root, "runtime/parsar-daemon/execution/auth.json"), auth, 0600); err != nil { + t.Fatal("cannot configure owned daemon identity") + } + wrapper := filepath.Join(root, "native-wrapper") + script := `#!/bin/sh +set -eu +for argument in "$@"; do + if [ "$argument" = app-server ]; then + printf '%s %s\n' "$$" "$(awk '{print $22}' /proc/$$/stat)" >> "$PARSAR_MCP_BEARER_STARTS" + printf '%s\0' "$@" >> "$PARSAR_MCP_BEARER_ARGV" + break + fi +done +exec "$PARSAR_MCP_BEARER_NATIVE" -c 'model_provider="minimax_validation"' -c 'model_providers.minimax_validation.name="MiniMax validation"' -c 'model_providers.minimax_validation.base_url="https://api.minimax.cn/v1"' -c 'model_providers.minimax_validation.env_key="MINIMAX_VALIDATION_KEY"' -c 'model_providers.minimax_validation.wire_api="responses"' "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil { + t.Fatal("cannot create owned native wrapper") + } + env := []string{"HOME=" + filepath.Join(root, "home"), "TMPDIR=" + filepath.Join(root, "tmp"), "PARSAR_HOME=" + filepath.Join(root, "runtime"), "PARSAR_CODEX_BIN=" + wrapper, "MINIMAX_VALIDATION_KEY=" + provider, "SSL_CERT_FILE=" + caFile, "PARSAR_MCP_BEARER_NATIVE=" + native, "PARSAR_MCP_BEARER_STARTS=" + filepath.Join(root, "native-starts"), "PARSAR_MCP_BEARER_ARGV=" + filepath.Join(root, "native-argv")} + for _, name := range []string{"PATH", "LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"} { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + versionCtx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + version := exec.CommandContext(versionCtx, native, "--version") + version.Env, version.Dir = env, root + output, err := version.Output() + if err != nil || strings.TrimSpace(string(output)) != "codex-cli 0.153.4" { + t.Fatal("explicit native binary must be pinned Codex 0.153.4") + } + cmd := exec.Command(daemon, "connect", "--profile", "execution") + cmd.Env, cmd.Dir, cmd.Stdout, cmd.Stderr = env, root, log, log + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal("cannot start explicitly supplied daemon binary") + } + stopped := make(chan error, 1) + go func() { stopped <- cmd.Wait() }() + t.Cleanup(func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + select { + case <-stopped: + case <-time.After(10 * time.Second): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-stopped + } + // Native RPC children own separate groups. Match recorded start time before cleanup. + _, _ = mcpBearerProcesses(root, true) + deadline := time.Now().Add(3 * time.Second) + for { + _, active := mcpBearerProcesses(root, false) + if active == 0 { + break + } + if time.Now().After(deadline) { + t.Error("owned native process did not exit during cleanup") + break + } + time.Sleep(25 * time.Millisecond) + } + }) +} + +func mcpBearerProcesses(root string, kill bool) (launches, active int) { + data, _ := os.ReadFile(filepath.Join(root, "native-starts")) + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 1 { + continue + } + launches++ + stat, err := os.ReadFile(filepath.Join("/proc", fields[0], "stat")) + if err != nil { + continue + } + _, tail, ok := strings.Cut(string(stat), ") ") + state := strings.Fields(tail) + if !ok || len(state) <= 19 || state[19] != fields[1] || state[0] == "Z" { + continue + } + active++ + if group, err := syscall.Getpgid(pid); kill && err == nil && group == pid { + _ = syscall.Kill(-group, syscall.SIGKILL) + } + } + return launches, active +} + +func mcpBearerReleased(t *testing.T, root string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + launches, active := mcpBearerProcesses(root, false) + if launches > 0 && active == 0 { + return launches + } + if time.Now().After(deadline) { + t.Fatal("Done did not release the owned native process") + } + time.Sleep(25 * time.Millisecond) + } +} + +func mcpBearerConfigReference(t *testing.T, root, token string) string { + t.Helper() + pattern := regexp.MustCompile(`(?m)^bearer_token_env_var\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"`) + var references []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Name() != "config.toml" || !entry.Type().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(token)) { + t.Error("MCP bearer persisted in native configuration") + return nil + } + for _, match := range pattern.FindAllSubmatch(data, -1) { + references = append(references, string(match[1])) + } + return nil + }) + if err != nil || len(references) != 1 { + t.Fatal("expected one native bearer environment reference") + } + return references[0] +} + +func mcpBearerSafeWrite(t *testing.T, path string, data []byte, secrets ...string) { + t.Helper() + for _, secret := range secrets { + if secret != "" && bytes.Contains(data, []byte(secret)) { + t.Error("secret detected in captured acceptance artifact") + _ = os.Remove(path) + return + } + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Error("cannot save safe acceptance artifact") + } +} + +func mcpBearerScanArtifacts(t *testing.T, root, token, provider string) int { + t.Helper() + histories := 0 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.Type().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(path, "/sessions/") && strings.HasSuffix(path, ".jsonl") { + histories++ + } + mcpLeak, providerPresent := bytes.Contains(data, []byte(token)), bytes.Contains(data, []byte(provider)) + if mcpLeak || providerPresent { + if err := os.Remove(path); err != nil { + t.Error("cannot remove secret-bearing owned artifact") + } + if mcpLeak { + t.Error("injected MCP bearer persisted in an owned runtime artifact") + } + } + return nil + }) + if err != nil { + t.Error("cannot scan owned runtime artifacts") + } + return histories +} + +func mcpBearerBinaryHash(t *testing.T, path string) string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal("cannot read explicit acceptance binary") + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + t.Fatal("cannot fingerprint acceptance binary") + } + return hex.EncodeToString(digest.Sum(nil)) +} diff --git a/internal/agentdaemon/gateway/mcp_test.go b/internal/agentdaemon/gateway/mcp_test.go new file mode 100644 index 000000000..b4c65fb71 --- /dev/null +++ b/internal/agentdaemon/gateway/mcp_test.go @@ -0,0 +1,90 @@ +package gateway + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestMCPHTTPBearerCapabilitySurvivesHeartbeatMapping(t *testing.T) { + for _, supported := range []bool{false, true} { + heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, + Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, MCPHTTPBearerAuth: supported}}}} + raw, err := json.Marshal(heartbeat) + if err != nil || strings.Contains(string(raw), `"mcp_http_bearer_auth":true`) != supported { + t.Fatal("wire capability changed", err) + } + var decoded proto.HeartbeatPayload + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + s := &Session{} + s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) + info, found, known := s.AgentKindStatus("codex") + if !found || !known || !info.Capabilities.MCPHTTPTools || info.Capabilities.MCPHTTPBearerAuth != supported { + t.Fatal("bearer capability was lost or inferred from credential-free MCP") + } + } +} + +func TestMCPRemoteCapabilityIsExplicit(t *testing.T) { + for _, supported := range []bool{false, true} { + heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: supported}}}} + raw, err := json.Marshal(heartbeat) + if err != nil || strings.Contains(string(raw), `"mcp_http_remote_environment":true`) != supported { + t.Fatal("wire capability differs", err) + } + var decoded proto.HeartbeatPayload + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + s := &Session{} + s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) + info, found, known := s.AgentKindStatus("codex") + if !found || !known || info.Capabilities.MCPHTTPRemoteEnvironment != supported { + t.Fatal("combination capability lost or inferred") + } + } +} + +func TestMCPRemoteBearerCapabilityIsExplicit(t *testing.T) { + for _, supported := range []bool{false, true} { + heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, MCPHTTPBearerAuth: true, MCPHTTPRemoteBearerAuth: supported}}}} + raw, err := json.Marshal(heartbeat) + if err != nil || strings.Contains(string(raw), `"mcp_http_remote_bearer_auth":true`) != supported { + t.Fatal("wire capability differs", err) + } + var decoded proto.HeartbeatPayload + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + s := &Session{} + s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) + info, found, known := s.AgentKindStatus("codex") + if !found || !known || info.Capabilities.MCPHTTPRemoteBearerAuth != supported { + t.Fatal("combination capability lost or inferred") + } + } +} + +func TestMCPRequiredCapabilityIsExplicit(t *testing.T) { + for _, supported := range []bool{false, true} { + heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: supported}}}} + raw, err := json.Marshal(heartbeat) + if err != nil || strings.Contains(string(raw), `"mcp_http_required":true`) != supported { + t.Fatal("wire capability differs", err) + } + var decoded proto.HeartbeatPayload + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + s := &Session{} + s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) + info, found, known := s.AgentKindStatus("codex") + if !found || !known || info.Capabilities.MCPHTTPRequired != supported { + t.Fatal("combination capability lost or inferred") + } + } +} diff --git a/internal/agentdaemon/gateway/owner.go b/internal/agentdaemon/gateway/owner.go new file mode 100644 index 000000000..de85f864a --- /dev/null +++ b/internal/agentdaemon/gateway/owner.go @@ -0,0 +1,35 @@ +package gateway + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" +) + +const defaultOwnerLeaseTTL = 90 * time.Second + +// DeviceOwnerStore is the DB-backed owner lease surface used by the +// gateway. +type DeviceOwnerStore interface { + ClaimAgentDaemonDeviceOwner(ctx context.Context, input device.ClaimOwner) (device.Owner, error) + RenewAgentDaemonDeviceOwner(ctx context.Context, input device.RenewOwner) (device.Owner, bool, error) + ReleaseAgentDaemonDeviceOwner(ctx context.Context, input device.ReleaseOwner) (bool, error) + GetAgentDaemonDeviceOwner(ctx context.Context, deviceID string) (device.Owner, bool, error) +} + +type ownerLease struct { + store DeviceOwnerStore + deviceID string + ownerPodID string + ownerURL string + generation int64 + ttl time.Duration +} + +func normalizeOwnerTTL(ttl time.Duration) time.Duration { + if ttl <= 0 { + return defaultOwnerLeaseTTL + } + return ttl +} diff --git a/internal/agentdaemon/gateway/owner_test.go b/internal/agentdaemon/gateway/owner_test.go new file mode 100644 index 000000000..a55e1c6d3 --- /dev/null +++ b/internal/agentdaemon/gateway/owner_test.go @@ -0,0 +1,75 @@ +package gateway + +import ( + "context" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type fakeOwnerStore struct { + renewOK bool + renewCalls int + releaseCalls int + releaseCh chan struct{} + lastRenewGen int64 +} + +func (f *fakeOwnerStore) ClaimAgentDaemonDeviceOwner(context.Context, device.ClaimOwner) (device.Owner, error) { + return device.Owner{}, nil +} + +func (f *fakeOwnerStore) RenewAgentDaemonDeviceOwner(_ context.Context, in device.RenewOwner) (device.Owner, bool, error) { + f.renewCalls++ + f.lastRenewGen = in.Generation + return device.Owner{}, f.renewOK, nil +} + +func (f *fakeOwnerStore) ReleaseAgentDaemonDeviceOwner(context.Context, device.ReleaseOwner) (bool, error) { + f.releaseCalls++ + if f.releaseCh != nil { + select { + case <-f.releaseCh: + default: + close(f.releaseCh) + } + } + return true, nil +} + +func (f *fakeOwnerStore) GetAgentDaemonDeviceOwner(context.Context, string) (device.Owner, bool, error) { + return device.Owner{}, false, nil +} + +func TestSessionOwnerLeaseLostClosesStaleConnection(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + owners := &fakeOwnerStore{renewOK: false, releaseCh: make(chan struct{})} + lease := &ownerLease{store: owners, deviceID: "dev-1", ownerPodID: "pod-a", generation: 7, ttl: time.Minute} + sess := NewSessionWithOwner(conn, "dev-1", "wks-1", proto.Version, reg, nil, lease) + reg.Register(sess) + sess.Start() + + heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{}) + raw, _ := jsonMarshal(heartbeat) + conn.Feed(raw) + + select { + case <-sess.Closed(): + case <-time.After(2 * time.Second): + t.Fatal("session did not close after owner renew returned false") + } + if owners.renewCalls == 0 || owners.lastRenewGen != 7 { + t.Fatalf("renew not called with generation 7: calls=%d gen=%d", owners.renewCalls, owners.lastRenewGen) + } + select { + case <-owners.releaseCh: + case <-time.After(2 * time.Second): + t.Fatal("release not called on close") + } + if owners.releaseCalls == 0 { + t.Fatal("release count not incremented") + } +} diff --git a/internal/agentdaemon/gateway/preparation.go b/internal/agentdaemon/gateway/preparation.go new file mode 100644 index 000000000..8f09abd56 --- /dev/null +++ b/internal/agentdaemon/gateway/preparation.go @@ -0,0 +1,86 @@ +package gateway + +import ( + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type preparationSubscription struct { + sub *Subscription + handle string + revision uint64 +} + +// SubscribePreparation correlates private control responses without registering +// a Run. Unsubscribe on abandonment; a terminal resource status closes the stream. +// This subscription belongs to this physical daemon connection only. +func (s *Session) SubscribePreparation(requestID string) (*Subscription, error) { + s.preparationMu.Lock() + defer s.preparationMu.Unlock() + if s.IsClosed() { + return nil, ErrSessionClosed + } + if requestID == "" || s.preparations[requestID] != nil || len(s.preparations) >= 64 { + return nil, errors.New("agentdaemon gateway: invalid, duplicate or excess preparation subscription") + } + ch := make(chan proto.Envelope, 16) + sub := &Subscription{Events: ch, ch: ch, durable: true} + s.preparations[requestID] = &preparationSubscription{sub: sub} + return sub, nil +} + +func (s *Session) UnsubscribePreparation(requestID string) { + s.preparationMu.Lock() + defer s.preparationMu.Unlock() + if p := s.preparations[requestID]; p != nil { + p.sub.mu.Lock() + p.sub.closeLocked(nil) + p.sub.mu.Unlock() + delete(s.preparations, requestID) + } +} + +func (s *Session) dispatchPreparation(env proto.Envelope) { + var status proto.PreparationStatusPayload + if env.DecodePayload(&status) != nil { + return + } + s.preparationMu.Lock() + defer s.preparationMu.Unlock() + p := s.preparations[env.ID] + if p == nil { + return + } + if status.State != "rejected" { + if status.Handle == "" || status.Revision == 0 || (p.handle != "" && p.handle != status.Handle) || status.Revision <= p.revision { + return + } + p.handle, p.revision = status.Handle, status.Revision + } + p.sub.mu.Lock() + defer p.sub.mu.Unlock() + select { + case p.sub.ch <- env: + switch status.State { + case "started", "released", "expired", "failed": + p.sub.closeLocked(nil) + } + default: + p.sub.closeLocked(ErrSubscriberOverflow) + } + if p.sub.closed { + delete(s.preparations, env.ID) + } +} + +func (s *Session) closePreparations() { + s.preparationMu.Lock() + defer s.preparationMu.Unlock() + for id, p := range s.preparations { + p.sub.mu.Lock() + p.sub.closeLocked(ErrSessionClosed) + p.sub.mu.Unlock() + delete(s.preparations, id) + } +} diff --git a/internal/agentdaemon/gateway/preparation_test.go b/internal/agentdaemon/gateway/preparation_test.go new file mode 100644 index 000000000..a525c60c6 --- /dev/null +++ b/internal/agentdaemon/gateway/preparation_test.go @@ -0,0 +1,84 @@ +package gateway + +import ( + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestPreparationSubscriptionHasNoRunIdentityAndOrdersRevisions(t *testing.T) { + registry := NewRegistry() + s := NewSession(newFakeConn(), "device", "tenant", "test", registry, nil) + defer s.Close("test") + sub, err := s.SubscribePreparation("request") + if err != nil { + t.Fatal(err) + } + defer s.UnsubscribePreparation("request") + if registry.LookupRun("request") != nil { + t.Fatal("preparation registered a fake run") + } + for _, status := range []proto.PreparationStatusPayload{ + {Handle: "handle", State: "ready", Revision: 2}, + {Handle: "handle", State: "preparing", Revision: 1}, + {Handle: "foreign-handle", State: "failed", Revision: 100}, + {Handle: "handle", State: "released", Revision: 3}, + {Handle: "handle", State: "ready", Revision: 2}, + } { + env, err := proto.NewEnvelope(proto.TypePreparationStatus, "request", status) + if err != nil { + t.Fatal(err) + } + s.dispatch(env) + } + var states []string + for env := range sub.Events { + var status proto.PreparationStatusPayload + if err := env.DecodePayload(&status); err != nil { + t.Fatal(err) + } + states = append(states, status.State) + } + if len(states) != 2 || states[0] != "ready" || states[1] != "released" || sub.Err() != nil { + t.Fatal(states, sub.Err()) + } +} + +func TestPreparationCloseAndOverflowDoNotInventRunEvents(t *testing.T) { + for _, overflow := range []bool{false, true} { + s := NewSession(newFakeConn(), "device", "tenant", "test", NewRegistry(), nil) + sub, err := s.SubscribePreparation("request") + if err != nil { + t.Fatal(err) + } + if overflow { + for revision := uint64(1); revision <= 17; revision++ { + env, _ := proto.NewEnvelope(proto.TypePreparationStatus, "request", proto.PreparationStatusPayload{Handle: "handle", State: "ready", Revision: revision}) + s.dispatch(env) + } + } else { + s.Close("disconnect") + } + for env := range sub.Events { + if env.Type != proto.TypePreparationStatus { + t.Fatal("invented run event", env.Type) + } + } + want := ErrSessionClosed + if overflow { + want = ErrSubscriberOverflow + } + if !errors.Is(sub.Err(), want) { + t.Fatal(sub.Err()) + } + s.Close("test") + } +} + +func TestPreparationCapabilitySurvivesHeartbeatMapping(t *testing.T) { + kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Preparation: true, RemoteEnvironment: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, NativeSessionRecovery: true}}}}) + if len(kinds) != 1 || (!kinds[0].Capabilities.Preparation || !kinds[0].Capabilities.LocalEnvironment || !kinds[0].Capabilities.LocalEnvironmentNetworkPolicy || !kinds[0].Capabilities.WorkspaceReadPreparation || !kinds[0].Capabilities.NativeSessionRecovery) { + t.Fatal("preparation capability lost") + } +} diff --git a/internal/agentdaemon/gateway/registry.go b/internal/agentdaemon/gateway/registry.go new file mode 100644 index 000000000..ba6711c23 --- /dev/null +++ b/internal/agentdaemon/gateway/registry.go @@ -0,0 +1,342 @@ +// Package gateway is the server-side hub of the agent_daemon connector. +// It owns the HTTP / WebSocket entry points the daemon dials in to, +// per-device long-lived WebSocket sessions, and a process-local +// registry of deviceID/runID/permID → Session for routing. +// +// The package deliberately does NOT depend on the connector +// implementation — the connector imports it, not the other way around. +package gateway + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrDeviceNotRegistered is returned by Registry lookups when a caller +// asks for a device the gateway has no live session for. +var ErrDeviceNotRegistered = errors.New("agentdaemon gateway: device not registered (offline / never connected)") + +// ErrPermissionNotRegistered is returned when SubmitPermission arrives +// for a perm id we don't have a pending mapping for. +var ErrPermissionNotRegistered = errors.New("agentdaemon gateway: permission id not registered (expired / unknown)") + +// ErrPromptForUserChoiceNotRegistered is returned when +// SubmitPromptForUserChoice arrives for an ask id we don't have a +// pending mapping for. Same race semantics as the permission variant +// (cancelled / expired / never seen). +var ErrPromptForUserChoiceNotRegistered = errors.New("agentdaemon gateway: prompt_for_user_choice id not registered (expired / unknown)") + +// ErrWaitForDeviceTimeout is returned by WaitForDevice when the +// deadline expires before a daemon dials in. +var ErrWaitForDeviceTimeout = errors.New("agentdaemon gateway: timed out waiting for device to register") + +// Registry is the process-wide map of live daemon sessions. It is +// concurrency-safe; readers and writers live in different goroutines. +// Four O(1) indexes are maintained: byDevice (primary), byRun (per +// Subscribe), byPerm (per permission_request), byAsk (per +// prompt_for_user_choice). +type Registry struct { + mu sync.RWMutex + byDevice map[string]*Session + byRun map[string]*Session + byPerm map[string]*Session + byAsk map[string]*Session + + // waiters holds buffered(1) channels that WaitForDevice callers + // are blocked on. Register drains the slice the moment a session + // is inserted into byDevice. Buffered(1) so a Register that + // happens between WaitForDevice registering and selecting on the + // chan still wakes the waiter. + waiters map[string][]chan *Session +} + +// NewRegistry returns an empty registry. The zero value would also +// work but the constructor avoids accidental nil-map panics. +func NewRegistry() *Registry { + return &Registry{ + byDevice: map[string]*Session{}, + byRun: map[string]*Session{}, + byPerm: map[string]*Session{}, + byAsk: map[string]*Session{}, + waiters: map[string][]chan *Session{}, + } +} + +// Register adds a freshly-upgraded session under its deviceID. The +// latest dial-in wins: if a session was already registered for that +// device, the old one's run/perm indexes are evicted (the caller +// closes the displaced *Session). +func (r *Registry) Register(sess *Session) (previous *Session) { + r.mu.Lock() + defer r.mu.Unlock() + previous = r.byDevice[sess.DeviceID] + if previous != nil && previous != sess { + // Done under the registry lock so the new session doesn't + // race against the old one's read loop on the way out. + for runID, s := range r.byRun { + if s == previous { + delete(r.byRun, runID) + } + } + for permID, s := range r.byPerm { + if s == previous { + delete(r.byPerm, permID) + } + } + for askID, s := range r.byAsk { + if s == previous { + delete(r.byAsk, askID) + } + } + } + r.byDevice[sess.DeviceID] = sess + + // Signal waiters while still holding the registry lock so a + // subsequent Deregister can't sneak in and clear byDevice before + // the waiter resumes. + if pending, ok := r.waiters[sess.DeviceID]; ok { + for _, ch := range pending { + // Non-blocking by construction — channels are buffered(1). + select { + case ch <- sess: + default: + } + } + delete(r.waiters, sess.DeviceID) + } + return previous +} + +// Deregister removes a device's session if it matches the one currently +// registered. Caller passes the *Session pointer so a stale goroutine +// that wakes up after a reconnect can't evict the new session. +func (r *Registry) Deregister(sess *Session) { + r.mu.Lock() + defer r.mu.Unlock() + if cur, ok := r.byDevice[sess.DeviceID]; ok && cur == sess { + delete(r.byDevice, sess.DeviceID) + } + for runID, s := range r.byRun { + if s == sess { + delete(r.byRun, runID) + } + } + for permID, s := range r.byPerm { + if s == sess { + delete(r.byPerm, permID) + } + } + for askID, s := range r.byAsk { + if s == sess { + delete(r.byAsk, askID) + } + } +} + +// LookupDevice returns the registered session for a device, or +// ErrDeviceNotRegistered when the device has never dialled in / has +// dropped. +func (r *Registry) LookupDevice(deviceID string) (*Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if sess, ok := r.byDevice[deviceID]; ok { + return sess, nil + } + return nil, ErrDeviceNotRegistered +} + +// LookupRun returns the session currently handling a runID, or nil +// when the run has not been subscribed yet / has completed. +func (r *Registry) LookupRun(runID string) *Session { + r.mu.RLock() + defer r.mu.RUnlock() + return r.byRun[runID] +} + +// AttachRun adds the runID -> session mapping. Idempotent. +func (r *Registry) AttachRun(runID string, sess *Session) { + if runID == "" || sess == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.byRun[runID] = sess +} + +// DetachRun removes a runID -> session mapping. +func (r *Registry) DetachRun(runID string) { + if runID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.byRun, runID) +} + +// AttachPermission records a permID -> session mapping so a later +// SubmitPermission can route the decision frame to the right device. +func (r *Registry) AttachPermission(permID string, sess *Session) { + if permID == "" || sess == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.byPerm[permID] = sess +} + +// DetachPermission clears the permID -> session mapping. Idempotent. +func (r *Registry) DetachPermission(permID string) { + if permID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.byPerm, permID) +} + +// LookupPermission returns the session that owns a pending permID, +// or ErrPermissionNotRegistered when the permission has expired / +// been cancelled. +func (r *Registry) LookupPermission(permID string) (*Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if sess, ok := r.byPerm[permID]; ok { + return sess, nil + } + return nil, ErrPermissionNotRegistered +} + +// AttachPromptForUserChoice records askID → session so a later +// SubmitPromptForUserChoice can route the decision back to the right +// daemon. Mirrors AttachPermission. +func (r *Registry) AttachPromptForUserChoice(askID string, sess *Session) { + if askID == "" || sess == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.byAsk[askID] = sess +} + +// DetachPromptForUserChoice clears the askID → session mapping. +// Idempotent. +func (r *Registry) DetachPromptForUserChoice(askID string) { + if askID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.byAsk, askID) +} + +// LookupPromptForUserChoice returns the session that owns a pending +// askID, or ErrPromptForUserChoiceNotRegistered when the ask has +// expired or been cancelled. +func (r *Registry) LookupPromptForUserChoice(askID string) (*Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if sess, ok := r.byAsk[askID]; ok { + return sess, nil + } + return nil, ErrPromptForUserChoiceNotRegistered +} + +// Devices returns a snapshot of registered device ids in arbitrary order. +func (r *Registry) Devices() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.byDevice)) + for id := range r.byDevice { + out = append(out, id) + } + return out +} + +// PendingWaiters returns the pending WaitForDevice waiter channels +// for a given deviceID. Diagnostics only. +func (r *Registry) PendingWaiters(deviceID string) []chan *Session { + r.mu.RLock() + defer r.mu.RUnlock() + return r.waiters[deviceID] +} + +// WaitForDevice blocks until a session for deviceID is registered, or +// until (timeout, ctx) bound expires. Returns the *Session on success. +// +// Fast path returns immediately if already registered. Slow path +// registers a buffered(1) channel under r.waiters[deviceID] which +// Register drains the moment a matching session arrives. timeout of +// 0 means "use the context deadline only". +func (r *Registry) WaitForDevice(ctx context.Context, deviceID string, timeout time.Duration) (*Session, error) { + if deviceID == "" { + return nil, ErrDeviceNotRegistered + } + + r.mu.RLock() + if sess, ok := r.byDevice[deviceID]; ok { + r.mu.RUnlock() + return sess, nil + } + r.mu.RUnlock() + + // Buffer = 1 so Register's non-blocking send always lands even + // if we haven't yet entered the select below. + waiter := make(chan *Session, 1) + r.mu.Lock() + // Double-check — Register could have landed between the RUnlock + // above and the Lock here. + if sess, ok := r.byDevice[deviceID]; ok { + r.mu.Unlock() + return sess, nil + } + r.waiters[deviceID] = append(r.waiters[deviceID], waiter) + r.mu.Unlock() + + defer r.removeWaiter(deviceID, waiter) + + var timer *time.Timer + var timerC <-chan time.Time + if timeout > 0 { + timer = time.NewTimer(timeout) + defer timer.Stop() + timerC = timer.C + } + + select { + case sess := <-waiter: + if sess == nil { + // Register never sends nil; defensive guard in case a + // future refactor closes the channel cleanly. + return nil, ErrDeviceNotRegistered + } + return sess, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-timerC: + return nil, ErrWaitForDeviceTimeout + } +} + +// removeWaiter purges a waiter channel from the pending list. Safe to +// call after Register has already drained the slice. Idempotent. +func (r *Registry) removeWaiter(deviceID string, ch chan *Session) { + r.mu.Lock() + defer r.mu.Unlock() + pending, ok := r.waiters[deviceID] + if !ok { + return + } + filtered := pending[:0] + for _, c := range pending { + if c != ch { + filtered = append(filtered, c) + } + } + if len(filtered) == 0 { + delete(r.waiters, deviceID) + } else { + r.waiters[deviceID] = filtered + } +} diff --git a/internal/agentdaemon/gateway/registry_test.go b/internal/agentdaemon/gateway/registry_test.go new file mode 100644 index 000000000..4ac2456f1 --- /dev/null +++ b/internal/agentdaemon/gateway/registry_test.go @@ -0,0 +1,173 @@ +package gateway + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestWaitForDevice_FastPath(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-warm", "wks-1", "0.1.0", reg, nil) + reg.Register(sess) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + got, err := reg.WaitForDevice(ctx, "dev-warm", 0) + if err != nil { + t.Fatalf("WaitForDevice fast-path: %v", err) + } + if got != sess { + t.Fatalf("WaitForDevice fast-path: wrong session %p, want %p", got, sess) + } +} + +func TestWaitForDevice_SignalledByRegister(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-cold", "wks-1", "0.1.0", reg, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var got *Session + var gotErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + got, gotErr = reg.WaitForDevice(ctx, "dev-cold", 1*time.Second) + }() + + // Give the goroutine a beat to register its waiter before + // triggering Register. A truly deterministic version would expose + // the waiter list size. + time.Sleep(20 * time.Millisecond) + reg.Register(sess) + + wg.Wait() + if gotErr != nil { + t.Fatalf("WaitForDevice signalled: %v", gotErr) + } + if got != sess { + t.Fatalf("WaitForDevice signalled: wrong session %p", got) + } +} + +func TestWaitForDevice_Timeout(t *testing.T) { + reg := NewRegistry() + start := time.Now() + _, err := reg.WaitForDevice(context.Background(), "dev-nobody", 50*time.Millisecond) + if !errors.Is(err, ErrWaitForDeviceTimeout) { + t.Fatalf("expected ErrWaitForDeviceTimeout, got %v", err) + } + if elapsed := time.Since(start); elapsed < 40*time.Millisecond { + t.Fatalf("returned too quickly (elapsed %v); did the timer fire?", elapsed) + } +} + +// TestWaitForDevice_ContextCancel: a cancelled caller must clean up +// its waiter so a later Register doesn't leak the buffered channel +// into a permanently-detached pending list. +func TestWaitForDevice_ContextCancel(t *testing.T) { + reg := NewRegistry() + ctx, cancel := context.WithCancel(context.Background()) + + var gotErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, gotErr = reg.WaitForDevice(ctx, "dev-cancelled", 0) + }() + + time.Sleep(20 * time.Millisecond) + cancel() + wg.Wait() + if !errors.Is(gotErr, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", gotErr) + } + + reg.mu.Lock() + pending, present := reg.waiters["dev-cancelled"] + reg.mu.Unlock() + if present && len(pending) != 0 { + t.Fatalf("waiter not cleaned up after ctx cancel; pending=%d", len(pending)) + } +} + +// TestWaitForDevice_EmptyDeviceID: empty deviceID must error instead +// of blocking forever on a key nobody could Register. +func TestWaitForDevice_EmptyDeviceID(t *testing.T) { + reg := NewRegistry() + _, err := reg.WaitForDevice(context.Background(), "", 10*time.Millisecond) + if !errors.Is(err, ErrDeviceNotRegistered) { + t.Fatalf("expected ErrDeviceNotRegistered, got %v", err) + } +} + +// TestWaitForDevice_DoubleCheckAfterLock: a Register that lands +// between the RLock check and the write Lock must not leak a waiter +// entry. The race is hard to drive deterministically; this test checks +// the post-race shape. +func TestWaitForDevice_DoubleCheckAfterLock(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-race", "wks-1", "0.1.0", reg, nil) + reg.Register(sess) + + got, err := reg.WaitForDevice(context.Background(), "dev-race", 100*time.Millisecond) + if err != nil { + t.Fatalf("WaitForDevice after Register: %v", err) + } + if got != sess { + t.Fatalf("got wrong session %p", got) + } + + reg.mu.Lock() + defer reg.mu.Unlock() + if pending, ok := reg.waiters["dev-race"]; ok && len(pending) > 0 { + t.Fatalf("leaked %d waiter(s) after fast-path return", len(pending)) + } +} + +// TestWaitForDevice_MultipleWaitersSameDevice: two callers may both +// block on the same deviceID; when the device registers, both must +// unblock with the same session. +func TestWaitForDevice_MultipleWaitersSameDevice(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-shared", "wks-1", "0.1.0", reg, nil) + + var wg sync.WaitGroup + var mu sync.Mutex + results := []*Session{} + + for range 3 { + wg.Add(1) + go func() { + defer wg.Done() + got, err := reg.WaitForDevice(context.Background(), "dev-shared", 1*time.Second) + if err != nil { + t.Errorf("waiter saw error: %v", err) + return + } + mu.Lock() + results = append(results, got) + mu.Unlock() + }() + } + + time.Sleep(30 * time.Millisecond) + reg.Register(sess) + wg.Wait() + + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + for i, r := range results { + if r != sess { + t.Fatalf("result[%d] = %p, want %p", i, r, sess) + } + } +} diff --git a/internal/agentdaemon/gateway/routes.go b/internal/agentdaemon/gateway/routes.go new file mode 100644 index 000000000..a786a291b --- /dev/null +++ b/internal/agentdaemon/gateway/routes.go @@ -0,0 +1,25 @@ +package gateway + +import ( + "github.com/go-chi/chi/v5" +) + +// RegisterRoutes mounts the agent_daemon HTTP / WebSocket endpoints +// onto a chi router. +// +// GET /agent-daemon/ws — daemon dial-in (WS upgrade) +// POST /agent-daemon/bootstrap — daemon first-call to fetch wsUrl + heartbeat cadence +// GET /agent-daemon/device-status — daemon self-check +// +// All three accept the runtime credential issued via the +// runtimes/pairings flow with type='agent_daemon'. +func RegisterRoutes(r chi.Router, h *Handler) { + if h == nil { + panic("agentdaemon gateway: RegisterRoutes called with nil handler") + } + r.Route("/agent-daemon", func(r chi.Router) { + r.Get("/ws", h.WS) + r.Post("/bootstrap", h.Bootstrap) + r.Get("/device-status", h.DeviceStatus) + }) +} diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go new file mode 100644 index 000000000..4f5b621f6 --- /dev/null +++ b/internal/agentdaemon/gateway/session.go @@ -0,0 +1,646 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// Tunables. Package-level so tests can override via small helpers +// without exposing struct fields on every Session. +var ( + // DefaultHeartbeatInterval is the cadence the daemon is told to + // send heartbeats at via the bootstrap response. The server uses + // HeartbeatTimeout (not this cadence) to decide a session is dead. + DefaultHeartbeatInterval = 15 * time.Second + + // HeartbeatTimeout is how long the server tolerates no inbound + // frame (heartbeat OR data) before declaring the session unhealthy. + HeartbeatTimeout = 60 * time.Second + + // WriteTimeout caps how long a single outbound frame may block. + // Past this we treat the peer as wedged and close the session. + WriteTimeout = 10 * time.Second + + // InteractionAckTimeout bounds the application-level round trip for a + // permission or user-input decision after it is written to the daemon. + InteractionAckTimeout = 15 * time.Second + + // ReadLimit caps a single inbound frame at 4 MiB. tool_call + // results can be large but anything past this is almost certainly + // a misbehaving daemon (or hostile input). + ReadLimit int64 = 4 * 1024 * 1024 + + // CloseRuntimeDeleted is a custom WS close code (4001) sent when + // a heartbeat discovers the runtime has been deleted. The daemon + // treats this as a permanent error and exits rather than reconnecting. + CloseRuntimeDeleted = 4001 +) + +// ErrSessionClosed is returned by Send / Subscribe when the session +// has shut down. +var ErrSessionClosed = errors.New("agentdaemon gateway: session closed") + +// WSConn is the slice of *websocket.Conn the session uses, exported so +// cross-package tests can substitute a fake without a real WS upgrader. +type WSConn interface { + ReadMessage() (int, []byte, error) + WriteMessage(messageType int, data []byte) error + SetReadLimit(limit int64) + SetReadDeadline(t time.Time) error + SetWriteDeadline(t time.Time) error + Close() error +} + +// SessionLogger is the minimal logging surface a session needs. Pass +// nil to silence logs. +type SessionLogger func(format string, args ...any) + +// Session owns one goroutine for the read loop and serialises writes +// via a single send goroutine so callers can Send concurrently without +// violating gorilla's "single writer" requirement. +type Session struct { + DeviceID string + WorkspaceID string + DaemonVersion string + ConnectedAt time.Time + + conn WSConn + log SessionLogger + reg *Registry + + // owner is non-nil in multi-pod mode. It fences this WebSocket + // against the DB owner row so stale connections from an older pod + // cannot keep handling prompts after a reconnect claimed a newer + // generation. + owner *ownerLease + + // heartbeat persists daemon-advertised capability snapshots. + heartbeat HeartbeatTouch + + hbMu sync.Mutex + lastSeenAt time.Time + + // supportedKinds is the latest daemon-advertised agent_kind snapshot, + // updated from heartbeat frames and read by the connector before + // dispatching prompt_request so unsupported engines fail on the server. + kindsMu sync.RWMutex + kindsSeen bool + supportedKinds []device.SupportedAgentKind + + // Subscribers keyed by runID. The read loop only sends on these + // channels; Unsubscribe is the only place that closes them. + subsMu sync.Mutex + subs map[string]*Subscription + preparationMu sync.Mutex + preparations map[string]*preparationSubscription + workspaceWriteMu sync.Mutex + workspaceWrites map[string]chan proto.Envelope + workspaceReadMu sync.Mutex + workspaceReads map[string]chan proto.Envelope + workspaceExportMu sync.Mutex + workspaceExports map[string]chan proto.Envelope + + ackMu sync.Mutex + ackWaiters map[string]chan proto.InteractionDecisionAckPayload + + // sendCh feeds the WS write loop. Capacity is bounded so a slow + // peer can't queue unbounded outbound frames; once full, Send + // blocks up to WriteTimeout then returns an error. + sendCh chan proto.Envelope + + // closeOnce guards the shutdown path so concurrent Close calls + // collapse into one. + closeOnce sync.Once + closed chan struct{} +} + +// NewSession wires a freshly-upgraded WS connection into a Session. +// The session does NOT start its goroutines automatically — Start runs +// once the handler is ready so the session can't race with response writes. +func NewSession(conn WSConn, deviceID, workspaceID, daemonVersion string, reg *Registry, log SessionLogger) *Session { + return NewSessionWithOwner(conn, deviceID, workspaceID, daemonVersion, reg, log, nil) +} + +// NewSessionWithOwner wires a session with an optional DB-backed owner +// lease. Multi-pod deployments pass the lease returned by +// ClaimAgentDaemonDeviceOwner so heartbeats can fence stale connections. +func NewSessionWithOwner(conn WSConn, deviceID, workspaceID, daemonVersion string, reg *Registry, log SessionLogger, owner *ownerLease) *Session { + if log == nil { + log = func(string, ...any) {} + } + if reg == nil { + reg = NewRegistry() + } + now := time.Now() + return &Session{ + DeviceID: deviceID, + WorkspaceID: workspaceID, + DaemonVersion: daemonVersion, + ConnectedAt: now, + conn: conn, + log: log, + reg: reg, + owner: owner, + lastSeenAt: now, + subs: map[string]*Subscription{}, + preparations: map[string]*preparationSubscription{}, + ackWaiters: map[string]chan proto.InteractionDecisionAckPayload{}, + sendCh: make(chan proto.Envelope, 64), + closed: make(chan struct{}), + } +} + +// Start kicks off the read + write loops. The caller MUST eventually +// call Close (or wait for the read loop to fail) before *Session is +// GC-eligible. +func (s *Session) Start() { + s.conn.SetReadLimit(ReadLimit) + go s.writeLoop() + go s.readLoop() +} + +// Closed returns a channel that's closed once the session has shut down. +func (s *Session) Closed() <-chan struct{} { return s.closed } + +// IsClosed reports whether Close has been called. +func (s *Session) IsClosed() bool { + select { + case <-s.closed: + return true + default: + return false + } +} + +// LastSeen returns the timestamp of the most recent inbound frame. +func (s *Session) LastSeen() time.Time { + s.hbMu.Lock() + defer s.hbMu.Unlock() + return s.lastSeenAt +} + +// AgentKindStatus returns the latest advertised descriptor for kind. +// found=false means the daemon has not advertised that kind; snapshotKnown +// distinguishes "no heartbeat yet" from "heartbeat arrived and omitted it". +// Before the first heartbeat, legacy Claude Code behavior is preserved so +// older daemons can still receive claude_code prompt_requests immediately. +func (s *Session) AgentKindStatus(kind string) (info device.SupportedAgentKind, found bool, snapshotKnown bool) { + kind = strings.TrimSpace(kind) + if kind == "" { + return device.SupportedAgentKind{}, false, false + } + s.kindsMu.RLock() + seen := s.kindsSeen + kinds := make([]device.SupportedAgentKind, len(s.supportedKinds)) + copy(kinds, s.supportedKinds) + s.kindsMu.RUnlock() + if !seen { + if kind == "claude_code" { + return legacyClaudeCodeKind(), true, false + } + return device.SupportedAgentKind{}, false, false + } + for _, candidate := range kinds { + if candidate.Kind == kind { + return candidate, true, true + } + } + return device.SupportedAgentKind{}, false, true +} + +func (s *Session) setSupportedAgentKinds(kinds []device.SupportedAgentKind) { + copyKinds := make([]device.SupportedAgentKind, len(kinds)) + copy(copyKinds, kinds) + s.kindsMu.Lock() + s.kindsSeen = true + s.supportedKinds = copyKinds + s.kindsMu.Unlock() +} + +func legacyClaudeCodeKind() device.SupportedAgentKind { + return device.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Capabilities: device.KindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + } +} + +// Close tears the session down: closes the WS, drains subscribers +// with a synthetic error+done pair, and deregisters. Idempotent. +func (s *Session) Close(reason string) { + s.closeOnce.Do(func() { + close(s.closed) + _ = s.conn.Close() + // Synthetic error + done so the connector's translation loop + // sees a clean EOF and unsubscribes naturally. + s.subsMu.Lock() + subs := s.subs + s.subs = map[string]*Subscription{} + s.subsMu.Unlock() + for runID, sub := range subs { + s.closeSubscription(runID, sub, reason) + s.reg.DetachRun(runID) + } + s.reg.Deregister(s) + s.closePreparations() + s.closeWorkspaceReads() + s.closeWorkspaceWrites() + s.closeWorkspaceExports() + s.markOfflineOnClose() + s.releaseOwnerLease() + }) +} + +// CloseWithCode sends a WS close frame with a custom status code so +// permanent conditions (e.g. runtime deleted) can be distinguished +// from transient disconnects. +func (s *Session) CloseWithCode(code int, reason string) { + _ = s.conn.SetWriteDeadline(time.Now().Add(WriteTimeout)) + _ = s.conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(code, reason)) + s.Close(reason) +} + +func (s *Session) deliverSynthetic(runID string, ch chan proto.Envelope, reason string) { + if reason == "" { + reason = "device disconnected" + } + errEnv, _ := proto.NewEnvelope(proto.TypeError, runID, proto.ErrorPayload{Error: reason}) + doneEnv, _ := proto.NewEnvelope(proto.TypeDone, runID, proto.DonePayload{}) + // Non-blocking — drop rather than hang the close path on a + // wedged subscriber. + select { + case ch <- errEnv: + default: + } + select { + case ch <- doneEnv: + default: + } +} + +// Send queues an envelope for the WS write loop. Returns ErrSessionClosed +// if the session has shut down, or context.DeadlineExceeded if the send +// queue is full for longer than ctx's timeout. +// +// Side effect: stamps env.Trace from ctx if absent, so every server → +// daemon frame inherits the caller's trace_id. Callers that explicitly +// set env.Trace win. +func (s *Session) Send(ctx context.Context, env proto.Envelope) error { + if s.IsClosed() { + return ErrSessionClosed + } + if env.Trace == "" { + if carrier, ok := obslog.TraceFromContext(ctx); ok { + env.Trace = carrier.String() + } + } + select { + case s.sendCh <- env: + return nil + case <-s.closed: + return ErrSessionClosed + case <-ctx.Done(): + return ctx.Err() + } +} + +// SendAndWaitInteractionAck sends a decision and waits until the daemon +// confirms that its agent session applied it. Queueing or writing the +// WebSocket frame alone is not success: without this receipt the canonical +// database interaction must remain retryable. +func (s *Session) SendAndWaitInteractionAck(ctx context.Context, env proto.Envelope, deliveryID string) (proto.InteractionDecisionAckPayload, error) { + deliveryID = strings.TrimSpace(deliveryID) + if deliveryID == "" { + return proto.InteractionDecisionAckPayload{}, errors.New("agentdaemon gateway: interaction delivery id is required") + } + waiter := make(chan proto.InteractionDecisionAckPayload, 1) + s.ackMu.Lock() + if _, exists := s.ackWaiters[deliveryID]; exists { + s.ackMu.Unlock() + return proto.InteractionDecisionAckPayload{}, fmt.Errorf("agentdaemon gateway: duplicate interaction delivery id %q", deliveryID) + } + s.ackWaiters[deliveryID] = waiter + s.ackMu.Unlock() + defer func() { + s.ackMu.Lock() + delete(s.ackWaiters, deliveryID) + s.ackMu.Unlock() + }() + + if err := s.Send(ctx, env); err != nil { + return proto.InteractionDecisionAckPayload{}, err + } + waitCtx, cancel := context.WithTimeout(ctx, InteractionAckTimeout) + defer cancel() + select { + case ack := <-waiter: + return ack, nil + case <-s.closed: + return proto.InteractionDecisionAckPayload{}, ErrSessionClosed + case <-waitCtx.Done(): + // If the ack raced the deadline, prefer the application receipt; + // treating an already-applied decision as retryable can trigger a + // contradictory second human response. + select { + case ack := <-waiter: + return ack, nil + default: + return proto.InteractionDecisionAckPayload{}, waitCtx.Err() + } + } +} + +// writeLoop is the single writer goroutine that gorilla/websocket +// requires. Exits when sendCh is closed (Close path) or on a write error. +func (s *Session) writeLoop() { + for { + select { + case env, ok := <-s.sendCh: + if !ok { + return + } + raw, err := json.Marshal(env) + if err != nil { + s.log("agentdaemon gateway: marshal outbound envelope: %v", err) + continue + } + _ = s.conn.SetWriteDeadline(time.Now().Add(WriteTimeout)) + if err := s.conn.WriteMessage(websocket.TextMessage, raw); err != nil { + s.log("agentdaemon gateway: write %s frame: %v", env.Type, err) + s.Close("write error: " + err.Error()) + return + } + case <-s.closed: + return + } + } +} + +// readLoop is the only place that consumes from the WS. It owns the +// heartbeat timestamp and the dispatch into per-run subscribers. +func (s *Session) readLoop() { + defer s.Close("read loop exit") + + for { + // Bound the read so a dead peer surfaces as a deadline rather + // than a hang. + _ = s.conn.SetReadDeadline(time.Now().Add(HeartbeatTimeout)) + _, raw, err := s.conn.ReadMessage() + if err != nil { + s.log("agentdaemon gateway: read frame: %v", err) + return + } + s.markSeen() + if !s.renewOwnerLease() { + return + } + + var env proto.Envelope + if err := json.Unmarshal(raw, &env); err != nil { + s.log("agentdaemon gateway: unmarshal inbound frame: %v", err) + continue + } + s.dispatch(env) + } +} + +func (s *Session) markSeen() { + s.hbMu.Lock() + s.lastSeenAt = time.Now() + s.hbMu.Unlock() +} + +func (s *Session) renewOwnerLease() bool { + if s.owner == nil || s.owner.store == nil { + return true + } + now := time.Now().UTC() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, ok, err := s.owner.store.RenewAgentDaemonDeviceOwner(ctx, device.RenewOwner{ + DeviceID: s.owner.deviceID, + OwnerPodID: s.owner.ownerPodID, + Generation: s.owner.generation, + Now: now, + LeaseExpiresAt: now.Add(normalizeOwnerTTL(s.owner.ttl)), + }) + if err != nil { + s.log("agentdaemon gateway: owner lease renew failed device=%s generation=%d: %v", s.DeviceID, s.owner.generation, err) + s.Close("owner lease renew failed") + return false + } + if !ok { + s.log("agentdaemon gateway: owner lease lost device=%s generation=%d", s.DeviceID, s.owner.generation) + s.Close("owner lease lost to a newer connection") + return false + } + return true +} + +func (s *Session) releaseOwnerLease() { + if s.owner == nil || s.owner.store == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := s.owner.store.ReleaseAgentDaemonDeviceOwner(ctx, device.ReleaseOwner{ + DeviceID: s.owner.deviceID, + OwnerPodID: s.owner.ownerPodID, + Generation: s.owner.generation, + }); err != nil { + s.log("agentdaemon gateway: owner lease release failed device=%s generation=%d: %v", s.DeviceID, s.owner.generation, err) + } +} + +func (s *Session) markOfflineOnClose() { + if s.heartbeat == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := s.heartbeat.MarkRuntimeOffline(ctx, s.DeviceID); err != nil { + s.log("agentdaemon gateway: mark offline on close failed device=%s: %v", s.DeviceID, err) + } +} + +func (s *Session) handleHeartbeat(env proto.Envelope) { + var p proto.HeartbeatPayload + if err := env.DecodePayload(&p); err != nil { + s.log("agentdaemon gateway: decode heartbeat payload device=%s: %v", s.DeviceID, err) + return + } + kinds := deviceKindsFromHeartbeat(p) + s.setSupportedAgentKinds(kinds) + if s.heartbeat == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + status, err := s.heartbeat.TouchAgentDaemonHeartbeat(ctx, device.Heartbeat{ + RuntimeID: s.DeviceID, + DaemonVersion: p.DaemonVersion, + ActiveRequests: p.ActiveRequests, + HeartbeatTimestamp: p.Timestamp, + SupportedAgentKinds: kinds, + }) + if err != nil { + s.log("agentdaemon gateway: persist heartbeat device=%s: %v", s.DeviceID, err) + return + } + if status.Deleted { + s.log("agentdaemon gateway: runtime retired, closing session device=%s", s.DeviceID) + // "retired" rather than "deleted by admin": the row may have + // been soft-deleted by sandbox stale-row cleanup or by an + // actual admin action; the daemon only sees it's no longer + // the current owner. + s.CloseWithCode(CloseRuntimeDeleted, "runtime retired") + } +} + +func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentKind { + if len(p.SupportedAgentKinds) == 0 { + if !p.ClaudeAvailable { + return nil + } + return []device.SupportedAgentKind{{ + Kind: "claude_code", + Available: true, + Capabilities: device.KindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }} + } + out := make([]device.SupportedAgentKind, 0, len(p.SupportedAgentKinds)) + for _, info := range p.SupportedAgentKinds { + out = append(out, device.SupportedAgentKind{ + Kind: info.Kind, + Available: info.Available, + Version: info.Version, + Capabilities: device.KindCapabilities{ + Streaming: info.Capabilities.Streaming, + Permissions: info.Capabilities.Permissions, + Usage: info.Capabilities.Usage, + Resume: info.Capabilities.Resume, + Steering: info.Capabilities.Steering, + DurableTurns: info.Capabilities.DurableTurns, + DurableInputReceipts: info.Capabilities.DurableInputReceipts, + NativeSessionRecovery: info.Capabilities.NativeSessionRecovery, + MessageItems: info.Capabilities.MessageItems, + ToolItems: info.Capabilities.ToolItems, + ToolObservations: info.Capabilities.ToolObservations, + EnvironmentNone: info.Capabilities.EnvironmentNone, + RemoteEnvironment: info.Capabilities.RemoteEnvironment, + LocalEnvironment: info.Capabilities.LocalEnvironment, + LocalEnvironmentNetworkPolicy: info.Capabilities.LocalEnvironmentNetworkPolicy, + Preparation: info.Capabilities.Preparation, + WorkspaceReadPreparation: info.Capabilities.WorkspaceReadPreparation, + WorkspaceOutputExport: info.Capabilities.WorkspaceOutputExport, + WebSearchControl: info.Capabilities.WebSearchControl, + TextVerbosity: info.Capabilities.TextVerbosity, + ExecutionControls: info.Capabilities.ExecutionControls, + SubagentControl: info.Capabilities.SubagentControl, + FunctionTools: info.Capabilities.FunctionTools, + MCPHTTPTools: info.Capabilities.MCPHTTPTools, + MCPHTTPRequired: info.Capabilities.MCPHTTPRequired, + MCPHTTPRemoteEnvironment: info.Capabilities.MCPHTTPRemoteEnvironment, + MCPHTTPRemoteBearerAuth: info.Capabilities.MCPHTTPRemoteBearerAuth, + MCPHTTPBearerAuth: info.Capabilities.MCPHTTPBearerAuth, + WorkspaceAuthoring: info.Capabilities.WorkspaceAuthoring, + }, + }) + } + return out +} + +func (s *Session) dispatch(env proto.Envelope) { + switch env.Type { + case proto.TypeWorkspaceExportResult: + s.dispatchWorkspaceExport(env) + return + case proto.TypeWorkspaceWriteResult: + s.dispatchWorkspaceWrite(env) + case proto.TypeWorkspaceReadResult: + s.dispatchWorkspaceRead(env) + return + case proto.TypePreparationStatus: + s.dispatchPreparation(env) + return + case proto.TypeHeartbeat: + s.handleHeartbeat(env) + return + case proto.TypePermissionRequest: + var p proto.PermissionRequestPayload + requestID := "" + if err := env.DecodePayload(&p); err == nil { + requestID = strings.TrimSpace(p.RequestID) + } + if requestID == "" { + requestID = strings.TrimSpace(env.ID) + } + if requestID != "" { + s.reg.AttachPermission(requestID, s) + } + case proto.TypePermissionCancel: + if env.ID != "" { + s.reg.DetachPermission(env.ID) + } + case proto.TypePromptForUserChoice: + // env.ID is the run id (so the fan path below delivers this + // frame to the run's subscriber). The ask id rides on the + // payload — pull it out so SubmitPromptForUserChoice can find + // the session by ask id via the byAsk index. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err == nil && p.AskID != "" { + s.reg.AttachPromptForUserChoice(p.AskID, s) + } + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + if err := env.DecodePayload(&ack); err != nil { + s.log("agentdaemon gateway: decode interaction decision ack: %v", err) + return + } + s.ackMu.Lock() + waiter := s.ackWaiters[ack.DeliveryID] + s.ackMu.Unlock() + if waiter == nil { + s.log("agentdaemon gateway: interaction decision ack has no waiter delivery=%s request=%s", ack.DeliveryID, env.ID) + return + } + select { + case waiter <- ack: + default: + } + return + } + + // All run-correlated frames fan to the matching subscriber. Current + // permission and prompt-for-user-choice frames keep their interaction ID + // in the payload so Envelope.ID remains the run ID. Legacy permission + // frames put the permission ID in Envelope.ID; those are still indexed + // above but cannot be correlated to a run subscriber. + if env.ID == "" { + return + } + s.dispatchToSubscriber(env) +} diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go new file mode 100644 index 000000000..e9c0c86d8 --- /dev/null +++ b/internal/agentdaemon/gateway/session_test.go @@ -0,0 +1,479 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "io" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// fakeConn is the WSConn implementation used by session + registry +// tests. Concurrency-safe. +type fakeConn struct { + mu sync.Mutex + incoming []fakeFrame + cond *sync.Cond + closed bool + + writes [][]byte + writeErr error +} + +type fakeFrame struct { + data []byte + err error +} + +func newFakeConn() *fakeConn { + c := &fakeConn{} + c.cond = sync.NewCond(&c.mu) + return c +} + +func (c *fakeConn) Feed(data []byte) { + c.mu.Lock() + c.incoming = append(c.incoming, fakeFrame{data: data}) + c.cond.Broadcast() + c.mu.Unlock() +} + +func (c *fakeConn) FeedError(err error) { + c.mu.Lock() + c.incoming = append(c.incoming, fakeFrame{err: err}) + c.cond.Broadcast() + c.mu.Unlock() +} + +func (c *fakeConn) ReadMessage() (int, []byte, error) { + c.mu.Lock() + for len(c.incoming) == 0 && !c.closed { + c.cond.Wait() + } + if c.closed && len(c.incoming) == 0 { + c.mu.Unlock() + return 0, nil, io.EOF + } + f := c.incoming[0] + c.incoming = c.incoming[1:] + c.mu.Unlock() + if f.err != nil { + return 0, nil, f.err + } + return 1, f.data, nil +} + +func (c *fakeConn) WriteMessage(_ int, data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.writeErr != nil { + return c.writeErr + } + cp := make([]byte, len(data)) + copy(cp, data) + c.writes = append(c.writes, cp) + return nil +} + +func (c *fakeConn) SetReadLimit(int64) {} +func (c *fakeConn) SetReadDeadline(time.Time) error { return nil } +func (c *fakeConn) SetWriteDeadline(time.Time) error { + return nil +} + +func (c *fakeConn) Close() error { + c.mu.Lock() + c.closed = true + c.cond.Broadcast() + c.mu.Unlock() + return nil +} + +func (c *fakeConn) Writes() [][]byte { + c.mu.Lock() + defer c.mu.Unlock() + out := make([][]byte, len(c.writes)) + copy(out, c.writes) + return out +} + +type fakeHeartbeatStore struct { + mu sync.Mutex + daemonCh chan device.Heartbeat + daemon []device.Heartbeat + runtime []string +} + +func newFakeHeartbeatStore() *fakeHeartbeatStore { + return &fakeHeartbeatStore{daemonCh: make(chan device.Heartbeat, 4)} +} + +func (f *fakeHeartbeatStore) TouchRuntimeHeartbeat(_ context.Context, runtimeID string) (device.HeartbeatStatus, error) { + f.mu.Lock() + f.runtime = append(f.runtime, runtimeID) + f.mu.Unlock() + return device.HeartbeatStatus{Liveness: "online"}, nil +} + +func (f *fakeHeartbeatStore) TouchAgentDaemonHeartbeat(_ context.Context, input device.Heartbeat) (device.HeartbeatStatus, error) { + f.mu.Lock() + f.daemon = append(f.daemon, input) + f.mu.Unlock() + select { + case f.daemonCh <- input: + default: + } + return device.HeartbeatStatus{Liveness: "online"}, nil +} + +func (f *fakeHeartbeatStore) MarkRuntimeOffline(_ context.Context, _ string) error { + return nil +} + +func (f *fakeHeartbeatStore) waitDaemonHeartbeat(t *testing.T) device.Heartbeat { + t.Helper() + select { + case input := <-f.daemonCh: + return input + case <-time.After(2 * time.Second): + t.Fatal("daemon heartbeat was not persisted") + } + return device.Heartbeat{} +} + +// ---- registry tests ------------------------------------------------- + +func TestRegistry_RegisterAndLookup(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + if prev := reg.Register(sess); prev != nil { + t.Fatalf("first Register returned non-nil prev") + } + got, err := reg.LookupDevice("dev-1") + if err != nil || got != sess { + t.Fatalf("LookupDevice round-trip failed: got=%p err=%v", got, err) + } +} + +func TestRegistry_RegisterReplacesAndEvictsRuns(t *testing.T) { + reg := NewRegistry() + old := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + reg.Register(old) + reg.AttachRun("run-1", old) + reg.AttachPermission("perm-1", old) + + new := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + prev := reg.Register(new) + if prev != old { + t.Fatalf("expected old session as displaced previous, got %p", prev) + } + // Old session's run/perm indexes must be cleared so a stale + // Cancel can't be routed to the wrong session. + if got := reg.LookupRun("run-1"); got != nil { + t.Fatalf("expected run-1 mapping cleared, got %p", got) + } + if _, err := reg.LookupPermission("perm-1"); !errors.Is(err, ErrPermissionNotRegistered) { + t.Fatalf("expected perm-1 cleared, got %v", err) + } +} + +func TestRegistry_DeregisterPreservesNewer(t *testing.T) { + reg := NewRegistry() + old := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + reg.Register(old) + new := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + reg.Register(new) + // A stale Deregister from the old session (e.g. its read loop + // wakes after preemption) must NOT remove the new session. + reg.Deregister(old) + got, err := reg.LookupDevice("dev-1") + if err != nil || got != new { + t.Fatalf("new session evicted by stale Deregister: got=%p err=%v", got, err) + } +} + +// ---- session tests -------------------------------------------------- + +func TestSession_DispatchDeliversToSubscriber(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Start() + defer sess.Close("test done") + + ch, err := sess.Subscribe("run-1") + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + env, _ := proto.NewEnvelope(proto.TypeDelta, "run-1", proto.DeltaPayload{Delta: "hi", Sequence: 1}) + raw, _ := jsonMarshal(env) + conn.Feed(raw) + + select { + case got := <-ch: + if got.Type != proto.TypeDelta || got.ID != "run-1" { + t.Fatalf("unexpected env: %+v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("subscriber never received delta") + } +} + +func TestSession_DoneFrameAutoUnsubscribes(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Start() + defer sess.Close("test done") + + ch, _ := sess.Subscribe("run-1") + env, _ := proto.NewEnvelope(proto.TypeDone, "run-1", proto.DonePayload{Content: "ok"}) + raw, _ := jsonMarshal(env) + conn.Feed(raw) + + // Drain the done, then expect the channel to be closed by the + // auto-unsubscribe path. + deadline := time.After(2 * time.Second) + gotDone := false + for { + select { + case env, ok := <-ch: + if !ok { + if !gotDone { + t.Fatal("channel closed without delivering done envelope") + } + return + } + if env.Type == proto.TypeDone { + gotDone = true + } + case <-deadline: + t.Fatal("subscriber channel never closed after done") + } + } +} + +func TestSession_PermissionRequestIndexedInRegistry(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Start() + defer sess.Close("test done") + + ch, err := sess.Subscribe("run-1") + if err != nil { + t.Fatalf("subscribe: %v", err) + } + env, _ := proto.NewEnvelope(proto.TypePermissionRequest, "run-1", proto.PermissionRequestPayload{ + RequestID: "perm-abc", + Tool: "Bash", + Title: "rm -rf /tmp/scratch", + }) + raw, _ := jsonMarshal(env) + conn.Feed(raw) + + // Poll because the read loop is async. + deadline := time.Now().Add(2 * time.Second) + for { + if got, err := reg.LookupPermission("perm-abc"); err == nil && got == sess { + break + } + if time.Now().After(deadline) { + t.Fatal("perm-abc never indexed in registry") + } + time.Sleep(10 * time.Millisecond) + } + + select { + case got := <-ch: + if got.ID != "run-1" || got.Type != proto.TypePermissionRequest { + t.Fatalf("subscriber received %+v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("run subscriber never received permission request") + } +} + +func TestSession_CloseFansSyntheticErrorAndDone(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Start() + + ch, _ := sess.Subscribe("run-1") + sess.Close("simulated drop") + + gotErr, gotDone := false, false + deadline := time.After(2 * time.Second) + for !gotErr || !gotDone { + select { + case env, ok := <-ch: + if !ok { + if !gotErr || !gotDone { + t.Fatalf("channel closed before delivering synthetic error+done (gotErr=%v gotDone=%v)", gotErr, gotDone) + } + return + } + switch env.Type { + case proto.TypeError: + gotErr = true + case proto.TypeDone: + gotDone = true + } + case <-deadline: + t.Fatalf("synthetic frames not delivered (gotErr=%v gotDone=%v)", gotErr, gotDone) + } + } +} + +func TestSession_SendOnClosedReturnsError(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Close("never started") + env, _ := proto.NewEnvelope(proto.TypePromptCancel, "run-1", nil) + err := sess.Send(context.Background(), env) + if !errors.Is(err, ErrSessionClosed) { + t.Fatalf("expected ErrSessionClosed, got %v", err) + } +} + +func TestSession_SendWritesToWire(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.Start() + defer sess.Close("test done") + + env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{ + AgentKind: "claude_code", + RunID: "run-1", + Prompt: "hello", + }) + if err := sess.Send(context.Background(), env); err != nil { + t.Fatalf("Send: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for { + if len(conn.Writes()) > 0 { + return + } + if time.Now().After(deadline) { + t.Fatal("write loop never flushed envelope to wire") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + heartbeat := newFakeHeartbeatStore() + sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) + sess.heartbeat = heartbeat + sess.Start() + defer sess.Close("test done") + + env, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{ + Timestamp: 1710000000, + ActiveRequests: 2, + DaemonVersion: "0.2.0-test", + SupportedAgentKinds: []proto.SupportedAgentKind{ + { + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + }, + }, + { + Kind: "claude_code", + Available: true, + Version: "1.2.3", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + { + Kind: "codex", + Available: true, + Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, Steering: true, MessageItems: true, ToolItems: true, ToolObservations: true, EnvironmentNone: true, RemoteEnvironment: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true}, + }, + }, + }) + raw, _ := jsonMarshal(env) + conn.Feed(raw) + + got := heartbeat.waitDaemonHeartbeat(t) + if got.RuntimeID != "dev-1" || got.DaemonVersion != "0.2.0-test" || got.ActiveRequests != 2 || got.HeartbeatTimestamp != 1710000000 { + t.Fatalf("heartbeat metadata not preserved: %+v", got) + } + if len(got.SupportedAgentKinds) != 3 { + t.Fatalf("SupportedAgentKinds len = %d, want 3: %#v", len(got.SupportedAgentKinds), got.SupportedAgentKinds) + } + byKind := map[string]device.SupportedAgentKind{} + for _, info := range got.SupportedAgentKinds { + byKind[info.Kind] = info + } + claude := byKind["claude_code"] + if !claude.Available || claude.Version != "1.2.3" || !claude.Capabilities.Permissions || !claude.Capabilities.Usage || !claude.Capabilities.Resume { + t.Fatalf("claude_code descriptor not converted: %#v", claude) + } + opencode := byKind["opencode"] + if opencode.Available || opencode.Version != "missing" || !opencode.Capabilities.Streaming { + t.Fatalf("opencode descriptor not converted: %#v", opencode) + } + if !byKind["codex"].Capabilities.RemoteEnvironment || claude.Capabilities.RemoteEnvironment || opencode.Capabilities.RemoteEnvironment || !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || opencode.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || opencode.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || opencode.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || opencode.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || opencode.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || opencode.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.ToolItems || claude.Capabilities.ToolItems || opencode.Capabilities.ToolItems || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || opencode.Capabilities.Steering { + t.Fatalf("steering capability not preserved: %#v", byKind) + } + if !byKind["codex"].Capabilities.MCPHTTPTools || claude.Capabilities.MCPHTTPTools || opencode.Capabilities.MCPHTTPTools { + t.Fatalf("HTTP MCP capability not preserved: %#v", byKind) + } + codex, found, known := sess.AgentKindStatus("codex") + if !found || !known || !codex.Capabilities.Steering || !codex.Capabilities.MCPHTTPTools { + t.Fatalf("steering capability absent from live session: %#v", codex) + } +} + +func TestSession_HeartbeatInfersClaudeCodeFromLegacyFlag(t *testing.T) { + reg := NewRegistry() + conn := newFakeConn() + heartbeat := newFakeHeartbeatStore() + sess := NewSession(conn, "dev-legacy", "wks-1", "0.1.0", reg, nil) + sess.heartbeat = heartbeat + sess.Start() + defer sess.Close("test done") + + env, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{ + Timestamp: 1710000100, + DaemonVersion: "0.1.0-old", + ClaudeAvailable: true, + }) + raw, _ := jsonMarshal(env) + conn.Feed(raw) + + got := heartbeat.waitDaemonHeartbeat(t) + if len(got.SupportedAgentKinds) != 1 { + t.Fatalf("SupportedAgentKinds len = %d, want 1: %#v", len(got.SupportedAgentKinds), got.SupportedAgentKinds) + } + claude := got.SupportedAgentKinds[0] + if claude.Kind != "claude_code" || !claude.Available || !claude.Capabilities.Streaming || !claude.Capabilities.Permissions || !claude.Capabilities.Usage || !claude.Capabilities.Resume { + t.Fatalf("legacy claude_available fallback not inferred: %#v", claude) + } + if claude.Capabilities.Steering { + t.Fatal("legacy daemon must not advertise steering") + } +} + +// jsonMarshal aliases encoding/json.Marshal so call sites read cleanly. +func jsonMarshal(v any) ([]byte, error) { + return json.Marshal(v) +} diff --git a/internal/agentdaemon/gateway/subscription.go b/internal/agentdaemon/gateway/subscription.go new file mode 100644 index 000000000..71bc9c1fc --- /dev/null +++ b/internal/agentdaemon/gateway/subscription.go @@ -0,0 +1,126 @@ +package gateway + +import ( + "errors" + "fmt" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var ErrSubscriberOverflow = errors.New("execution subscriber buffer overflow") + +type Subscription struct { + Events <-chan proto.Envelope + ch chan proto.Envelope + mu sync.Mutex + err error + closed bool + durable bool +} + +func (s *Subscription) Err() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.err +} + +func (s *Subscription) closeLocked(err error) { + if !s.closed { + s.err, s.closed = err, true + close(s.ch) + } +} + +// Subscribe retains the product's best-effort stream behavior. +func (s *Session) Subscribe(runID string) (<-chan proto.Envelope, error) { + sub, err := s.subscribe(runID, false) + if err != nil { + return nil, err + } + return sub.Events, nil +} + +// SubscribeDurable fails the subscription on overflow instead of losing events silently. +func (s *Session) SubscribeDurable(runID string) (*Subscription, error) { + return s.subscribe(runID, true) +} + +func (s *Session) subscribe(runID string, durable bool) (*Subscription, error) { + if runID == "" { + return nil, fmt.Errorf("agentdaemon gateway: Subscribe requires non-empty runID") + } + capacity := 32 + if durable { + capacity = 256 + } + ch := make(chan proto.Envelope, capacity) + sub := &Subscription{Events: ch, ch: ch, durable: durable} + s.subsMu.Lock() + defer s.subsMu.Unlock() + if s.IsClosed() { + return nil, ErrSessionClosed + } + if existing := s.subs[runID]; existing != nil { + existing.mu.Lock() + existing.closeLocked(ErrSessionClosed) + existing.mu.Unlock() + } + s.subs[runID] = sub + s.reg.AttachRun(runID, s) + return sub, nil +} + +func (s *Session) Unsubscribe(runID string) { + s.subsMu.Lock() + defer s.subsMu.Unlock() + if sub := s.subs[runID]; sub != nil { + sub.mu.Lock() + sub.closeLocked(nil) + sub.mu.Unlock() + delete(s.subs, runID) + } + s.reg.DetachRun(runID) +} + +func (s *Session) closeSubscription(runID string, sub *Subscription, reason string) { + sub.mu.Lock() + defer sub.mu.Unlock() + if sub.closed { + return + } + if !sub.durable { + s.deliverSynthetic(runID, sub.ch, reason) + } + sub.closeLocked(ErrSessionClosed) +} + +func (s *Session) dispatchToSubscriber(env proto.Envelope) { + s.subsMu.Lock() + defer s.subsMu.Unlock() + sub := s.subs[env.ID] + if sub == nil { + return + } + sub.mu.Lock() + defer sub.mu.Unlock() + select { + case sub.ch <- env: + if env.Type == proto.TypeDone { + sub.closeLocked(nil) + } + default: + if sub.durable { + sub.closeLocked(ErrSubscriberOverflow) + } else { + s.log("agentdaemon gateway: subscriber buffer full for run %s, dropping %s", env.ID, env.Type) + if env.Type == proto.TypeDone { + sub.closeLocked(nil) + } + } + } + if sub.closed { + delete(s.subs, env.ID) + s.reg.DetachRun(env.ID) + } +} diff --git a/internal/agentdaemon/gateway/subscription_test.go b/internal/agentdaemon/gateway/subscription_test.go new file mode 100644 index 000000000..ec4215802 --- /dev/null +++ b/internal/agentdaemon/gateway/subscription_test.go @@ -0,0 +1,76 @@ +package gateway + +import ( + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestDurableSubscriptionOverflowIsExplicitAndIsolated(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + defer s.Close("test finished") + sub, err := s.SubscribeDurable("slow") + if err != nil { + t.Fatal(err) + } + other, _ := s.SubscribeDurable("other") + for range 257 { + s.dispatchToSubscriber(proto.Envelope{ID: "slow", Type: proto.TypeDelta}) + } + count := 0 + for range sub.Events { + count++ + } + if count != 256 || !errors.Is(sub.Err(), ErrSubscriberOverflow) { + t.Fatalf("count=%d error=%v", count, sub.Err()) + } + s.dispatchToSubscriber(proto.Envelope{ID: "slow", Type: proto.TypeDone}) + s.dispatchToSubscriber(proto.Envelope{ID: "other", Type: proto.TypeDone}) + if event := <-other.Events; event.Type != proto.TypeDone || other.Err() != nil { + t.Fatal("overflow affected another run") + } +} + +func TestProductSubscriptionRetainsBestEffortBuffer(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + defer s.Close("test finished") + events, _ := s.Subscribe("product") + for range 33 { + s.dispatchToSubscriber(proto.Envelope{ID: "product", Type: proto.TypeDelta}) + } + if len(events) != 32 { + t.Fatal("product buffer changed") + } + for range 32 { + <-events + } + s.dispatchToSubscriber(proto.Envelope{ID: "product", Type: proto.TypeDone}) + if event := <-events; event.Type != proto.TypeDone { + t.Fatal(event.Type) + } + if _, ok := <-events; ok { + t.Fatal("terminal stream not closed") + } +} + +func TestSubscriptionCloseAndDispatchAreSerialized(t *testing.T) { + for range 100 { + s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + sub, _ := s.SubscribeDurable("run") + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + for range 64 { + s.dispatchToSubscriber(proto.Envelope{ID: "run", Type: proto.TypeDelta}) + } + }() + go func() { defer wg.Done(); s.Unsubscribe("run") }() + go func() { defer wg.Done(); s.Close("disconnected") }() + wg.Wait() + for range sub.Events { + } + } +} diff --git a/internal/agentdaemon/gateway/workspace_directory_test.go b/internal/agentdaemon/gateway/workspace_directory_test.go new file mode 100644 index 000000000..edea31d2c --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_directory_test.go @@ -0,0 +1,94 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestWorkspaceDirectorySharesReadCorrelationAndFrameBound(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + request := proto.WorkspaceReadPayload{Handle: "prepared", EnvironmentID: "environment", MaxEntries: proto.WorkspaceDirectoryMaxEntries} + done := make(chan error, 1) + go func() { + _, err := s.ListWorkspaceDirectory(t.Context(), request) + done <- err + }() + message := <-s.sendCh + var sent proto.WorkspaceReadPayload + if message.Type != proto.TypeWorkspaceRead || message.DecodePayload(&sent) != nil || sent.Operation != "directory" || sent.MaxBytes != 0 || sent.MaxEntries != request.MaxEntries { + t.Fatal("directory request changed") + } + directory := &proto.WorkspaceDirectoryResult{Entries: make([]proto.WorkspaceDirectoryEntry, request.MaxEntries), Truncated: true} + for i := range directory.Entries { + directory.Entries[i] = proto.WorkspaceDirectoryEntry{Name: strings.Repeat("\x01", 250) + fmt.Sprintf("%04d", i), Kind: "directory"} + } + result := proto.WorkspaceReadResultPayload{Outcome: "completed", CloseAcknowledged: true, Directory: directory} + reply, _ := proto.NewEnvelope(proto.TypeWorkspaceReadResult, message.ID, result) + encoded, err := json.Marshal(reply) + if err != nil || int64(len(encoded)) >= ReadLimit { + t.Fatal("directory result exceeds frame", len(encoded), err) + } + s.dispatch(reply) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceDirectoryRejectsContradictoryAndUnboundedMetadata(t *testing.T) { + request := proto.WorkspaceReadPayload{Operation: "directory", MaxEntries: 2} + size := int64(0) + valid := proto.WorkspaceDirectoryEntry{Name: "file", Kind: "file", SizeBytes: &size} + for _, entries := range [][]proto.WorkspaceDirectoryEntry{ + nil, {valid, valid}, {{Name: "../other", Kind: "directory"}}, + {{Name: "file", Kind: "file"}}, {{Name: "dir", Kind: "directory", SizeBytes: &size}}, + {{Name: strings.Repeat("x", 256), Kind: "directory"}}, + } { + result := proto.WorkspaceReadResultPayload{Outcome: "completed", CloseAcknowledged: true, Directory: &proto.WorkspaceDirectoryResult{Entries: entries}} + if validWorkspaceOperationResult(result, request) { + t.Fatal("invalid directory metadata accepted") + } + } + result := proto.WorkspaceReadResultPayload{Outcome: "completed", CloseAcknowledged: true, Directory: &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{valid}}} + if !validWorkspaceOperationResult(result, request) || validWorkspaceReadResult(result, 1024) { + t.Fatal("byte and directory result contracts mixed") + } + result.Data = []byte("unexpected bytes") + if validWorkspaceOperationResult(result, request) { + t.Fatal("contradictory result accepted") + } +} + +func TestWorkspaceDirectoryRequiresExplicitWireTruncation(t *testing.T) { + for _, tc := range []struct { + name, directory string + valid bool + }{ + {"omitted", `{"entries":[]}`, false}, + {"null", `{"entries":[],"truncated":null}`, false}, + {"wrong_type", `{"entries":[],"truncated":"false"}`, false}, + {"complete", `{"entries":[],"truncated":false}`, true}, + {"truncated", `{"entries":[{"name":"dir","kind":"directory","size_bytes":null}],"truncated":true}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + done := make(chan error, 1) + go func() { + _, err := s.ListWorkspaceDirectory(t.Context(), proto.WorkspaceReadPayload{Handle: "prepared", EnvironmentID: "environment", MaxEntries: 1}) + done <- err + }() + request := <-s.sendCh + reply := proto.Envelope{Type: proto.TypeWorkspaceReadResult, ID: request.ID, + Payload: json.RawMessage(`{"outcome":"completed","close_acknowledged":true,"directory":` + tc.directory + `}`)} + s.dispatch(reply) + if err := <-done; (err == nil) != tc.valid { + t.Fatal("directory wire validation differs", err) + } + }) + } +} diff --git a/internal/agentdaemon/gateway/workspace_export.go b/internal/agentdaemon/gateway/workspace_export.go new file mode 100644 index 000000000..8f6e5d639 --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_export.go @@ -0,0 +1,129 @@ +package gateway + +import ( + "context" + "errors" + "io" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// ExportWorkspaceOutputs consumes bounded chunks and requires the exporter completion receipt. +func (s *Session) ExportWorkspaceOutputs(ctx context.Context, request proto.WorkspaceExportPayload, consume func(io.Reader) error) error { + request.Step = "begin" + if !proto.ValidWorkspaceExportRequest(request) || consume == nil { + return errors.New("agentdaemon gateway: invalid workspace export") + } + id := uuid.NewString() + s.workspaceExportMu.Lock() + if s.IsClosed() { + s.workspaceExportMu.Unlock() + return ErrSessionClosed + } + if len(s.workspaceExports) != 0 { + s.workspaceExportMu.Unlock() + return errors.New("agentdaemon gateway: workspace export capacity") + } + replies := make(chan proto.Envelope, 1) + s.workspaceExports = map[string]chan proto.Envelope{id: replies} + s.workspaceExportMu.Unlock() + defer func() { s.workspaceExportMu.Lock(); delete(s.workspaceExports, id); s.workspaceExportMu.Unlock() }() + ctx, cancel := context.WithTimeout(ctx, 180*time.Second) + defer cancel() + r := &workspaceExportReader{ctx: ctx, peer: s, id: id, replies: replies, request: request} + defer func() { + if !r.completed { + stop, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + env, _ := proto.NewEnvelope(proto.TypeWorkspaceExport, id, proto.WorkspaceExportPayload{Step: "cancel"}) + _ = s.Send(stop, env) + } + }() + if err := consume(r); err != nil { + return err + } + // Archive decoders may stop at their trailer before the native exit receipt. + _, err := io.Copy(io.Discard, r) + return err +} + +type workspaceExportReader struct { + ctx context.Context + peer *Session + id string + replies <-chan proto.Envelope + request proto.WorkspaceExportPayload + data []byte + offset int64 + completed bool +} + +func (r *workspaceExportReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if len(r.data) > 0 { + n := copy(p, r.data) + r.data = r.data[n:] + return n, nil + } + if r.completed { + return 0, io.EOF + } + env, err := proto.NewEnvelope(proto.TypeWorkspaceExport, r.id, r.request) + if err != nil { + return 0, err + } + if err = r.peer.Send(r.ctx, env); err != nil { + return 0, err + } + select { + case env, ok := <-r.replies: + if !ok { + return 0, ErrSessionClosed + } + var result proto.WorkspaceExportResultPayload + if len(env.Payload) > proto.WorkspaceExportMaxFrameBytes || env.DecodePayload(&result) != nil || result.Offset != r.offset { + return 0, errors.New("agentdaemon gateway: invalid export receipt") + } + if result.Outcome == "completed" && len(result.Data) == 0 && result.ErrorCode == "" { + r.completed = true + return 0, io.EOF + } + if result.Outcome != "chunk" || result.ErrorCode != "" || len(result.Data) == 0 || len(result.Data) > proto.WorkspaceExportChunkBytes || int64(len(result.Data)) > proto.WorkspaceExportMaxBytes-r.offset { + return 0, errors.New("agentdaemon gateway: workspace export incomplete") + } + r.data = result.Data + r.offset += int64(len(result.Data)) + r.request = proto.WorkspaceExportPayload{Step: "next", Offset: r.offset} + return r.Read(p) + case <-r.ctx.Done(): + return 0, r.ctx.Err() + case <-r.peer.closed: + return 0, ErrSessionClosed + } +} + +func (s *Session) dispatchWorkspaceExport(env proto.Envelope) { + s.workspaceExportMu.Lock() + defer s.workspaceExportMu.Unlock() + if replies := s.workspaceExports[env.ID]; replies != nil { + select { + case replies <- env: + default: + close(replies) + delete(s.workspaceExports, env.ID) + } + } +} + +func (s *Session) closeWorkspaceExports() { + s.workspaceExportMu.Lock() + defer s.workspaceExportMu.Unlock() + for id, replies := range s.workspaceExports { + close(replies) + delete(s.workspaceExports, id) + } +} diff --git a/internal/agentdaemon/gateway/workspace_export_test.go b/internal/agentdaemon/gateway/workspace_export_test.go new file mode 100644 index 000000000..73a289578 --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_export_test.go @@ -0,0 +1,121 @@ +package gateway + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func exportReply(t *testing.T, s *Session, id string, result proto.WorkspaceExportResultPayload) { + t.Helper() + env, err := proto.NewEnvelope(proto.TypeWorkspaceExportResult, id, result) + if err != nil { + t.Fatal(err) + } + s.dispatch(env) +} + +func TestWorkspaceExportPullsOnlyAfterConsumedAndRequiresCompletion(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + consumed, resume := make(chan struct{}), make(chan struct{}) + done := make(chan error, 1) + body := bytes.Repeat([]byte{0, 255, 7}, 100) + go func() { + done <- s.ExportWorkspaceOutputs(t.Context(), proto.WorkspaceExportPayload{Handle: "prepared", EnvironmentID: "env"}, func(r io.Reader) error { + got := make([]byte, len(body)) + if _, err := io.ReadFull(r, got); err != nil { + return err + } + if !bytes.Equal(got, body) { + return errors.New("bytes differ") + } + close(consumed) + <-resume + return nil + }) + }() + first := <-s.sendCh + exportReply(t, s, "foreign", proto.WorkspaceExportResultPayload{Outcome: "completed"}) + exportReply(t, s, first.ID, proto.WorkspaceExportResultPayload{Outcome: "chunk", Data: body}) + <-consumed + select { + case env := <-s.sendCh: + t.Fatalf("premature next chunk: %s", env.Type) + default: + } + close(resume) + next := <-s.sendCh + var request proto.WorkspaceExportPayload + if next.DecodePayload(&request) != nil || request.Step != "next" || request.Offset != int64(len(body)) { + t.Fatal("bad continuation", request) + } + select { + case err := <-done: + t.Fatal("succeeded before native completion", err) + default: + } + exportReply(t, s, next.ID, proto.WorkspaceExportResultPayload{Outcome: "completed", Offset: int64(len(body))}) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceExportRejectsInvalidChunksAndNativeFailure(t *testing.T) { + for _, result := range []proto.WorkspaceExportResultPayload{ + {Outcome: "chunk", Offset: 1, Data: []byte("x")}, + {Outcome: "chunk", Data: make([]byte, proto.WorkspaceExportChunkBytes+1)}, + {Outcome: "chunk"}, + {Outcome: "completed", Data: []byte("x")}, + {Outcome: "failed", ErrorCode: "export_failed"}, + } { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + done := make(chan error, 1) + go func() { + done <- s.ExportWorkspaceOutputs(t.Context(), proto.WorkspaceExportPayload{Handle: "prepared", EnvironmentID: "env"}, func(r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }) + }() + first := <-s.sendCh + exportReply(t, s, first.ID, result) + if err := <-done; err == nil { + t.Fatal("invalid export accepted", result.Outcome) + } + cancel := <-s.sendCh + var request proto.WorkspaceExportPayload + if cancel.DecodePayload(&request) != nil || request.Step != "cancel" { + t.Fatal("missing cleanup request") + } + s.Close("test") + } +} + +func TestWorkspaceExportDisconnectAndCancellationSettleRead(t *testing.T) { + for _, disconnect := range []bool{false, true} { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- s.ExportWorkspaceOutputs(ctx, proto.WorkspaceExportPayload{Handle: "prepared", EnvironmentID: "env"}, func(r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }) + }() + <-s.sendCh + if disconnect { + s.Close("lost") + } else { + cancel() + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted export succeeded") + } + case <-time.After(time.Second): + t.Fatal("read did not settle") + } + cancel() + s.Close("test") + } +} diff --git a/internal/agentdaemon/gateway/workspace_read.go b/internal/agentdaemon/gateway/workspace_read.go new file mode 100644 index 000000000..f50c817c2 --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_read.go @@ -0,0 +1,122 @@ +package gateway + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// ReadWorkspaceFile observes one private operation; cancellation never retries or cancels native work. +func (s *Session) ReadWorkspaceFile(ctx context.Context, request proto.WorkspaceReadPayload) (proto.WorkspaceReadResultPayload, error) { + if request.Operation != "" { + return proto.WorkspaceReadResultPayload{}, errors.New("agentdaemon gateway: invalid byte read operation") + } + return s.readWorkspace(ctx, request) +} + +func (s *Session) ListWorkspaceDirectory(ctx context.Context, request proto.WorkspaceReadPayload) (proto.WorkspaceReadResultPayload, error) { + request.Operation = "directory" + return s.readWorkspace(ctx, request) +} + +func (s *Session) readWorkspace(ctx context.Context, request proto.WorkspaceReadPayload) (proto.WorkspaceReadResultPayload, error) { + var result proto.WorkspaceReadResultPayload + if !proto.ValidWorkspaceReadRequest(request) { + return result, errors.New("agentdaemon gateway: invalid workspace read") + } + id := uuid.NewString() + env, err := proto.NewEnvelope(proto.TypeWorkspaceRead, id, request) + if err != nil || len(env.Payload) > proto.WorkspaceReadMaxRequestBytes { + return result, errors.New("agentdaemon gateway: invalid workspace read") + } + s.workspaceReadMu.Lock() + if s.IsClosed() { + s.workspaceReadMu.Unlock() + return result, ErrSessionClosed + } + if len(s.workspaceReads) >= 4 { + s.workspaceReadMu.Unlock() + return result, errors.New("agentdaemon gateway: workspace read capacity") + } + if s.workspaceReads == nil { + s.workspaceReads = make(map[string]chan proto.Envelope) + } + replies := make(chan proto.Envelope, 1) + s.workspaceReads[id] = replies + s.workspaceReadMu.Unlock() + defer func() { s.workspaceReadMu.Lock(); delete(s.workspaceReads, id); s.workspaceReadMu.Unlock() }() + ctx, cancel := context.WithTimeout(ctx, 17*time.Second) + defer cancel() + if err = s.Send(ctx, env); err != nil { + return result, err + } + select { + case reply, ok := <-replies: + if !ok { + return result, ErrSessionClosed + } + if reply.DecodePayload(&result) != nil || !validWorkspaceOperationResult(result, request) { + return proto.WorkspaceReadResultPayload{}, errors.New("agentdaemon gateway: invalid workspace read response") + } + return result, nil + case <-ctx.Done(): + return result, ctx.Err() + case <-s.closed: + return result, ErrSessionClosed + } +} + +func validWorkspaceOperationResult(result proto.WorkspaceReadResultPayload, request proto.WorkspaceReadPayload) bool { + if request.Operation == "directory" && result.Outcome == "completed" { + return result.CloseAcknowledged && result.ErrorCode == "" && len(result.Data) == 0 && !result.Truncated && proto.ValidWorkspaceDirectory(result.Directory, request.MaxEntries) + } + return validWorkspaceReadResult(result, request.MaxBytes) +} + +func validWorkspaceReadResult(result proto.WorkspaceReadResultPayload, limit int) bool { + if result.Directory != nil { + return false + } + if result.Outcome == "completed" { + return result.CloseAcknowledged && result.ErrorCode == "" && len(result.Data) <= limit && + (!result.Truncated || len(result.Data) == limit) + } + if len(result.Data) != 0 || result.Truncated || result.CloseAcknowledged { + return false + } + if result.Outcome == "unknown" { + return result.ErrorCode == "read_unconfirmed" + } + if result.Outcome != "rejected" { + return false + } + switch result.ErrorCode { + case "invalid_request", "resource_unavailable", "read_capacity", "read_unsupported", "not_found", "permission_denied": + return true + default: + return false + } +} + +func (s *Session) dispatchWorkspaceRead(env proto.Envelope) { + s.workspaceReadMu.Lock() + defer s.workspaceReadMu.Unlock() + if replies := s.workspaceReads[env.ID]; replies != nil { + select { + case replies <- env: + default: + } + } +} + +func (s *Session) closeWorkspaceReads() { + s.workspaceReadMu.Lock() + defer s.workspaceReadMu.Unlock() + for id, replies := range s.workspaceReads { + close(replies) + delete(s.workspaceReads, id) + } +} diff --git a/internal/agentdaemon/gateway/workspace_read_test.go b/internal/agentdaemon/gateway/workspace_read_test.go new file mode 100644 index 000000000..21a9b53ab --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_read_test.go @@ -0,0 +1,147 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func workspaceReadRequest() proto.WorkspaceReadPayload { + return proto.WorkspaceReadPayload{Handle: "prepared", EnvironmentID: "environment", Path: "file", MaxBytes: proto.WorkspaceReadMaxBytes} +} + +func TestWorkspaceReadCorrelatesOneBoundedResult(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + done := make(chan error, 1) + data := bytes.Repeat([]byte{0, 127, 255, 3}, proto.WorkspaceReadMaxBytes/4) + go func() { + result, err := s.ReadWorkspaceFile(t.Context(), workspaceReadRequest()) + if err == nil && (!bytes.Equal(result.Data, data) || !result.Truncated || !result.CloseAcknowledged) { + err = errors.New("read data or acknowledgment differs") + } + done <- err + }() + request := <-s.sendCh + result := proto.WorkspaceReadResultPayload{Outcome: "completed", Data: data, Truncated: true, CloseAcknowledged: true} + foreign, _ := proto.NewEnvelope(proto.TypeWorkspaceReadResult, "other-operation", result) + s.dispatch(foreign) + reply, _ := proto.NewEnvelope(proto.TypeWorkspaceReadResult, request.ID, result) + encoded, err := json.Marshal(reply) + if err != nil || int64(len(encoded)) >= ReadLimit { + t.Fatal("result exceeds existing transport frame", err, len(encoded)) + } + s.dispatch(reply) + if err := <-done; err != nil { + t.Fatal(err) + } + if s.reg.LookupRun(request.ID) != nil { + t.Fatal("read registered a synthetic Run") + } +} + +func TestWorkspaceReadRejectsIncompleteOrContradictoryReplies(t *testing.T) { + for _, result := range []proto.WorkspaceReadResultPayload{ + {Outcome: "completed", Data: []byte("x")}, + {Outcome: "completed", CloseAcknowledged: true, Truncated: true}, + {Outcome: "completed", CloseAcknowledged: true, ErrorCode: "not_found"}, + {Outcome: "unknown", ErrorCode: "read_unconfirmed", Data: []byte("partial")}, + {Outcome: "rejected", ErrorCode: "native-private-secret"}, + } { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + done := make(chan error, 1) + go func() { _, err := s.ReadWorkspaceFile(t.Context(), workspaceReadRequest()); done <- err }() + request := <-s.sendCh + reply, _ := proto.NewEnvelope(proto.TypeWorkspaceReadResult, request.ID, result) + s.dispatch(reply) + if err := <-done; err == nil { + t.Fatal("invalid result accepted", result.Outcome) + } + s.Close("test") + } +} + +func TestWorkspaceReadObserverCancellationDoesNotSendCancelOrRetry(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { _, err := s.ReadWorkspaceFile(ctx, workspaceReadRequest()); done <- err }() + request := <-s.sendCh + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + reply, _ := proto.NewEnvelope(proto.TypeWorkspaceReadResult, request.ID, proto.WorkspaceReadResultPayload{Outcome: "unknown", ErrorCode: "read_unconfirmed"}) + s.dispatch(reply) + select { + case extra := <-s.sendCh: + t.Fatal("observer caused another control", extra.Type) + default: + } + s.workspaceReadMu.Lock() + count := len(s.workspaceReads) + s.workspaceReadMu.Unlock() + if count != 0 { + t.Fatal("observer subscription leaked") + } +} + +func TestWorkspaceReadCapacityAndConnectionLoss(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + done := make(chan error, 4) + for i := 0; i < 4; i++ { + go func() { _, err := s.ReadWorkspaceFile(t.Context(), workspaceReadRequest()); done <- err }() + select { + case <-s.sendCh: + case <-time.After(time.Second): + t.Fatal("read not sent") + } + } + if _, err := s.ReadWorkspaceFile(t.Context(), workspaceReadRequest()); err == nil { + t.Fatal("capacity bypassed") + } + s.Close("connection lost") + for i := 0; i < 4; i++ { + if err := <-done; !errors.Is(err, ErrSessionClosed) { + t.Fatal(err) + } + } +} + +func TestWorkspaceReadRejectsOversizedRequestsBeforeQueueing(t *testing.T) { + for _, field := range []string{"path", "handle", "environment", "escaped"} { + t.Run(field, func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + request := workspaceReadRequest() + oversized := strings.Repeat("x", int(ReadLimit)) + switch field { + case "path": + request.Path = oversized + case "handle": + request.Handle = oversized + case "environment": + request.EnvironmentID = oversized + case "escaped": + request.Path = strings.Repeat("\x00", proto.WorkspaceReadMaxRequestBytes/2) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if _, err := s.ReadWorkspaceFile(ctx, request); err == nil || errors.Is(err, context.DeadlineExceeded) { + t.Fatal("oversized request not rejected before send", err) + } + select { + case <-s.sendCh: + t.Fatal("oversized request queued") + default: + } + }) + } +} diff --git a/internal/agentdaemon/gateway/workspace_write.go b/internal/agentdaemon/gateway/workspace_write.go new file mode 100644 index 000000000..42631b5ea --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_write.go @@ -0,0 +1,123 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// WriteWorkspaceFile sends an already durably owned mutation once. A transport +// error is not a rejection and must retain the caller's unknown-outcome gate. +func (s *Session) WriteWorkspaceFile(ctx context.Context, id string, request proto.WorkspaceWritePayload, data []byte) (proto.WorkspaceWriteResultPayload, error) { + var empty proto.WorkspaceWriteResultPayload + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil || parsed.String() != id || len(data) > proto.WorkspaceWriteMaxBytes { + return empty, errors.New("agentdaemon gateway: invalid workspace write") + } + digest := sha256.Sum256(data) + request.Step, request.SizeBytes, request.SHA256 = "begin", len(data), hex.EncodeToString(digest[:]) + if !proto.ValidWorkspaceWriteRequest(request) { + return empty, errors.New("agentdaemon gateway: invalid workspace write") + } + s.workspaceWriteMu.Lock() + if s.IsClosed() { + s.workspaceWriteMu.Unlock() + return empty, ErrSessionClosed + } + if len(s.workspaceWrites) != 0 { + s.workspaceWriteMu.Unlock() + return empty, errors.New("agentdaemon gateway: workspace write capacity") + } + replies := make(chan proto.Envelope, 1) + s.workspaceWrites = map[string]chan proto.Envelope{id: replies} + s.workspaceWriteMu.Unlock() + defer func() { s.workspaceWriteMu.Lock(); delete(s.workspaceWrites, id); s.workspaceWriteMu.Unlock() }() + ctx, cancel := context.WithTimeout(ctx, 195*time.Second) + defer cancel() + exchange := func(payload proto.WorkspaceWritePayload, outcome string, offset int) (proto.WorkspaceWriteResultPayload, error) { + env, err := proto.NewEnvelope(proto.TypeWorkspaceWrite, id, payload) + if err != nil || len(env.Payload) > proto.WorkspaceWriteMaxFrameBytes { + return empty, errors.New("agentdaemon gateway: invalid write frame") + } + if err := s.Send(ctx, env); err != nil { + return empty, err + } + select { + case env, ok := <-replies: + if !ok { + return empty, ErrSessionClosed + } + var result proto.WorkspaceWriteResultPayload + if env.DecodePayload(&result) != nil || !validWorkspaceWriteResult(result, outcome, offset, len(data)) { + return empty, errors.New("agentdaemon gateway: invalid write receipt") + } + return result, nil + case <-ctx.Done(): + return empty, ctx.Err() + case <-s.closed: + return empty, ErrSessionClosed + } + } + result, err := exchange(request, "ready", 0) + if err != nil || result.Outcome != "ready" { + return result, err + } + for offset := 0; offset < len(data); { + end := min(offset+proto.WorkspaceWriteChunkBytes, len(data)) + result, err = exchange(proto.WorkspaceWritePayload{Step: "chunk", Offset: offset, Data: data[offset:end]}, "received", end) + if err != nil || result.Outcome != "received" { + return result, err + } + offset = end + } + return exchange(proto.WorkspaceWritePayload{Step: "commit"}, "completed", 0) +} + +func validWorkspaceWriteResult(r proto.WorkspaceWriteResultPayload, expected string, offset, size int) bool { + if r.Outcome == "rejected" { + if r.Offset != 0 || r.SizeBytes != 0 { + return false + } + switch r.ErrorCode { + case "invalid_request", "resource_unavailable", "write_capacity", "write_unsupported", "write_rejected": + return true + default: + return false + } + } + if r.Outcome == "unknown" { + return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "write_unconfirmed" + } + if r.Outcome != expected || r.Offset != offset || r.ErrorCode != "" { + return false + } + if expected == "completed" { + return r.SizeBytes == size + } + return r.SizeBytes == 0 +} + +func (s *Session) dispatchWorkspaceWrite(env proto.Envelope) { + s.workspaceWriteMu.Lock() + defer s.workspaceWriteMu.Unlock() + if replies := s.workspaceWrites[env.ID]; replies != nil { + select { + case replies <- env: + default: + } + } +} + +func (s *Session) closeWorkspaceWrites() { + s.workspaceWriteMu.Lock() + defer s.workspaceWriteMu.Unlock() + for id, replies := range s.workspaceWrites { + close(replies) + delete(s.workspaceWrites, id) + } +} diff --git a/internal/agentdaemon/gateway/workspace_write_test.go b/internal/agentdaemon/gateway/workspace_write_test.go new file mode 100644 index 000000000..4dc98b5ee --- /dev/null +++ b/internal/agentdaemon/gateway/workspace_write_test.go @@ -0,0 +1,105 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestWorkspaceWriteChunksAndCorrelatesReceipt(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + data := bytes.Repeat([]byte{0, 255, 3}, 400000) + id := uuid.NewString() + request := proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file"} + done := make(chan error, 1) + go func() { + result, err := s.WriteWorkspaceFile(t.Context(), id, request, data) + if err == nil && (result.Outcome != "completed" || result.SizeBytes != len(data)) { + err = errors.New("missing commit") + } + done <- err + }() + var received []byte + for { + env := <-s.sendCh + var p proto.WorkspaceWritePayload + if env.ID != id || env.Type != proto.TypeWorkspaceWrite || len(env.Payload) > proto.WorkspaceWriteMaxFrameBytes || env.DecodePayload(&p) != nil || !proto.ValidWorkspaceWriteRequest(p) { + t.Fatal("invalid transfer frame") + } + result := proto.WorkspaceWriteResultPayload{} + switch p.Step { + case "begin": + digest := sha256.Sum256(data) + if p.EnvironmentID != request.EnvironmentID || p.SessionID != request.SessionID || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { + t.Fatal("scope or digest changed") + } + result.Outcome = "ready" + case "chunk": + if p.Offset != len(received) { + t.Fatal("noncontiguous chunks") + } + received = append(received, p.Data...) + result.Outcome, result.Offset = "received", len(received) + case "commit": + if !bytes.Equal(data, received) { + t.Fatal("bytes differ") + } + result.Outcome, result.SizeBytes = "completed", len(data) + } + foreign, _ := proto.NewEnvelope(proto.TypeWorkspaceWriteResult, uuid.NewString(), result) + s.dispatch(foreign) + reply, _ := proto.NewEnvelope(proto.TypeWorkspaceWriteResult, id, result) + s.dispatch(reply) + if p.Step == "commit" { + break + } + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceWriteDoesNotCommitAfterLostObservation(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, err := s.WriteWorkspaceFile(ctx, uuid.NewString(), proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file"}, []byte("value")) + done <- err + }() + <-s.sendCh + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + select { + case env := <-s.sendCh: + t.Fatal("lost observer sent extra frame", env.Type) + default: + } +} + +func TestWorkspaceWriteRejectsPrematureOrContradictoryReceipts(t *testing.T) { + for _, result := range []proto.WorkspaceWriteResultPayload{ + {Outcome: "completed", SizeBytes: 4}, + {Outcome: "ready", SizeBytes: 4}, + {Outcome: "ready", Offset: 1}, + {Outcome: "rejected", ErrorCode: "private-detail"}, + {Outcome: "unknown", ErrorCode: "write_unconfirmed", SizeBytes: 4}, + } { + if validWorkspaceWriteResult(result, "ready", 0, 4) { + t.Fatal("unsafe result", result) + } + } + if validWorkspaceWriteResult(proto.WorkspaceWriteResultPayload{Outcome: "received", Offset: 1}, "received", 2, 4) { + t.Fatal("wrong offset accepted") + } +} diff --git a/internal/agentdaemon/placement/controller_linux.go b/internal/agentdaemon/placement/controller_linux.go new file mode 100644 index 000000000..e0409725f --- /dev/null +++ b/internal/agentdaemon/placement/controller_linux.go @@ -0,0 +1,211 @@ +//go:build linux + +package placement + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "time" +) + +var fullID = regexp.MustCompile(`^[a-f0-9]{64}$`) +var ownerID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) + +// Controller is the bounded local Linux/Docker retirement consumer. Its authority +// is the operator's private enrollment, never a public executor credential. +type Controller struct { + root string + run func(context.Context, ...string) ([]byte, error) + procRoot string + cgroupRoot string + socketPath string + syncDir func(string) error +} + +// New uses a fixed local Docker endpoint and private state beneath ~/.parsar. +func New() (*Controller, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + return &Controller{root: filepath.Join(home, ".parsar", "placements"), run: runDocker, + procRoot: "/proc", cgroupRoot: "/sys/fs/cgroup", socketPath: localDockerSocket, syncDir: syncDirectory}, nil +} + +func (c *Controller) enroll(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { + if !ownerID.MatchString(owner) { + return nil, errors.New("invalid placement owner") + } + unlock, err := c.lock(ctx, id) + if err != nil { + return nil, err + } + defer unlock() + if _, err := os.Lstat(c.recordPath(id)); !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("placement already enrolled or record unavailable; use retire to reconcile") + } + unit, err := c.inspect(ctx, id) + if err != nil { + return nil, err + } + if !unit.State.Running { + return nil, errors.New("enrollment requires a running placement") + } + if err := c.validateProfile(unit, owner, workspace); err != nil { + return nil, err + } + host, supervisor, err := c.host(ctx) + if err != nil { + return nil, err + } + init, _, err := c.process(unit.State.Pid) + if err != nil { + return nil, err + } + group, err := c.group(init.PID, id) + if err != nil { + return nil, err + } + members, err := c.members(group) + if err != nil { + return nil, err + } + if len(members) == 0 { + return nil, errors.New("running placement has no observed members") + } + version := 1 + if environment != "" { + version = 2 + } + r := &Receipt{Version: version, EnvironmentID: environment, State: "enrolled", Owner: owner, RequestedAt: time.Now().UTC(), + Members: members, Target: Target{HostBootID: host, Supervisor: supervisor, Container: id, + Created: unit.Created, Started: unit.State.StartedAt, Restarts: unit.RestartCount, + Image: unit.Image, Workspace: workspace, Cgroup: group, Init: init}} + if err := c.save(r); err != nil { + return nil, err + } + return r, nil +} + +func (c *Controller) retirePlacement(ctx context.Context, id, environment string) (*Receipt, error) { + unlock, err := c.lock(ctx, id) + if err != nil { + return nil, err + } + defer unlock() + r, err := c.load(id) + if err != nil { + return nil, err + } + if r.EnvironmentID != environment { + return nil, errors.New("placement Environment does not match enrollment") + } + if r.State == "retired" { + // A previous process may have published the rename without completing + // its directory sync. Finish that barrier before recovering success. + if err := c.syncDir(c.root); err != nil { + return nil, fmt.Errorf("placement receipt durability unknown: %w", err) + } + return r, nil + } + err = c.retire(ctx, r) + if err != nil { + return r, fmt.Errorf("placement retirement unknown: %w", err) + } + return r, nil +} + +func (c *Controller) retire(ctx context.Context, r *Receipt) error { + host, supervisor, err := c.host(ctx) + if err != nil { + return err + } + if host != r.Target.HostBootID || supervisor != r.Target.Supervisor { + return errors.New("local supervisor incarnation changed") + } + unit, err := c.inspect(ctx, r.Target.Container) + if err != nil { + return err + } + if err := c.validateProfile(unit, r.Owner, r.Target.Workspace); err != nil { + return err + } + if unit.Created != r.Target.Created || unit.Image != r.Target.Image || + unit.State.StartedAt != r.Target.Started || unit.RestartCount != r.Target.Restarts { + return errors.New("container incarnation changed") + } + if unit.State.Running { + init, _, err := c.process(unit.State.Pid) + if err != nil { + return err + } + if init != r.Target.Init { + return errors.New("container init identity changed") + } + group, err := c.group(init.PID, unit.ID) + if err != nil || group != r.Target.Cgroup { + return errors.New("container cgroup changed") + } + members, err := c.members(group) + if err != nil { + return err + } + r.Members = append(r.Members, members...) + } + // The immutable target and current members are durable before any stop. + r.State = "stopping" + if err := c.save(r); err != nil { + return err + } + if unit.State.Running { + if _, err := c.run(ctx, "container", "stop", "--time", "1", r.Target.Container); err != nil { + return err + } + } + unit, err = c.inspect(ctx, r.Target.Container) + if err != nil { + return err + } + if unit.State.Running || unit.State.Pid != 0 || unit.State.StartedAt != r.Target.Started || + unit.RestartCount != r.Target.Restarts { + return errors.New("container is live or restarted") + } + if err := c.observeRetired(r); err != nil { + return err + } + // Non-forced removal fails if an external operator restarted the unit. No + // volumes are deleted. A crash between removal and save stays unknown. + if _, err := c.run(ctx, "container", "rm", r.Target.Container); err != nil { + return err + } + r.State = "retired" + now := time.Now().UTC() + r.RetiredAt = &now + if err := c.save(r); err != nil { + r.State = "stopping" + r.RetiredAt = nil + return err + } + return nil +} + +func (c *Controller) host(ctx context.Context) (string, string, error) { + boot, err := os.ReadFile(filepath.Join(c.procRoot, "sys/kernel/random/boot_id")) + if err != nil { + return "", "", err + } + out, err := c.run(ctx, "info", "--format", "{{json .ID}}") + if err != nil { + return "", "", err + } + var id string + if err := json.Unmarshal(out, &id); err != nil || id == "" || len(boot) == 0 { + return "", "", errors.New("missing supervisor identity") + } + return string(boot), id, nil +} diff --git a/internal/agentdaemon/placement/controller_linux_test.go b/internal/agentdaemon/placement/controller_linux_test.go new file mode 100644 index 000000000..6bae21fbf --- /dev/null +++ b/internal/agentdaemon/placement/controller_linux_test.go @@ -0,0 +1,411 @@ +//go:build linux + +package placement + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +const testID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type fixture struct { + c *Controller + unit *container + workspace string + stops, removals int + fail string + t *testing.T +} + +func writeTestFile(t *testing.T, path, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + base := filepath.Join(home, ".parsar", "placement-tests") + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + dir, err := os.MkdirTemp(base, "case-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + f := &fixture{t: t, workspace: filepath.Join(dir, "workspace")} + if err := os.Mkdir(f.workspace, 0700); err != nil { + t.Fatal(err) + } + f.c = &Controller{root: filepath.Join(dir, "state"), procRoot: filepath.Join(dir, "proc"), cgroupRoot: filepath.Join(dir, "cgroup"), socketPath: filepath.Join(dir, "run/docker.sock"), syncDir: syncDirectory} + f.c.run = f.run + writeTestFile(t, f.c.socketPath, "fake supervisor socket") + f.unit = &container{ID: testID, Created: "created-1", Image: "sha256:image"} + f.unit.State.Running = true + f.unit.State.Pid = 123 + f.unit.State.StartedAt = "start-1" + f.unit.Config.User = "1000:1000" + f.unit.Config.Labels = map[string]string{BindingLabel: "owner-1"} + f.unit.HostConfig.NetworkMode = "none" + f.unit.HostConfig.IpcMode = "private" + f.unit.HostConfig.CgroupnsMode = "private" + f.unit.HostConfig.CapDrop = []string{"ALL"} + f.unit.HostConfig.SecurityOpt = []string{"no-new-privileges"} + f.unit.HostConfig.RestartPolicy.Name = "no" + f.unit.Mounts = append(f.unit.Mounts, struct { + Type, Source, Destination, Propagation string + RW bool + }{Type: "bind", Source: f.workspace, Destination: "/workspace", Propagation: "rprivate", RW: true}) + writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-1") + writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n") + writeTestFile(t, filepath.Join(f.c.procRoot, "123/stat"), "123 (native (worker)) S "+strings.Repeat("0 ", 18)+"999 0") + writeTestFile(t, filepath.Join(f.c.procRoot, "123/cgroup"), "0::/docker-"+testID+".scope\n") + writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.procs"), "123\n") + writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.events"), "populated 1\n") + return f +} + +func (f *fixture) run(_ context.Context, args ...string) ([]byte, error) { + if args[0] == "info" { + if f.fail == "supervisor" { + return nil, errors.New("unavailable") + } + return []byte(`"supervisor-1"`), nil + } + switch args[1] { + case "inspect": + if f.unit == nil { + return nil, os.ErrNotExist + } + return json.Marshal([]container{*f.unit}) + case "stop": + r, err := f.c.load(testID) + if err != nil || r.State != "stopping" || len(r.Members) == 0 { + f.t.Fatal("stop before durable intent", err) + } + f.stops++ + if f.fail == "stop" { + return nil, errors.New("stop unavailable") + } + f.unit.State.Running = false + f.unit.State.Pid = 0 + if f.fail != "live" && f.fail != "escaped" { + _ = os.RemoveAll(filepath.Join(f.c.procRoot, "123")) + _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) + } + if f.fail == "escaped" { + _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) + } + return nil, nil + case "rm": + f.removals++ + f.unit = nil + if f.fail == "lost-remove-ack" { + return nil, errors.New("controller lost removal acknowledgement") + } + return nil, nil + } + return nil, errors.New("unexpected Docker command") +} + +func (f *fixture) enroll() *Receipt { + f.t.Helper() + r, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace) + if err != nil { + f.t.Fatal(err) + } + return r +} + +func TestRetirementPersistsBeforeStopAndRecoversIdenticalReceipt(t *testing.T) { + f := newFixture(t) + f.enroll() + writeTestFile(t, filepath.Join(f.workspace, "history"), "retained") + r, err := f.c.Retire(context.Background(), testID) + if err != nil || r.State != "retired" { + t.Fatal(r, err) + } + fresh := *f.c + fresh.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("completed receipt must not need supervisor") + return nil, nil + } + again, err := fresh.Retire(context.Background(), testID) + if err != nil || !reflect.DeepEqual(r, again) { + t.Fatal("receipt changed across controller restart", err) + } + if f.stops != 1 || f.removals != 1 { + t.Fatal("repeated destructive action") + } + if data, err := os.ReadFile(filepath.Join(f.workspace, "history")); err != nil || string(data) != "retained" { + t.Fatal("history lost") + } +} + +func TestUnknownEvidenceNeverRemovesOrReportsRetired(t *testing.T) { + for _, failure := range []string{"supervisor", "stop", "live", "escaped", "missing", "restart", "boot", "lost-remove-ack"} { + t.Run(failure, func(t *testing.T) { + f := newFixture(t) + f.enroll() + f.fail = failure + switch failure { + case "missing": + f.unit = nil + case "restart": + f.unit.State.StartedAt = "start-2" + case "boot": + writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-2") + } + r, err := f.c.Retire(context.Background(), testID) + if err == nil || r.State == "retired" { + t.Fatal("uncertain retirement reported success", r, err) + } + persisted, err := f.c.load(testID) + if err != nil || persisted.State == "retired" { + t.Fatal("uncertainty lost", err) + } + if failure != "lost-remove-ack" && f.removals != 0 { + t.Fatal("removed without proof") + } + if failure == "lost-remove-ack" { + fresh := *f.c + r, err = fresh.Retire(context.Background(), testID) + if err == nil || r.State == "retired" { + t.Fatal("absence manufactured success") + } + } + }) + } +} + +func TestReconcileStoppedTargetAfterInterruptedController(t *testing.T) { + f := newFixture(t) + r := f.enroll() + r.State = "stopping" + if err := f.c.save(r); err != nil { + t.Fatal(err) + } + if _, err := f.run(context.Background(), "container", "stop"); err != nil { + t.Fatal(err) + } + fresh := *f.c + result, err := fresh.Retire(context.Background(), testID) + if err != nil || result.State != "retired" || f.stops != 1 { + t.Fatal(result, err) + } +} + +func TestConcurrentRetirementUsesOneDestructiveAttempt(t *testing.T) { + f := newFixture(t) + f.enroll() + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + fresh := *f.c + r, err := fresh.Retire(context.Background(), testID) + if err != nil || r.State != "retired" { + t.Error(r, err) + } + }() + } + wg.Wait() + if f.stops != 1 || f.removals != 1 { + t.Fatal("duplicate destructive attempts") + } +} + +func TestTargetLockHonorsCancellation(t *testing.T) { + f := newFixture(t) + unlock, err := f.c.lock(context.Background(), testID) + if err != nil { + t.Fatal(err) + } + defer unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if _, err := f.c.lock(ctx, testID); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } +} + +func TestEnrollmentRejectsUnqualifiedAuthorityAndProfile(t *testing.T) { + for _, bad := range []string{"short-id", "wrong-owner", "privileged", "host-pid", "network", "capability", "security", "restart", "workspace", "state-mount", "socket-mount", "relative", "stopped"} { + t.Run(bad, func(t *testing.T) { + f := newFixture(t) + id, owner, workspace := testID, "owner-1", f.workspace + switch bad { + case "short-id": + id = "aaaa" + case "wrong-owner": + owner = "someone-else" + case "privileged": + f.unit.HostConfig.Privileged = true + case "host-pid": + f.unit.HostConfig.PidMode = "host" + case "network": + f.unit.HostConfig.NetworkMode = "bridge" + case "capability": + f.unit.HostConfig.CapAdd = []string{"SYS_ADMIN"} + case "security": + f.unit.HostConfig.SecurityOpt = append(f.unit.HostConfig.SecurityOpt, "seccomp=unconfined") + case "restart": + f.unit.HostConfig.RestartPolicy.Name = "always" + case "workspace": + f.unit.Mounts = nil + case "state-mount": + f.unit.Mounts[0].Source = filepath.Dir(f.c.root) + case "socket-mount": + f.unit.Mounts[0].Type = "volume" + case "relative": + workspace = "workspace" + case "stopped": + f.unit.State.Running = false + } + if _, err := f.c.Enroll(context.Background(), id, owner, workspace); err == nil { + t.Fatal("accepted unqualified enrollment") + } + if f.stops+f.removals != 0 { + t.Fatal("enrollment mutated supervisor") + } + }) + } +} + +func TestUntrustedStateIsRejected(t *testing.T) { + f := newFixture(t) + f.enroll() + if err := os.Chmod(f.c.recordPath(testID), 0644); err != nil { + t.Fatal(err) + } + if _, err := f.c.Retire(context.Background(), testID); err == nil { + t.Fatal("accepted exposed authority") + } + if f.stops+f.removals != 0 { + t.Fatal("mutated supervisor before authorization") + } +} + +func TestBindingCannotBeOverwrittenOrAppliedToAnotherContainer(t *testing.T) { + f := newFixture(t) + f.enroll() + if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { + t.Fatal("binding overwritten") + } + if _, err := f.c.Retire(context.Background(), strings.Repeat("b", 64)); err == nil { + t.Fatal("another target authorized") + } + f.unit.ID = strings.Repeat("b", 64) + if _, err := f.c.Retire(context.Background(), testID); err == nil { + t.Fatal("supervisor target substitution accepted") + } + if f.stops+f.removals != 0 { + t.Fatal("wrong target mutated") + } +} + +func TestSymlinkedStateAndChangedProfileAreRejected(t *testing.T) { + f := newFixture(t) + f.enroll() + path := f.c.recordPath(testID) + if err := os.Rename(path, path+".original"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(path+".original", path); err != nil { + t.Fatal(err) + } + if _, err := f.c.Retire(context.Background(), testID); err == nil { + t.Fatal("symlinked authority accepted") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".original", path); err != nil { + t.Fatal(err) + } + f.unit.HostConfig.Privileged = true + if _, err := f.c.Retire(context.Background(), testID); err == nil { + t.Fatal("changed profile accepted") + } + if f.stops+f.removals != 0 { + t.Fatal("mutated unqualified placement") + } +} + +func TestRootAndCanonicalSupervisorSocketAreNeverExposed(t *testing.T) { + f := newFixture(t) + if !inside("/", f.c.root) || !inside("/", f.c.socketPath) { + t.Fatal("filesystem root hides protected descendants") + } + f.workspace = filepath.Dir(f.c.socketPath) + f.unit.Mounts[0].Source = f.workspace + alias := filepath.Join(filepath.Dir(f.c.root), "socket-alias") + if err := os.Symlink(f.c.socketPath, alias); err != nil { + t.Fatal(err) + } + f.c.socketPath = alias + if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil || !strings.Contains(err.Error(), "supervisor socket") { + t.Fatal("canonical socket exposed", err) + } + if f.stops+f.removals != 0 { + t.Fatal("unqualified supervisor mutated") + } +} + +func TestPublishedReceiptMustCompleteDirectorySyncOnRecovery(t *testing.T) { + f := newFixture(t) + f.enroll() + f.c.syncDir = func(path string) error { + r, err := f.c.load(testID) + if err != nil { + return err + } + if r.State == "retired" { + return errors.New("injected directory sync failure after rename") + } + return syncDirectory(path) + } + if r, err := f.c.Retire(context.Background(), testID); err == nil || r.State == "retired" { + t.Fatal("reported success before durable receipt", r, err) + } + published, err := f.c.load(testID) + if err != nil || published.State != "retired" { + t.Fatal("failure did not exercise published rename", published, err) + } + fresh := *f.c + fresh.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("receipt recovery must not mutate supervisor") + return nil, nil + } + if _, err := fresh.Retire(context.Background(), testID); err == nil { + t.Fatal("recovery skipped durability barrier") + } + synced := false + fresh.syncDir = func(path string) error { synced = true; return syncDirectory(path) } + recovered, err := fresh.Retire(context.Background(), testID) + if err != nil || !synced || !reflect.DeepEqual(recovered, published) { + t.Fatal("durable receipt recovery failed", recovered, err) + } + if f.stops != 1 || f.removals != 1 { + t.Fatal("repeated destructive action") + } +} diff --git a/internal/agentdaemon/placement/controller_other.go b/internal/agentdaemon/placement/controller_other.go new file mode 100644 index 000000000..2bd82f666 --- /dev/null +++ b/internal/agentdaemon/placement/controller_other.go @@ -0,0 +1,24 @@ +//go:build !linux + +package placement + +import ( + "context" + "errors" +) + +// Controller requires the qualified local Linux supervisor profile. +type Controller struct{} + +// New rejects unqualified hosts before any supervisor operation. +func New() (*Controller, error) { + return nil, errors.New("placement retirement requires local Linux/Docker with cgroup v2") +} + +func (*Controller) enroll(context.Context, string, string, string, string) (*Receipt, error) { + return nil, errors.New("unsupported placement host") +} + +func (*Controller) retirePlacement(context.Context, string, string) (*Receipt, error) { + return nil, errors.New("unsupported placement host") +} diff --git a/internal/agentdaemon/placement/docker_linux.go b/internal/agentdaemon/placement/docker_linux.go new file mode 100644 index 000000000..1eb115f18 --- /dev/null +++ b/internal/agentdaemon/placement/docker_linux.go @@ -0,0 +1,164 @@ +//go:build linux + +package placement + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +const localDockerSocket = "/var/run/docker.sock" + +// Do not inherit a remote Docker context while observing local /proc and cgroups. +func runDocker(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "docker", append([]string{"--host", "unix://" + localDockerSocket}, args...)...) + for _, v := range os.Environ() { + if !strings.HasPrefix(v, "DOCKER_") { + cmd.Env = append(cmd.Env, v) + } + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("local Docker %s failed: %w", args[0], err) + } + return out, nil +} + +type container struct { + ID string `json:"Id"` + Created string + Image string + RestartCount int + State struct { + Running bool + Pid int + StartedAt string + Paused bool + Restarting bool + } + Config struct { + Labels map[string]string + User string + } + HostConfig struct { + Privileged bool + PidMode string + IpcMode string + CgroupnsMode string + NetworkMode string + CapAdd []string + CapDrop []string + SecurityOpt []string + Devices []json.RawMessage + DeviceRequests []json.RawMessage + DeviceCgroupRules []string + VolumesFrom []string + RestartPolicy struct{ Name string } + } + Mounts []struct { + Type, Source, Destination, Propagation string + RW bool + } +} + +func (c *Controller) inspect(ctx context.Context, id string) (*container, error) { + out, err := c.run(ctx, "container", "inspect", id) + if err != nil { + return nil, err + } + var units []container + if err := json.Unmarshal(out, &units); err != nil { + return nil, err + } + if len(units) != 1 || units[0].ID != id { + return nil, errors.New("supervisor returned wrong target") + } + return &units[0], nil +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func inside(parent, path string) bool { + relative, err := filepath.Rel(parent, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) +} + +func (c *Controller) validateProfile(u *container, owner, workspace string) error { + h := u.HostConfig + uid, err := strconv.Atoi(strings.Split(u.Config.User, ":")[0]) + if err != nil || uid <= 0 || u.Config.Labels[BindingLabel] != owner || u.State.Paused || u.State.Restarting || + h.Privileged || h.PidMode != "" || h.IpcMode != "private" || h.CgroupnsMode != "private" || + h.NetworkMode != "none" || len(h.CapAdd) != 0 || !contains(h.CapDrop, "ALL") || + (len(h.SecurityOpt) != 1 || !contains(h.SecurityOpt, "no-new-privileges")) || len(h.Devices)+len(h.DeviceRequests)+len(h.DeviceCgroupRules)+len(h.VolumesFrom) != 0 || + h.RestartPolicy.Name != "no" { + return errors.New("placement is outside qualified unprivileged local Docker profile") + } + if !filepath.IsAbs(workspace) || filepath.Clean(workspace) != workspace { + return errors.New("workspace must be canonical and absolute") + } + resolved, err := filepath.EvalSymlinks(workspace) + if err != nil || resolved != workspace { + return errors.New("workspace must exist without symlink aliases") + } + info, err := os.Stat(workspace) + if err != nil || !info.IsDir() { + return errors.New("workspace must be a directory") + } + var fs syscall.Statfs_t + if err := syscall.Statfs(workspace, &fs); err != nil { + return err + } + switch uint64(fs.Type) { + case 0xef53, 0x58465342, 0x9123683e, 0x01021994: + default: + return errors.New("unqualified workspace filesystem") + } + socket, err := filepath.EvalSymlinks(c.socketPath) + if err != nil { + return fmt.Errorf("cannot resolve supervisor socket: %w", err) + } + mounts, err := c.hostMounts(workspace) + if err != nil { + return err + } + found := false + for _, m := range u.Mounts { + canonical, err := filepath.EvalSymlinks(m.Source) + if err != nil || canonical != m.Source || inside(m.Source, c.root) || inside(c.root, m.Source) || inside(m.Source, socket) { + return errors.New("mount aliases or exposes controller state or supervisor socket") + } + if err := unaliasedSource(m.Source, mounts); err != nil { + return err + } + if m.Type != "bind" || (m.Propagation != "rprivate" && m.Propagation != "") { + return errors.New("only private bind mounts are qualified") + } + if m.Source == workspace && m.RW && !found { + found = true + continue + } + info, err := os.Stat(m.Source) + if err != nil || m.RW || !info.Mode().IsRegular() { + return errors.New("additional mounts must be read-only regular files") + } + } + if !found { + return errors.New("missing exact retained workspace bind") + } + return nil +} diff --git a/internal/agentdaemon/placement/environment.go b/internal/agentdaemon/placement/environment.go new file mode 100644 index 000000000..1be6113bf --- /dev/null +++ b/internal/agentdaemon/placement/environment.go @@ -0,0 +1,38 @@ +package placement + +import ( + "context" + "errors" + "github.com/google/uuid" +) + +func validEnvironment(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil && parsed.String() == id +} + +// Enroll records an unscoped operator placement without Environment authority. +func (c *Controller) Enroll(ctx context.Context, id, owner, workspace string) (*Receipt, error) { + return c.enroll(ctx, id, owner, workspace, "") +} + +// EnrollEnvironment records an immutable operator-confirmed Environment association. +func (c *Controller) EnrollEnvironment(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { + if !validEnvironment(environment) { + return nil, errors.New("invalid placement Environment ID") + } + return c.enroll(ctx, id, owner, workspace, environment) +} + +// Retire reconciles only unscoped operator enrollment. +func (c *Controller) Retire(ctx context.Context, id string) (*Receipt, error) { + return c.retirePlacement(ctx, id, "") +} + +// RetireEnvironment requires the same Environment before any supervisor access. +func (c *Controller) RetireEnvironment(ctx context.Context, id, environment string) (*Receipt, error) { + if !validEnvironment(environment) { + return nil, errors.New("invalid placement Environment ID") + } + return c.retirePlacement(ctx, id, environment) +} diff --git a/internal/agentdaemon/placement/environment_linux_test.go b/internal/agentdaemon/placement/environment_linux_test.go new file mode 100644 index 000000000..4c5998268 --- /dev/null +++ b/internal/agentdaemon/placement/environment_linux_test.go @@ -0,0 +1,198 @@ +//go:build linux + +package placement + +import ( + "context" + "encoding/json" + "errors" + "os" + "reflect" + "sync" + "testing" +) + +const testEnvironment = "3fb4bdd9-d8c7-4f12-810c-9da932e06bc4" +const otherEnvironment = "384ff427-c83f-4484-80a7-58aa95662f97" + +func (f *fixture) enrollEnvironment() *Receipt { + f.t.Helper() + r, err := f.c.EnrollEnvironment(context.Background(), testID, "owner-1", f.workspace, testEnvironment) + if err != nil { + f.t.Fatal(err) + } + return r +} + +func TestEnvironmentScopeRejectsBeforeSupervisorAccess(t *testing.T) { + for _, state := range []string{"enrolled", "retired"} { + t.Run(state, func(t *testing.T) { + f := newFixture(t) + f.enrollEnvironment() + if state == "retired" { + if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err != nil { + t.Fatal(err) + } + } + f.c.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("scope rejection accessed supervisor") + return nil, nil + } + if _, err := f.c.Retire(t.Context(), testID); err == nil { + t.Fatal("scope omitted") + } + for _, id := range []string{otherEnvironment, "", "not-a-uuid", "00000000-0000-0000-0000-000000000000"} { + if _, err := f.c.RetireEnvironment(t.Context(), testID, id); err == nil { + t.Fatal("invalid scope accepted", id) + } + } + }) + } +} + +func TestEnvironmentEnrollmentIsImmutableAndSeparateFromLegacy(t *testing.T) { + f := newFixture(t) + r := f.enrollEnvironment() + if r.Version != 2 || r.EnvironmentID != testEnvironment { + t.Fatal("scope not versioned", r) + } + if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, otherEnvironment); err == nil { + t.Fatal("scope reassigned") + } + if _, err := f.c.Enroll(t.Context(), testID, "owner-1", f.workspace); err == nil { + t.Fatal("scope downgraded") + } + retained, err := f.c.load(testID) + if err != nil || !reflect.DeepEqual(r, retained) { + t.Fatal("enrollment changed", err) + } + legacy := newFixture(t) + old := legacy.enroll() + if old.Version != 1 || old.EnvironmentID != "" { + t.Fatal("legacy enrollment changed") + } + if _, err := legacy.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { + t.Fatal("legacy record gained scope") + } + if legacy.stops+legacy.removals != 0 { + t.Fatal("legacy target mutated") + } + if _, err := legacy.c.Retire(t.Context(), testID); err != nil { + t.Fatal(err) + } +} + +func TestEnvironmentReceiptVersionRejectsMissingOrDowngradedScope(t *testing.T) { + for _, mode := range []string{"missing", "malformed", "downgraded", "future"} { + t.Run(mode, func(t *testing.T) { + f := newFixture(t) + r := f.enrollEnvironment() + switch mode { + case "missing": + r.EnvironmentID = "" + case "malformed": + r.EnvironmentID = "invalid" + case "downgraded": + r.Version = 1 + case "future": + r.Version = 3 + } + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.c.recordPath(testID), data, 0600); err != nil { + t.Fatal(err) + } + f.c.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("invalid receipt accessed supervisor") + return nil, nil + } + if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { + t.Fatal("invalid scoped receipt accepted") + } + if _, err := f.c.Retire(t.Context(), testID); err == nil { + t.Fatal("invalid scoped receipt accepted by legacy path") + } + }) + } +} + +func TestEnvironmentRetirementConcurrentRecovery(t *testing.T) { + f := newFixture(t) + f.enrollEnvironment() + var wg sync.WaitGroup + results := make(chan *Receipt, 4) + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + fresh := *f.c + r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) + if err != nil { + t.Error(err) + return + } + results <- r + }() + } + wg.Wait() + close(results) + var expected *Receipt + for r := range results { + if expected == nil { + expected = r + } + if !reflect.DeepEqual(expected, r) { + t.Fatal("concurrent receipt changed") + } + } + if expected == nil || expected.State != "retired" || expected.EnvironmentID != testEnvironment || f.stops != 1 || f.removals != 1 { + t.Fatal("retirement not unique", expected) + } + fresh := *f.c + fresh.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("recovered receipt touched supervisor") + return nil, nil + } + again, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) + if err != nil || !reflect.DeepEqual(expected, again) { + t.Fatal("fresh recovery differs", err) + } +} + +func TestEnvironmentUnknownAndChangedTargetRemainUnresolved(t *testing.T) { + for _, failure := range []string{"lost-remove-ack", "restart"} { + t.Run(failure, func(t *testing.T) { + f := newFixture(t) + f.enrollEnvironment() + f.fail = failure + if failure == "restart" { + f.unit.State.StartedAt = "replacement" + } + if r, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { + t.Fatal("uncertainty lost", r, err) + } + fresh := *f.c + if r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { + t.Fatal("retry fabricated retirement", r, err) + } + if _, err := fresh.Retire(t.Context(), testID); err == nil { + t.Fatal("unscoped retry bypassed unknown scope") + } + }) + } +} + +func TestInvalidEnvironmentEnrollmentDoesNotTouchSupervisor(t *testing.T) { + f := newFixture(t) + f.c.run = func(context.Context, ...string) ([]byte, error) { + t.Fatal("invalid scope touched supervisor") + return nil, errors.New("unexpected") + } + for _, id := range []string{"", "3FB4BDD9-D8C7-4F12-810C-9DA932E06BC4", "00000000-0000-0000-0000-000000000000"} { + if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, id); err == nil { + t.Fatal("invalid scope enrolled") + } + } +} diff --git a/internal/agentdaemon/placement/mounts_linux.go b/internal/agentdaemon/placement/mounts_linux.go new file mode 100644 index 000000000..bb32822bf --- /dev/null +++ b/internal/agentdaemon/placement/mounts_linux.go @@ -0,0 +1,69 @@ +//go:build linux + +package placement + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +type hostMount struct { + device, root, point string +} + +// The initial profile excludes host mount aliases rather than trying to resolve +// arbitrary backing-path graphs. Mount administration remains a trusted host act. +func (c *Controller) hostMounts(workspace string) ([]hostMount, error) { + data, err := os.ReadFile(filepath.Join(c.procRoot, "self/mountinfo")) + if err != nil { + return nil, err + } + decode := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`) + var mounts []hostMount + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) < 10 { + return nil, errors.New("incomplete host mount evidence") + } + mount := hostMount{device: fields[2], root: decode.Replace(fields[3]), point: decode.Replace(fields[4])} + if !filepath.IsAbs(mount.point) || filepath.Clean(mount.point) != mount.point { + return nil, errors.New("invalid host mount evidence") + } + if mount.point != workspace && inside(workspace, mount.point) { + return nil, errors.New("nested workspace mounts are unqualified") + } + mounts = append(mounts, mount) + } + return mounts, nil +} + +func unaliasedSource(source string, mounts []hostMount) error { + selected := -1 + for i, mount := range mounts { + if !inside(mount.point, source) { + continue + } + if selected == -1 || len(mount.point) > len(mounts[selected].point) { + selected = i + } + } + if selected == -1 || mounts[selected].root != "/" { + return errors.New("mount source lacks whole-filesystem evidence; host aliases and subvolume roots are unqualified") + } + count := 0 + for _, mount := range mounts { + if mount.device == mounts[selected].device { + count++ + } + // A stacked mount point is ambiguous even if it uses another device. + if mount.point == mounts[selected].point && mount.device != mounts[selected].device { + return errors.New("stacked source mounts are unqualified") + } + } + if count != 1 { + return errors.New("multiple host mounts of the source filesystem are unqualified") + } + return nil +} diff --git a/internal/agentdaemon/placement/mounts_linux_test.go b/internal/agentdaemon/placement/mounts_linux_test.go new file mode 100644 index 000000000..f50089fdb --- /dev/null +++ b/internal/agentdaemon/placement/mounts_linux_test.go @@ -0,0 +1,56 @@ +//go:build linux + +package placement + +import ( + "context" + "fmt" + "path/filepath" + "testing" +) + +func TestAmbiguousHostMountsCannotAuthorizeEnrollment(t *testing.T) { + for _, scenario := range []string{"directory-bind", "whole-filesystem-bind", "nested-mount", "stacked-mount", "missing-evidence"} { + t.Run(scenario, func(t *testing.T) { + f := newFixture(t) + info := "1 0 8:2 / / rw - ext4 /dev/test rw\n" + switch scenario { + case "directory-bind": + info += fmt.Sprintf("2 1 8:2 /home/operator %s rw - ext4 /dev/test rw\n", f.workspace) + case "whole-filesystem-bind": + info += fmt.Sprintf("2 1 8:2 / %s rw - ext4 /dev/test rw\n", f.workspace) + case "nested-mount": + info += fmt.Sprintf("2 1 0:8 / %s/secret rw - tmpfs tmpfs rw\n", f.workspace) + case "stacked-mount": + info += fmt.Sprintf("2 1 0:8 / %s rw - tmpfs tmpfs rw\n3 1 0:9 / %s rw - tmpfs tmpfs rw\n", f.workspace, f.workspace) + case "missing-evidence": + info = "" + } + writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), info) + if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { + t.Fatal("ambiguous host mount authorized") + } + if f.stops+f.removals != 0 { + t.Fatal("unqualified supervisor mutated") + } + }) + } +} + +func TestNewHostAliasIsRejectedBeforeRetirement(t *testing.T) { + f := newFixture(t) + f.enroll() + writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 8:2 /home/operator /mnt/alias rw - ext4 /dev/test rw\n") + if _, err := f.c.Retire(context.Background(), testID); err == nil { + t.Fatal("changed host mount topology accepted") + } + if f.stops+f.removals != 0 { + t.Fatal("supervisor mutated before mount qualification") + } +} + +func TestUnrelatedNamespaceMountDoesNotInvalidateStorageEvidence(t *testing.T) { + f := newFixture(t) + writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 0:4 net:[12345] /run/docker/netns/example rw - nsfs nsfs rw\n") + f.enroll() +} diff --git a/internal/agentdaemon/placement/observe_linux.go b/internal/agentdaemon/placement/observe_linux.go new file mode 100644 index 000000000..90401a26f --- /dev/null +++ b/internal/agentdaemon/placement/observe_linux.go @@ -0,0 +1,123 @@ +//go:build linux + +package placement + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func (c *Controller) process(pid int) (Process, bool, error) { + if pid <= 0 { + return Process{}, false, errors.New("missing process identity") + } + data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "stat")) + if err != nil { + return Process{}, false, err + } + end := strings.LastIndexByte(string(data), ')') + if end < 0 { + return Process{}, false, errors.New("malformed process identity") + } + fields := strings.Fields(string(data[end+1:])) + if len(fields) < 20 { + return Process{}, false, errors.New("incomplete process identity") + } + return Process{PID: pid, Start: fields[19]}, fields[0] != "Z" && fields[0] != "X", nil +} + +func (c *Controller) group(pid int, id string) (string, error) { + data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "cgroup")) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "0::/") { + continue + } + path := strings.TrimPrefix(line, "0::") + base := filepath.Base(path) + if filepath.Clean(path) != path || (base != id && base != "docker-"+id+".scope") { + return "", errors.New("cgroup does not identify the exact container") + } + return path, nil + } + return "", errors.New("placement requires cgroup v2") +} + +func (c *Controller) groupPath(group string) (string, error) { + if !filepath.IsAbs(group) || filepath.Clean(group) != group || group == "/" { + return "", errors.New("invalid saved cgroup") + } + return filepath.Join(c.cgroupRoot, group), nil +} + +func (c *Controller) members(group string) ([]Process, error) { + path, err := c.groupPath(group) + if err != nil { + return nil, err + } + var members []Process + err = filepath.WalkDir(path, func(p string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Name() != "cgroup.procs" { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + for _, value := range strings.Fields(string(data)) { + pid, err := strconv.Atoi(value) + if err != nil { + return err + } + identity, _, err := c.process(pid) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + members = append(members, identity) + } + return nil + }) + return members, err +} + +func (c *Controller) observeRetired(r *Receipt) error { + path, err := c.groupPath(r.Target.Cgroup) + if err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(path, "cgroup.events")) + if errors.Is(err, os.ErrNotExist) { + if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { + return errors.New("cgroup evidence unavailable") + } + } else if err != nil { + return err + } else if !strings.Contains("\n"+string(data), "\npopulated 0\n") { + return errors.New("placement cgroup remains populated") + } + for _, old := range append(append([]Process{}, r.Members...), r.Target.Init) { + current, live, err := c.process(old.PID) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if current == old && live { + return fmt.Errorf("old placement process %d remains live", old.PID) + } + } + return nil +} diff --git a/internal/agentdaemon/placement/state_linux.go b/internal/agentdaemon/placement/state_linux.go new file mode 100644 index 000000000..31b72aa9a --- /dev/null +++ b/internal/agentdaemon/placement/state_linux.go @@ -0,0 +1,161 @@ +//go:build linux + +package placement + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + "time" +) + +func (c *Controller) recordPath(id string) string { return filepath.Join(c.root, id+".json") } + +func privateFile(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { + return errors.New("placement state must be a private, owned regular file") + } + return nil +} + +func secureDirectory(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("state directory must be canonical and absolute") + } + if path != "/" { + if err := secureDirectory(filepath.Dir(path)); err != nil { + return err + } + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(path, 0700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + parent, syncErr := os.Open(filepath.Dir(path)) + if syncErr != nil { + return syncErr + } + syncErr = parent.Sync() + closeErr := parent.Close() + if syncErr != nil { + return syncErr + } + if closeErr != nil { + return closeErr + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.IsDir() || info.Mode().Perm()&0022 != 0 || (st.Uid != 0 && st.Uid != uint32(os.Getuid())) { + return fmt.Errorf("untrusted placement state directory: %s", path) + } + return nil +} + +func (c *Controller) lock(ctx context.Context, id string) (func(), error) { + if !fullID.MatchString(id) { + return nil, errors.New("require full immutable container ID") + } + if err := secureDirectory(c.root); err != nil { + return nil, err + } + info, err := os.Stat(c.root) + if err != nil || info.Mode().Perm() != 0700 { + return nil, errors.New("placement state directory must have mode 0700") + } + f, err := os.OpenFile(filepath.Join(c.root, id+".lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600) + if err != nil { + return nil, err + } + if err := privateFile(f); err != nil { + f.Close() + return nil, err + } + for { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if err != syscall.EWOULDBLOCK { + f.Close() + return nil, err + } + select { + case <-ctx.Done(): + f.Close() + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + return func() { _ = f.Close() }, nil +} + +func (c *Controller) load(id string) (*Receipt, error) { + f, err := os.OpenFile(c.recordPath(id), os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + defer f.Close() + if err := privateFile(f); err != nil { + return nil, err + } + var r Receipt + if err := json.NewDecoder(f).Decode(&r); err != nil { + return nil, err + } + validScope := (r.Version == 1 && r.EnvironmentID == "") || (r.Version == 2 && validEnvironment(r.EnvironmentID)) + if !validScope || r.Target.Container != id || !ownerID.MatchString(r.Owner) || + (r.State != "enrolled" && r.State != "stopping" && r.State != "retired") || + (r.State == "retired") != (r.RetiredAt != nil) { + return nil, errors.New("invalid placement receipt") + } + return &r, nil +} + +func (c *Controller) save(r *Receipt) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + f, err := os.CreateTemp(c.root, ".receipt-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(append(data, '\n')); err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if err := os.Rename(f.Name(), c.recordPath(r.Target.Container)); err != nil { + return err + } + return c.syncDir(c.root) +} + +func syncDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} diff --git a/internal/agentdaemon/placement/types.go b/internal/agentdaemon/placement/types.go new file mode 100644 index 000000000..74ff58c88 --- /dev/null +++ b/internal/agentdaemon/placement/types.go @@ -0,0 +1,41 @@ +// Package placement owns explicit operator-managed execution placement retirement. +// It does not authorize Core dispatch, release harnesses or replay native work. +package placement + +import "time" + +// BindingLabel marks a container explicitly created for operator enrollment. +const BindingLabel = "parsar.runtime.placement" + +// Receipt is durable local evidence for one exact placement incarnation. +// Only State == "retired" reports qualified settlement; other states are unknown. +type Receipt struct { + EnvironmentID string `json:"environment_id,omitempty"` + Version int `json:"version"` + State string `json:"state"` + Owner string `json:"owner"` + Target Target `json:"target"` + Members []Process `json:"members"` + RequestedAt time.Time `json:"requested_at"` + RetiredAt *time.Time `json:"retired_at,omitempty"` +} + +// Target binds local supervisor, immutable container and observed incarnation. +type Target struct { + HostBootID string `json:"host_boot_id"` + Supervisor string `json:"supervisor"` + Container string `json:"container"` + Created string `json:"created"` + Started string `json:"started"` + Restarts int `json:"restarts"` + Image string `json:"image"` + Workspace string `json:"workspace"` + Cgroup string `json:"cgroup"` + Init Process `json:"init"` +} + +// Process includes Linux start ticks to distinguish a reused PID. +type Process struct { + PID int `json:"pid"` + Start string `json:"start"` +} diff --git a/internal/agentdaemon/proto/authoring.go b/internal/agentdaemon/proto/authoring.go new file mode 100644 index 000000000..222da697f --- /dev/null +++ b/internal/agentdaemon/proto/authoring.go @@ -0,0 +1,31 @@ +package proto + +import "encoding/json" + +const ( + TypeAuthoringRequest = "authoring_request" + TypeAuthoringResponse = "authoring_response" + AuthoringContext = "context" + AuthoringSkillList = "skill.list" + AuthoringSkillRead = "skill.read" + AuthoringSkillCreate = "skill.create" + AuthoringSkillUpdate = "skill.update" + AuthoringPromptRead = "instructions.read" + AuthoringPromptWrite = "instructions.write" + AuthoringMaxBytes = 1 << 20 + AuthoringSocketEnv = "PARSAR_DAEMON_SOCKET" +) + +// AuthoringRequestPayload uses Envelope.ID for the active run, never a client-supplied workspace or user. +type AuthoringRequestPayload struct { + RequestID string `json:"request_id,omitempty"` + Operation string `json:"operation"` + CapabilityID string `json:"capability_id,omitempty"` + Content string `json:"content,omitempty"` +} + +type AuthoringResponsePayload struct { + RequestID string `json:"request_id,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/agentdaemon/proto/command_output.go b/internal/agentdaemon/proto/command_output.go new file mode 100644 index 000000000..6c7c93d18 --- /dev/null +++ b/internal/agentdaemon/proto/command_output.go @@ -0,0 +1,11 @@ +package proto + +// TypeCommandOutput carries opt-in incremental output for an observed command. +const TypeCommandOutput = "command_output" + +// CommandOutputPayload references an existing command observation in this run. +// Delta contains native text, not a complete or byte-exact process output stream. +type CommandOutputPayload struct { + ID string `json:"id"` + Delta string `json:"delta"` +} diff --git a/internal/agentdaemon/proto/envelope.go b/internal/agentdaemon/proto/envelope.go new file mode 100644 index 000000000..53aaa1e4d --- /dev/null +++ b/internal/agentdaemon/proto/envelope.go @@ -0,0 +1,90 @@ +// Package proto defines the JSON wire format spoken by parsar-daemon over +// the reverse WebSocket tunnel to the Parsar server. Both ends import +// this package; adding an event means editing one file here and both +// sides at once. +// +// Topology: daemon dials OUT (firewall-friendly). Every frame is one +// JSON Envelope; the receiver routes by Type without partial-decoding +// Payload. Downstream = server → daemon (outbound.go), upstream = +// daemon → server (inbound.go). Event names match +// connector.PromptEvent.Type 1:1 so the gateway can translate without +// a lookup table. +// +// Envelope.ID correlation: +// - prompt_request / prompt_cancel: ID = RunID. +// - delta / tool_call / usage / error / done: ID = originating RunID. +// - permission_request: ID = daemon-minted "perm_<8hex>"; the matching +// downstream permission_decision echoes it back. +// - heartbeats carry no ID. +package proto + +import ( + "encoding/json" + "fmt" +) + +// Envelope is the outer JSON frame. Payload is held as raw JSON so the +// routing layer can dispatch by Type before paying a per-event decode. +type Envelope struct { + // Type tags the payload shape. Must be one of the constants in + // inbound.go (upstream) or outbound.go (downstream). + Type string `json:"type"` + + // ID correlates frames to a logical work unit; meaning depends on + // Type (see per-event comments). Omitted when empty (heartbeats). + ID string `json:"id,omitempty"` + + // Payload is the type-specific body, marshalled separately so the + // gateway can route on Type without double-decoding. + Payload json.RawMessage `json:"payload,omitempty"` + + // Trace is the W3C `traceparent` value + // ("00-{32hex trace_id}-{16hex span_id}-{2hex flags}") for the + // logical request. Server-issued envelopes carry the gateway's + // ctx carrier; daemon-issued envelopes carry the daemon's. Omitted + // when no trace is in scope (heartbeats, legacy clients). + // Receivers MUST tolerate missing/unparseable values — both mean + // "mint a fresh trace locally", never reject. + Trace string `json:"trace,omitempty"` +} + +// NewEnvelope marshals payload into an Envelope. A nil payload yields +// an Envelope with no Payload field (for bodyless types like +// prompt_cancel). +func NewEnvelope(typ string, id string, payload any) (Envelope, error) { + env := Envelope{Type: typ, ID: id} + if payload == nil { + return env, nil + } + raw, err := json.Marshal(payload) + if err != nil { + return Envelope{}, fmt.Errorf("proto: marshal payload for type %q: %w", typ, err) + } + env.Payload = raw + return env, nil +} + +// NewEnvelopeWithTrace stamps the given W3C traceparent string into +// Envelope.Trace. Pass "" to skip. String (not typed Carrier) so this +// package stays free of an import on internal/obs/log. +func NewEnvelopeWithTrace(typ string, id string, payload any, traceparent string) (Envelope, error) { + env, err := NewEnvelope(typ, id, payload) + if err != nil { + return Envelope{}, err + } + env.Trace = traceparent + return env, nil +} + +// DecodePayload unpacks Envelope.Payload into out. An empty Payload is +// a non-error so bodyless types (prompt_cancel, permission_cancel) +// decode cleanly. +func (e Envelope) DecodePayload(out any) error { + if len(e.Payload) == 0 { + return nil + } + if err := json.Unmarshal(e.Payload, out); err != nil { + return fmt.Errorf("proto: decode payload for type %q: %w", e.Type, err) + } + return nil +} diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go new file mode 100644 index 000000000..10fac5043 --- /dev/null +++ b/internal/agentdaemon/proto/envelope_test.go @@ -0,0 +1,103 @@ +package proto + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestEnvelopeRoundTrip(t *testing.T) { + in := DeltaPayload{Delta: "hello", Sequence: 42} + env, err := NewEnvelope(TypeDelta, "run-123", in) + if err != nil { + t.Fatalf("NewEnvelope: %v", err) + } + if env.Type != TypeDelta { + t.Fatalf("Type = %q, want %q", env.Type, TypeDelta) + } + if env.ID != "run-123" { + t.Fatalf("ID = %q, want %q", env.ID, "run-123") + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal envelope: %v", err) + } + var got Envelope + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("Unmarshal envelope: %v", err) + } + var out DeltaPayload + if err := got.DecodePayload(&out); err != nil { + t.Fatalf("DecodePayload: %v", err) + } + if out.Delta != "hello" || out.Sequence != 42 { + t.Fatalf("payload round-trip lost data: %+v", out) + } +} + +func TestEnvelopeOmitsEmptyPayload(t *testing.T) { + // prompt_cancel carries no body — the wire form must drop the + // payload field entirely so receivers can route on Type alone + // without hitting `decode: unexpected end` on an empty Payload. + env, err := NewEnvelope(TypePromptCancel, "run-456", nil) + if err != nil { + t.Fatalf("NewEnvelope: %v", err) + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(raw), `"payload"`) { + t.Fatalf("expected payload field omitted, got %s", raw) + } +} + +func TestDecodePayloadEmptyIsNoop(t *testing.T) { + env := Envelope{Type: TypePromptCancel, ID: "run-789"} + var out PromptCancelPayload + if err := env.DecodePayload(&out); err != nil { + t.Fatalf("DecodePayload on empty payload: %v", err) + } +} + +func TestUsagePayloadEmbedsUsage(t *testing.T) { + // The gateway hands UsagePayload straight to the connector boundary, + // which translates Usage → store.UsageInput. If we accidentally + // renamed the embedded field or stopped embedding, the persistence + // path would silently lose all usage fields. Catch that here. + in := UsagePayload{Usage: Usage{Provider: "anthropic", Model: "claude-opus", InputTokens: 100}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(raw), `"provider":"anthropic"`) { + t.Fatalf("expected provider field at top level, got %s", raw) + } + var out UsagePayload + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.Provider != "anthropic" || out.InputTokens != 100 { + t.Fatalf("usage round-trip lost data: %+v", out) + } +} + +func TestVersionCompatible(t *testing.T) { + cases := []struct { + client string + ok bool + }{ + {Version, true}, // exact match + {"0.2.99", true}, // patch drift OK + {"0.1.99", false}, // minor drift NOT OK + {"1.0.0", false}, // major drift NOT OK + {"", false}, // missing + {"garbage", false}, // unparseable + {"0.1", false}, // truncated + } + for _, tc := range cases { + if got := VersionCompatible(tc.client); got != tc.ok { + t.Errorf("VersionCompatible(%q) = %v, want %v", tc.client, got, tc.ok) + } + } +} diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go new file mode 100644 index 000000000..0ea690940 --- /dev/null +++ b/internal/agentdaemon/proto/environment.go @@ -0,0 +1,38 @@ +package proto + +import "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + +// LocalEnvironment references a deployment-bound workspace; it never supplies a path. +type LocalEnvironment struct { + ID string `json:"id"` + // Skills describes Core-installed inert content in the packaged Runtime. + Skills []agentskill.Metadata `json:"skills,omitempty"` + // ToolEnvironment consumes Core-completed confidential initialization. + ToolEnvironment bool `json:"tool_environment,omitempty"` + // SystemPackages requires the installed Runtime tool root during execution. + SystemPackages bool `json:"system_packages,omitempty"` + // NetworkAccess must match the immutable Runtime policy for execution. + NetworkAccess string `json:"network_access,omitempty"` +} + +func (r PromptRequestPayload) EnvironmentID() string { + if r.LocalEnvironment != nil { + return r.LocalEnvironment.ID + } + if r.RemoteEnvironment != nil { + return r.RemoteEnvironment.ID + } + return "" +} + +// RemoteEnvironment is a transient execution binding, not a public Environment +// resource. WorkDir remains the harness-local cwd. The selected AgentKind owns +// the native connection protocol; no native selector or configuration-variable name is shared. +// Send only to a peer advertising remote_environment. Never persist or log the +// connection token in Session configuration, events or completion metadata. +type RemoteEnvironment struct { + ID string `json:"id"` + WorkspaceDirectory string `json:"workspace_directory"` + ConnectionURL string `json:"connection_url"` + ConnectionToken string `json:"connection_token"` +} diff --git a/internal/agentdaemon/proto/functions.go b/internal/agentdaemon/proto/functions.go new file mode 100644 index 000000000..c282c0f6e --- /dev/null +++ b/internal/agentdaemon/proto/functions.go @@ -0,0 +1,58 @@ +package proto + +import ( + "encoding/json" + "errors" +) + +const ( + TypeFunctionCall = "function_call" + TypeFunctionResult = "function_result" +) + +type FunctionTool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +// FunctionCallPayload belongs to the Run identified by Envelope.ID. +type FunctionCallPayload struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type FunctionResultPayload struct { + DeliveryID string `json:"delivery_id"` + CallID string `json:"call_id"` + Success bool `json:"success"` + Content []FunctionResultContent `json:"content"` +} + +// FunctionResultContent is one ordered text or image part of a function result. +type FunctionResultContent struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + ImageURL *string `json:"image_url,omitempty"` +} + +func (r FunctionResultPayload) ValidateContent() error { + if r.Content == nil { + return errors.New("function result requires a content array") + } + for _, part := range r.Content { + switch part.Type { + case "input_text": + if part.Text != nil && part.ImageURL == nil { + continue + } + case "input_image": + if part.ImageURL != nil && part.Text == nil { + continue + } + } + return errors.New("function result requires text or image content") + } + return nil +} diff --git a/internal/agentdaemon/proto/functions_test.go b/internal/agentdaemon/proto/functions_test.go new file mode 100644 index 000000000..a34d83dfd --- /dev/null +++ b/internal/agentdaemon/proto/functions_test.go @@ -0,0 +1,43 @@ +package proto + +import ( + "encoding/json" + "testing" +) + +func TestFunctionResultContentWire(t *testing.T) { + for _, content := range []string{ + `[]`, + `[{"type":"input_text","text":""}]`, + `[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AA=="},{"type":"input_text","text":"after"}]`, + } { + raw := `{"delivery_id":"delivery","call_id":"call","success":false,"content":` + content + `}` + var result FunctionResultPayload + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatal(err) + } + if err := result.ValidateContent(); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(result) + if err != nil || string(encoded) != raw { + t.Fatalf("round trip: %s, %v", encoded, err) + } + } + for _, content := range []string{ + `null`, `[null]`, `[{}]`, + `[{"type":"input_text"}]`, `[{"type":"input_text","text":null}]`, + `[{"type":"input_image"}]`, `[{"type":"input_image","image_url":null}]`, + `[{"type":"input_text","text":"before","image_url":"url"}]`, + `[{"type":"input_image","image_url":"url","text":"text"}]`, + `[{"type":"input_audio","text":"audio"}]`, + } { + var result FunctionResultPayload + if err := json.Unmarshal([]byte(`{"content":`+content+`}`), &result); err != nil { + t.Fatal(err) + } + if err := result.ValidateContent(); err == nil { + t.Fatalf("accepted %s", content) + } + } +} diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go new file mode 100644 index 000000000..81af45acc --- /dev/null +++ b/internal/agentdaemon/proto/inbound.go @@ -0,0 +1,305 @@ +package proto + +import "encoding/json" + +// This package lives at the repo-root module so both the server-side +// gateway/connector AND apps/parsar-daemon can import it. That rules out +// importing server/internal/... (Go's internal-package rule), so wire +// types like Usage are declared here in full rather than imported from +// store.UsageInput. The connector layer translates at the boundary; +// the wire schema stays decoupled from upstream Go type edits. + +// Type constants for daemon → server frames. Names match +// connector.PromptEvent.Type 1:1 so the gateway can translate without +// a per-event lookup table. +const ( + // TypeDelta carries an incremental text fragment. Daemon + // accumulates these so the matching done frame can carry the + // full Final.Content. + TypeDelta = "delta" + + // TypeOutputMessage carries opt-in native message boundaries and completion snapshots. + TypeOutputMessage = "output_message" + + // TypeThinking carries an internal-thinking fragment. Gateway + // forwards as a plain EventDelta so existing renderers keep + // working. + TypeThinking = "thinking" + + // TypeToolCall carries a tool invocation. Stage=="before" runs + // before the call, "after" runs after. + TypeToolCall = "tool_call" + + // TypePermissionRequest carries an agent's request for human + // approval. Envelope.ID = "perm_<8hex>" minted by the daemon. + TypePermissionRequest = "permission_request" + + // TypePermissionCancel signals the agent withdrew an earlier + // permission request (e.g. its internal timeout fired). Used by + // the gateway to unblock pending SubmitPermission calls. + TypePermissionCancel = "permission_cancel" + + // TypePromptForUserChoice asks the human to pick one (or more) + // answers from a closed list before the agent can continue. Used + // to intercept Claude Code's built-in AskUserQuestion tool so the + // daemon doesn't deadlock waiting for a tool_result no one will + // send. Envelope.ID = "ask_<8hex>" minted by the daemon. + TypePromptForUserChoice = "prompt_for_user_choice" + + // TypeInteractionDecisionAck confirms that the daemon-side agent + // accepted (or definitively rejected) a permission/user-input decision or cancellation. + // The server must not mark the durable interaction terminal before this + // frame arrives. + TypeInteractionDecisionAck = "interaction_decision_ack" + + // TypeUsage reports incremental token / cost usage. + TypeUsage = "usage" + + // TypeError signals the prompt failed. Daemon MUST emit a Done + // frame immediately after to close the stream. + TypeError = "error" + + // TypeDone signals the prompt completed. Payload carries the + // equivalent of a sync PromptOutput. + TypeDone = "done" + + // TypeHeartbeat is the daemon's liveness signal. Carries no ID. + // Gateway uses arrival time to detect dead sessions. + TypeHeartbeat = "heartbeat" +) + +// DeltaPayload carries an incremental text fragment from the agent. +type DeltaPayload struct { + ItemID string `json:"item_id,omitempty"` + Delta string `json:"delta"` + Sequence uint64 `json:"sequence"` +} + +// OutputMessagePayload describes a native assistant message; Text is a completion snapshot. +type OutputMessagePayload struct { + ID string `json:"id"` + Status string `json:"status"` + Phase string `json:"phase,omitempty"` + Text *string `json:"text,omitempty"` +} + +// ThinkingPayload carries an internal-thinking fragment. +type ThinkingPayload struct { + Text string `json:"text"` + Sequence uint64 `json:"sequence,omitempty"` +} + +// ToolCallPayload carries a tool invocation event. Stage is "before" +// when the agent is about to call the tool, "after" when the result +// is back. +type ToolCallPayload struct { + // NativeItem is an opt-in engine snapshot for execution-service projection, not a public Item. + NativeItem json.RawMessage `json:"native_item,omitempty"` + Observation *ToolObservation `json:"observation,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage"` + Args map[string]any `json:"args,omitempty"` + Result map[string]any `json:"result,omitempty"` +} + +// PermissionRequestPayload carries an agent's request for human +// approval. RequestID is the daemon-minted handle used to route a later +// decision. It lives in the payload because Envelope.ID is the run ID used +// by the server gateway to deliver the request to the active run subscriber. +// Readers still accept legacy frames that omit RequestID and put the request +// handle in Envelope.ID. +type PermissionRequestPayload struct { + RequestID string `json:"request_id,omitempty"` + Tool string `json:"tool"` + Title string `json:"title"` + Detail string `json:"detail,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +// InteractionDecisionAckPayload is the daemon's application-level receipt +// for a server decision. DeliveryID correlates one resolve attempt without +// relying on the request id, which may outlive a reconnect or timeout race. +type InteractionDecisionAckPayload struct { + DeliveryID string `json:"delivery_id"` + Applied bool `json:"applied"` + ErrorCode string `json:"error_code,omitempty"` + Error string `json:"error,omitempty"` + // Outcome preserves native continuity when cancellation does not emit Done. + Outcome *DonePayload `json:"outcome,omitempty"` +} + +// PromptForUserChoiceOption is one button / checkbox the user can +// pick when answering a PromptForUserChoice. Label is the human- +// readable choice; Description is optional inline help. +type PromptForUserChoiceOption struct { + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +// PromptForUserChoiceQuestion is one question in a (possibly multi- +// question) AskUserQuestion call. Mirrors the Claude Code built-in +// schema verbatim so the daemon doesn't translate the shape twice. +type PromptForUserChoiceQuestion struct { + ID string `json:"id"` + Header string `json:"header,omitempty"` + Question string `json:"question"` + MultiSelect bool `json:"multi_select,omitempty"` + IsOther bool `json:"is_other,omitempty"` + IsSecret bool `json:"is_secret,omitempty"` + Options []PromptForUserChoiceOption `json:"options"` +} + +// PromptForUserChoicePayload carries the AskUserQuestion interception. +// +// AskID is the daemon-minted "ask_<8hex>" handle the server uses to +// route SubmitPromptForUserChoice back to the right session. It rides +// on the payload (not Envelope.ID) because Envelope.ID is reserved for +// the run id — that's the field server-side session.dispatch fans on +// to deliver the frame to the run's subscriber channel. ToolUseID is +// the originating Claude Code tool_use id; empty when the call came +// through the control_request channel (CCRequestID then identifies the +// daemon-side waiter instead but it doesn't ride on the wire). +// +// The legacy single-question fields (Question / Header / MultiSelect / +// Options) stay on the wire so older server/db snapshots can still be +// decoded. New code writes Questions; readers must call +// EffectiveQuestions to get a unified view across both shapes. +type PromptForUserChoicePayload struct { + AskID string `json:"ask_id"` + Questions []PromptForUserChoiceQuestion `json:"questions,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + AutoResolutionMs *uint64 `json:"auto_resolution_ms,omitempty"` + + // Legacy single-question fields — read-only on the new path. Empty + // when Questions is populated. + Question string `json:"question,omitempty"` + Header string `json:"header,omitempty"` + MultiSelect bool `json:"multi_select,omitempty"` + Options []PromptForUserChoiceOption `json:"options,omitempty"` +} + +// EffectiveQuestions returns the question list a consumer should +// render. Prefers the new Questions slice; falls back to the legacy +// single-question fields so old payloads still work after a restart. +func (p PromptForUserChoicePayload) EffectiveQuestions() []PromptForUserChoiceQuestion { + if len(p.Questions) > 0 { + return p.Questions + } + if p.Question == "" && len(p.Options) == 0 { + return nil + } + return []PromptForUserChoiceQuestion{{ + Header: p.Header, + Question: p.Question, + MultiSelect: p.MultiSelect, + Options: p.Options, + }} +} + +// Usage mirrors server/internal/store.UsageInput on the wire — field +// names and JSON tags identical so the connector boundary copies with +// a one-liner translator. Redeclared (not imported) because this +// package must stay free of server/internal dependencies. +type TokenUsage struct { + InputTokens int64 `json:"input_tokens"` + CachedInputTokens int64 `json:"cached_input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningOutputTokens int64 `json:"reasoning_output_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +type Usage struct { + Tokens *TokenUsage `json:"tokens,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + InputTokens int32 `json:"input_tokens,omitempty"` + OutputTokens int32 `json:"output_tokens,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +// UsagePayload carries a Usage update mid-stream. +type UsagePayload struct { + Usage +} + +// ErrorPayload reports a prompt-level failure. +type ErrorPayload struct { + Error string `json:"error"` +} + +// DonePayload mirrors connector.PromptOutput shape. Redeclared (not +// embedded) so a refactor of PromptOutput doesn't silently flip the +// wire shape. +type DonePayload struct { + Content string `json:"content"` + Transcript string `json:"transcript,omitempty"` + Usage Usage `json:"usage,omitzero"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +const ( + DoneMetaAgentSessionID = "agent_session_id" + DoneMetaAgentSessionType = "agent_session_type" +) + +// AgentKindCapabilities describes what a daemon-side agent_kind can +// do inside one prompt session. Runtime-level capabilities such as +// cancellation belong to the daemon connector itself; these bits are +// the engine-specific surface the UI uses for filtering and copy. +type AgentKindCapabilities struct { + Streaming bool `json:"streaming,omitempty"` + Permissions bool `json:"permissions,omitempty"` + Usage bool `json:"usage,omitempty"` + Resume bool `json:"resume,omitempty"` + NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` + WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` + Steering bool `json:"steering,omitempty"` + MessageItems bool `json:"message_items,omitempty"` + ToolItems bool `json:"tool_items,omitempty"` + ToolObservations bool `json:"tool_observations,omitempty"` + EnvironmentNone bool `json:"environment_none,omitempty"` + RemoteEnvironment bool `json:"remote_environment,omitempty"` + LocalEnvironment bool `json:"local_environment,omitempty"` + LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` + Preparation bool `json:"preparation,omitempty"` + WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` + WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` + WebSearchControl bool `json:"web_search_control,omitempty"` + // ExecutionControls supports typed search and verbosity controls. + ExecutionControls bool `json:"execution_controls,omitempty"` + TextVerbosity bool `json:"text_verbosity,omitempty"` + SubagentControl bool `json:"subagent_control,omitempty"` + DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` + // DurableTurns includes strict resume, completion release and cancellation snapshots. + DurableTurns bool `json:"durable_turns,omitempty"` + FunctionTools bool `json:"function_tools,omitempty"` + MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` + MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` + MCPHTTPRemoteEnvironment bool `json:"mcp_http_remote_environment,omitempty"` + MCPHTTPRemoteBearerAuth bool `json:"mcp_http_remote_bearer_auth,omitempty"` + MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` +} + +// SupportedAgentKind is one daemon-advertised agent engine. Daemons +// can report unavailable kinds with Available=false when the adapter +// exists but the underlying CLI binary is missing. +type SupportedAgentKind struct { + Kind string `json:"kind"` + Available bool `json:"available"` + Version string `json:"version,omitempty"` + Capabilities AgentKindCapabilities `json:"capabilities,omitempty"` +} + +// HeartbeatPayload is the daemon's liveness ping. supported_agent_kinds +// is preferred over the legacy claude_available flag; old daemons may +// still send only claude_available, and the server infers claude_code +// support. +type HeartbeatPayload struct { + Timestamp int64 `json:"ts"` + ActiveRequests int `json:"active_requests"` + DaemonVersion string `json:"daemon_version,omitempty"` + ClaudeAvailable bool `json:"claude_available,omitempty"` + SupportedAgentKinds []SupportedAgentKind `json:"supported_agent_kinds,omitempty"` +} diff --git a/internal/agentdaemon/proto/mcp.go b/internal/agentdaemon/proto/mcp.go new file mode 100644 index 000000000..5fbf86774 --- /dev/null +++ b/internal/agentdaemon/proto/mcp.go @@ -0,0 +1,13 @@ +package proto + +// MCPHTTPServer declares HTTP tools on the trusted harness host. Send only to a +// peer advertising mcp_http_tools; a transient BearerToken additionally requires +// mcp_http_bearer_auth. Required initialization requires mcp_http_required. +// Never persist or log this private request as configuration. +type MCPHTTPServer struct { + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerToken *string `json:"bearer_token,omitempty"` +} diff --git a/internal/agentdaemon/proto/mcp_test.go b/internal/agentdaemon/proto/mcp_test.go new file mode 100644 index 000000000..186b21ae2 --- /dev/null +++ b/internal/agentdaemon/proto/mcp_test.go @@ -0,0 +1,28 @@ +package proto + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestMCPHTTPBearerWireIsOptionalAndExact(t *testing.T) { + empty, opaque := "", "synthetic-opaque-token" + for _, token := range []*string{nil, &empty, &opaque} { + input := MCPHTTPServer{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: token} + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `"bearer_token"`) != (token != nil) { + t.Fatal("bearer field omission changed") + } + var decoded MCPHTTPServer + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if (decoded.BearerToken == nil) != (token == nil) || token != nil && *decoded.BearerToken != *token { + t.Fatal("private token bytes or presence changed") + } + } +} diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go new file mode 100644 index 000000000..d91b1a88d --- /dev/null +++ b/internal/agentdaemon/proto/outbound.go @@ -0,0 +1,175 @@ +package proto + +// Type constants for server → daemon frames. +const ( + // TypePromptRequest triggers one prompt cycle. Envelope.ID = RunID; + // the daemon stamps every resulting upstream frame with the same + // ID so the gateway can fan them back to the matching StreamPrompt + // subscriber. + TypePromptRequest = "prompt_request" + + // TypePromptCancel aborts an in-flight prompt. Envelope.ID = + // RunID. Idempotent — cancelling an unknown / already-finished + // run is a no-op on the daemon side. + TypePromptCancel = "prompt_cancel" + + // TypePermissionDecision delivers a human verdict back to the + // daemon. Envelope.ID = the perm_<8hex> id the daemon minted in + // the matching permission_request. + TypePermissionDecision = "permission_decision" + + // TypePromptForUserChoiceDecision delivers the human's answer back + // to the daemon. Envelope.ID = the ask_<8hex> id the daemon minted + // in the matching prompt_for_user_choice frame. + TypePromptForUserChoiceDecision = "prompt_for_user_choice_decision" + + // TypeDeviceShutdown asks the daemon to exit gracefully (SIGTERM + // child processes, flush state, close the socket). Ignored by + // long-lived local devices unless the operator explicitly + // requested it. + TypeDeviceShutdown = "device_shutdown" +) + +// PromptRequestPayload is the daemon-side view of a connector.PromptInput, +// trimmed to fields a daemon agent actually needs. Kept separate from +// PromptInput so future agent implementations can evolve the wire shape +// without touching the connector surface. +type PromptRequestPayload struct { + // AgentKind selects which agent implementation the daemon + // dispatches to. + AgentKind string `json:"agent_kind"` + + // ConversationID lets the daemon scope per-conversation state + // (Claude --resume session id, scratch dir). + ConversationID string `json:"conversation_id"` + + // RunID is the Parsar agent_run id; mirrored back on every + // upstream frame via Envelope.ID. + RunID string `json:"run_id"` + + // Prompt is the user-facing message that drives this turn. + Prompt string `json:"prompt"` + + // Attachments carries non-text payloads (images from inbound + // messages) alongside Prompt. The daemon-side agent decides how + // to fold them in: claude_code re-encodes them into Anthropic + // image content blocks on the stdin-driven JSON input loop. + // Silently ignored when the agent doesn't understand multimodal + // input — Prompt alone still drives the run. + Attachments []PromptAttachment `json:"attachments,omitempty"` + + // WorkDir is the cwd for the agent subprocess. Local mode: user's + // chosen project root. Sandbox mode: empty — the daemon falls + // back to a per-conversation scratch dir so plugin installs and + // the subprocess cwd stay on the same tree. + WorkDir string `json:"work_dir,omitempty"` + + // AgentOptions carries agent-specific overrides (model, mode, + // allowed_tools, system_prompt, mcp_servers, plugin_dirs, env, + // ...). The daemon's agent interprets these; the gateway never + // inspects them. + AgentOptions map[string]any `json:"agent_options,omitempty"` + + // ExecutionControls are authoritative engine-neutral settings, translated by the adapter. + ExecutionControls *ExecutionControls `json:"execution_controls,omitempty"` + // MCPHTTPServers replaces MCP configuration for the service-side HTTP profile. + // Nil preserves existing behavior; an empty list explicitly declares no servers. + MCPHTTPServers *[]MCPHTTPServer `json:"mcp_http_servers,omitempty"` + + // RemoteEnvironment selects independently placed execution through the native adapter. + RemoteEnvironment *RemoteEnvironment `json:"remote_environment,omitempty"` + LocalEnvironment *LocalEnvironment `json:"local_environment,omitempty"` + + // AgentSessionID is the upstream engine session id to resume. + AgentSessionID string `json:"agent_session_id,omitempty"` + + // AgentStateKey is the stable daemon-side state directory key. + // WorkspaceReadOnly prepares temporary native state that cannot start a Run. + WorkspaceReadOnly bool `json:"workspace_read_only,omitempty"` + AgentStateKey string `json:"agent_state_key,omitempty"` + WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` + // ReleaseOnCompletion closes the native writer before acknowledging Done. + ReleaseOnCompletion bool `json:"release_on_completion,omitempty"` + StrictResume bool `json:"strict_resume,omitempty"` + RequireExistingNativeSession bool `json:"require_existing_native_session,omitempty"` + ObserveMessages bool `json:"observe_messages,omitempty"` + ObserveTools bool `json:"observe_tools,omitempty"` + ObserveToolObservations bool `json:"observe_tool_observations,omitempty"` + ObserveSubagentIdentities bool `json:"observe_subagent_identities,omitempty"` + FunctionTools []FunctionTool `json:"function_tools,omitempty"` + DisableExecutionEnvironment bool `json:"disable_execution_environment,omitempty"` + DisableSubagents bool `json:"disable_subagents,omitempty"` +} + +// PromptAttachment is one piece of non-text user input the daemon-side +// agent should fold into the turn alongside Prompt. The field set is +// forward-compatible with file/audio so a wire-schema bump isn't +// required when those land. +// +// DataBase64 is standard-base64 raw bytes; the daemon decodes once +// before forwarding to its agent adapter (claude_code re-wraps as an +// Anthropic image content block on stdin). MIME is forwarded verbatim +// so the agent picks the right block shape (image/png vs image/jpeg). +type PromptAttachment struct { + Kind string `json:"kind"` + MIME string `json:"mime"` + DataBase64 string `json:"data_base64"` +} + +// PromptCancelPayload optionally requests an application receipt; identity is on Envelope.ID. +type PromptCancelPayload struct { + DeliveryID string `json:"delivery_id,omitempty"` +} + +// PermissionDecisionPayload carries the human verdict. UpdatedInput +// lets the approver edit the tool input before letting the call +// proceed (Claude Code's allow-with-changes path). +type PermissionDecisionPayload struct { + DeliveryID string `json:"delivery_id"` + Approved bool `json:"approved"` + Message string `json:"message,omitempty"` + UpdatedInput map[string]any `json:"updated_input,omitempty"` +} + +// PromptForUserChoiceQuestionAnswer carries one (question, answer) +// pair from a multi-question submit. QuestionID is the canonical key; +// Header and Answer remain as compatibility fields for older peers. +type PromptForUserChoiceQuestionAnswer struct { + QuestionID string `json:"question_id,omitempty"` + Answers []string `json:"answers,omitempty"` + Header string `json:"header,omitempty"` + Answer string `json:"answer,omitempty"` +} + +// PromptForUserChoiceDecisionPayload carries the human's pick. The +// daemon turns this into a tool_result JSON the agent's stdin +// consumes. +// +// - QuestionAnswers carries one entry per question, keyed by stable +// QuestionID with the selected values preserved as an array. +// - Answers length == 1 for single-select; length N for multi-select. +// Legacy single-question callers may still write this; the daemon +// treats it as "all answers belong to question 0". +// - Cancelled=true marks a non-answer (timeout, /cancel). Reason is +// a short machine tag (e.g. "timeout"); the daemon converts it +// into a tool_result message the LLM understands. +type PromptForUserChoiceDecisionPayload struct { + DeliveryID string `json:"delivery_id"` + QuestionAnswers []PromptForUserChoiceQuestionAnswer `json:"question_answers,omitempty"` + Answers []string `json:"answers,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// DeviceShutdownPayload tells the daemon why we're closing it (for log +// lines / metrics on the daemon side). Optional. +type DeviceShutdownPayload struct { + Reason string `json:"reason,omitempty"` +} + +// ExecutionControls requires both values when supplied; omitting the block preserves agent options. +// Send only to a peer advertising execution_controls, independently of older option capabilities. +type ExecutionControls struct { + WebSearch string `json:"web_search"` + TextVerbosity string `json:"text_verbosity"` +} diff --git a/internal/agentdaemon/proto/preparation.go b/internal/agentdaemon/proto/preparation.go new file mode 100644 index 000000000..e28c3ffd2 --- /dev/null +++ b/internal/agentdaemon/proto/preparation.go @@ -0,0 +1,42 @@ +package proto + +// Preparation controls use a caller request ID on Envelope.ID, never a RunID. +// Handles are daemon-generated and valid only on the accepting connection. +const ( + TypeExecutionPrepare = "execution_prepare" + TypeExecutionStart = "execution_start" + TypeExecutionRelease = "execution_release" + TypePreparationStatus = "preparation_status" +) + +// ExecutionPreparePayload reuses execution configuration without accepting input +// or product authoring. The initial private profile requires a remote environment, +// stable state key, strict resume and completion release. +type ExecutionPreparePayload struct { + Configuration PromptRequestPayload `json:"configuration"` +} + +type ExecutionStartPayload struct { + Handle string `json:"handle"` + RunID string `json:"run_id"` + Prompt string `json:"prompt"` +} + +type ExecutionReleasePayload struct { + Handle string `json:"handle"` +} + +// PreparationStatusPayload is a connection-local observation, not a public event. +// Keep the highest Revision for each Handle; concurrent sends may arrive out of +// order. ExpiresAt is Unix milliseconds. Repeated requests do not extend it. Retired request IDs +// may allocate a fresh handle, but an old handle can never start its replacement. +type PreparationStatusPayload struct { + Handle string `json:"handle,omitempty"` + Revision uint64 `json:"revision"` + State string `json:"state"` + ExpiresAt int64 `json:"expires_at,omitempty"` + RunID string `json:"run_id,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + // Operation is supplied for a rejected control operation (no resource revision). + Operation string `json:"operation,omitempty"` +} diff --git a/internal/agentdaemon/proto/steering.go b/internal/agentdaemon/proto/steering.go new file mode 100644 index 000000000..0c12c0765 --- /dev/null +++ b/internal/agentdaemon/proto/steering.go @@ -0,0 +1,24 @@ +package proto + +// TypePromptSteer appends text to an active run; Envelope.ID is the run ID. +const TypePromptSteer = "prompt_steer" + +// TypePromptSteerAck reports input receipt phases on the originating run ID. +const TypePromptSteerAck = "prompt_steer_ack" + +// PromptSteerPayload identifies one text input within an active run. +type PromptSteerPayload struct { + InputID string `json:"input_id"` + Text string `json:"text"` + DurableReceipt bool `json:"durable_receipt,omitempty"` +} + +// PromptSteerAckPayload distinguishes a completed write from native acceptance. +type PromptSteerAckPayload struct { + InputID string `json:"input_id"` + Accepted bool `json:"accepted"` + // Written is an intermediate transport phase, never native acceptance. + Written bool `json:"written,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/agentdaemon/proto/subagents.go b/internal/agentdaemon/proto/subagents.go new file mode 100644 index 000000000..73a984bba --- /dev/null +++ b/internal/agentdaemon/proto/subagents.go @@ -0,0 +1,14 @@ +package proto + +// TypeSubagentIdentity carries verified native identity facts, not public lifecycle. +const TypeSubagentIdentity = "subagent_identity" + +// SubagentIdentityPayload is scoped by the authenticated Run envelope. The service +// supplies its Session, project, device and public identity; none comes from here. +type SubagentIdentityPayload struct { + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + ParentTurnID string `json:"parent_turn_id"` + SourceItemID string `json:"source_item_id"` +} diff --git a/internal/agentdaemon/proto/token_usage.go b/internal/agentdaemon/proto/token_usage.go new file mode 100644 index 000000000..54850c5db --- /dev/null +++ b/internal/agentdaemon/proto/token_usage.go @@ -0,0 +1,21 @@ +package proto + +import ( + "encoding/json" + "errors" +) + +func (u *TokenUsage) UnmarshalJSON(raw []byte) error { + type plain TokenUsage + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + for _, key := range []string{"input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"} { + var count *int64 + if json.Unmarshal(fields[key], &count) != nil || count == nil || *count < 0 { + return errors.New("token usage requires all non-negative counters") + } + } + return json.Unmarshal(raw, (*plain)(u)) +} diff --git a/internal/agentdaemon/proto/token_usage_test.go b/internal/agentdaemon/proto/token_usage_test.go new file mode 100644 index 000000000..7edca4d73 --- /dev/null +++ b/internal/agentdaemon/proto/token_usage_test.go @@ -0,0 +1,26 @@ +package proto + +import ( + "encoding/json" + "testing" +) + +func TestTokenUsageKeepsMissingDistinctFromMeasuredZero(t *testing.T) { + for _, raw := range []string{ + `{"tokens":{}}`, + `{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":null,"total_tokens":0}}`, + `{"tokens":{"input_tokens":-1,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":0}}`, + } { + var usage Usage + if json.Unmarshal([]byte(raw), &usage) == nil { + t.Fatalf("incomplete counts accepted: %s", raw) + } + } + var legacy, measured Usage + if err := json.Unmarshal([]byte(`{"input_tokens":10}`), &legacy); err != nil || legacy.Tokens != nil { + t.Fatalf("legacy usage changed: %+v %v", legacy, err) + } + if err := json.Unmarshal([]byte(`{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":0,"future":"ignored"}}`), &measured); err != nil || measured.Tokens == nil { + t.Fatalf("measured zero lost: %+v %v", measured, err) + } +} diff --git a/internal/agentdaemon/proto/tool_observations.go b/internal/agentdaemon/proto/tool_observations.go new file mode 100644 index 000000000..6562d55a0 --- /dev/null +++ b/internal/agentdaemon/proto/tool_observations.go @@ -0,0 +1,28 @@ +package proto + +import "encoding/json" + +// ToolObservation is an engine-neutral execution snapshot, not a public API Item. +type ToolObservation struct { + Kind string `json:"kind"` + Status string `json:"status"` + Name string `json:"name,omitempty"` + Command string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + Server string `json:"server,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + Error json.RawMessage `json:"error,omitempty"` + Content *[]FunctionResultContent `json:"content,omitempty"` + Action *ToolWebSearchAction `json:"action,omitempty"` +} + +type ToolWebSearchAction struct { + Type string `json:"type"` + Query *string `json:"query,omitempty"` + Queries []string `json:"queries,omitempty"` + URL *string `json:"url,omitempty"` + Pattern *string `json:"pattern,omitempty"` +} diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go new file mode 100644 index 000000000..03cbc5d95 --- /dev/null +++ b/internal/agentdaemon/proto/version.go @@ -0,0 +1,40 @@ +package proto + +// Protocol version. Bump on any existing-payload-shape change (additive +// optional fields don't need a bump). Daemon sends this in the WS +// upgrade query (`version=`) and in the bootstrap HTTP +// response; mismatches fail closed at WS upgrade. +const ( + Version = "0.2.0" +) + +// VersionCompatible returns true when clientVersion's "X.Y" prefix +// exactly matches Version's. Inputs without a patch segment (e.g. +// "0.1", "1") are rejected so a half-set version string never +// spuriously matches. +func VersionCompatible(clientVersion string) bool { + if clientVersion == "" { + return false + } + clientMM, ok := majorMinor(clientVersion) + if !ok { + return false + } + serverMM, _ := majorMinor(Version) + return clientMM == serverMM +} + +// majorMinor returns the "X.Y" prefix of a semver-shaped version. +// Second return is false when the input has no patch segment. +func majorMinor(v string) (string, bool) { + dots := 0 + for i := 0; i < len(v); i++ { + if v[i] == '.' { + dots++ + if dots == 2 { + return v[:i], true + } + } + } + return "", false +} diff --git a/internal/agentdaemon/proto/workspace_directory.go b/internal/agentdaemon/proto/workspace_directory.go new file mode 100644 index 000000000..4209bf345 --- /dev/null +++ b/internal/agentdaemon/proto/workspace_directory.go @@ -0,0 +1,76 @@ +package proto + +import ( + "encoding/json" + "errors" + "strings" + "unicode/utf8" +) + +const WorkspaceDirectoryMaxEntries = 1024 + +type WorkspaceDirectoryEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + SizeBytes *int64 `json:"size_bytes"` +} + +type WorkspaceDirectoryResult struct { + Entries []WorkspaceDirectoryEntry `json:"entries"` + Truncated bool `json:"truncated"` +} + +func (result *WorkspaceDirectoryResult) UnmarshalJSON(data []byte) error { + var wire struct { + Entries []WorkspaceDirectoryEntry `json:"entries"` + Truncated *bool `json:"truncated"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + if wire.Truncated == nil { + return errors.New("workspace directory requires explicit truncation") + } + *result = WorkspaceDirectoryResult{Entries: wire.Entries, Truncated: *wire.Truncated} + return nil +} + +func ValidWorkspaceReadRequest(request WorkspaceReadPayload) bool { + if (request.Handle == "") == (request.RunID == "") || request.EnvironmentID == "" { + return false + } + switch request.Operation { + case "": + return request.MaxBytes >= 1 && request.MaxBytes <= WorkspaceReadMaxBytes && request.MaxEntries == 0 + case "directory": + return request.MaxBytes == 0 && request.MaxEntries >= 1 && request.MaxEntries <= WorkspaceDirectoryMaxEntries + default: + return false + } +} + +func ValidWorkspaceDirectory(result *WorkspaceDirectoryResult, limit int) bool { + if result == nil || result.Entries == nil || limit < 1 || limit > WorkspaceDirectoryMaxEntries || len(result.Entries) > limit { + return false + } + seen := make(map[string]bool, len(result.Entries)) + for _, entry := range result.Entries { + if entry.Name == "" || entry.Name == "." || entry.Name == ".." || len(entry.Name) > 255 || !utf8.ValidString(entry.Name) || strings.ContainsAny(entry.Name, "/\\\x00\r\n") || seen[entry.Name] { + return false + } + seen[entry.Name] = true + switch entry.Kind { + case "file": + if entry.SizeBytes == nil || *entry.SizeBytes < 0 { + return false + } + case "directory", "symlink", "other": + if entry.SizeBytes != nil { + return false + } + default: + return false + } + } + return true +} diff --git a/internal/agentdaemon/proto/workspace_export.go b/internal/agentdaemon/proto/workspace_export.go new file mode 100644 index 000000000..7b8d1f7a9 --- /dev/null +++ b/internal/agentdaemon/proto/workspace_export.go @@ -0,0 +1,31 @@ +package proto + +const ( + TypeWorkspaceExport = "workspace_export" + TypeWorkspaceExportResult = "workspace_export_result" + WorkspaceExportChunkBytes = 64 << 10 + WorkspaceExportMaxFrameBytes = 96 << 10 + // The archive allowance includes bounded headers and padding above 500 MiB of files. + WorkspaceExportMaxBytes int64 = 528 << 20 +) + +type WorkspaceExportPayload struct { + Step string `json:"step"` + Handle string `json:"handle,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + Offset int64 `json:"offset,omitempty"` +} + +type WorkspaceExportResultPayload struct { + Outcome string `json:"outcome"` + Offset int64 `json:"offset"` + Data []byte `json:"data,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func ValidWorkspaceExportRequest(p WorkspaceExportPayload) bool { + if p.Step == "begin" { + return p.Offset == 0 && len(p.Handle) > 0 && len(p.Handle) <= 128 && len(p.EnvironmentID) > 0 && len(p.EnvironmentID) <= 128 + } + return p.Handle == "" && p.EnvironmentID == "" && ((p.Step == "next" && p.Offset >= 0 && p.Offset <= WorkspaceExportMaxBytes) || (p.Step == "cancel" && p.Offset == 0)) +} diff --git a/internal/agentdaemon/proto/workspace_read.go b/internal/agentdaemon/proto/workspace_read.go new file mode 100644 index 000000000..5e6f7dcb6 --- /dev/null +++ b/internal/agentdaemon/proto/workspace_read.go @@ -0,0 +1,30 @@ +package proto + +const ( + TypeWorkspaceRead = "workspace_read" + TypeWorkspaceReadResult = "workspace_read_result" + WorkspaceReadMaxBytes = 1 << 20 + WorkspaceReadMaxRequestBytes = 8 << 10 + WorkspaceReadMaxIDBytes = 128 +) + +// WorkspaceReadPayload targets one existing resource on the current daemon connection. +type WorkspaceReadPayload struct { + Handle string `json:"handle,omitempty"` + RunID string `json:"run_id,omitempty"` + EnvironmentID string `json:"environment_id"` + Path string `json:"path"` + MaxBytes int `json:"max_bytes"` + Operation string `json:"operation,omitempty"` + MaxEntries int `json:"max_entries,omitempty"` +} + +// WorkspaceReadResultPayload never infers file settlement from local process exit. +type WorkspaceReadResultPayload struct { + Outcome string `json:"outcome"` + Data []byte `json:"data,omitempty"` + Truncated bool `json:"truncated,omitempty"` + CloseAcknowledged bool `json:"close_acknowledged,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Directory *WorkspaceDirectoryResult `json:"directory,omitempty"` +} diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go new file mode 100644 index 000000000..aa150eb26 --- /dev/null +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -0,0 +1,13 @@ +package proto + +// ValidWorkspaceReadPreparation excludes execution configuration and local paths. +// The native adapter supplies temporary state; this request cannot resume or start. +func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { + return r.WorkspaceReadOnly && ((r.RemoteEnvironment != nil) != (r.LocalEnvironment != nil)) && r.AgentStateKey != "" && + r.StrictResume && r.ReleaseOnCompletion && r.RunID == "" && r.Prompt == "" && + r.ConversationID == "" && r.AgentSessionID == "" && r.WorkDir == "" && + !r.RequireExistingNativeSession && !r.WorkspaceAuthoring && !r.DisableExecutionEnvironment && len(r.Attachments) == 0 && + len(r.AgentOptions) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && + len(r.FunctionTools) == 0 && !r.ObserveMessages && !r.ObserveTools && + !r.ObserveToolObservations && !r.ObserveSubagentIdentities +} diff --git a/internal/agentdaemon/proto/workspace_write.go b/internal/agentdaemon/proto/workspace_write.go new file mode 100644 index 000000000..19afdbf45 --- /dev/null +++ b/internal/agentdaemon/proto/workspace_write.go @@ -0,0 +1,63 @@ +package proto + +import ( + "encoding/hex" + "io/fs" + "strings" + + "github.com/google/uuid" +) + +const ( + TypeWorkspaceWrite = "workspace_write" + TypeWorkspaceWriteResult = "workspace_write_result" + WorkspaceWriteMaxBytes = 50 << 20 + WorkspaceWriteChunkBytes = 64 << 10 + WorkspaceWriteMaxFrameBytes = 96 << 10 +) + +// WorkspaceWritePayload transfers one complete body over the existing daemon +// connection. Envelope.ID is the durable Core mutation ID, never a retry key. +type WorkspaceWritePayload struct { + Step string `json:"step"` + EnvironmentID string `json:"environment_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Path string `json:"path,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Offset int `json:"offset,omitempty"` + Data []byte `json:"data,omitempty"` +} + +type WorkspaceWriteResultPayload struct { + Outcome string `json:"outcome"` + Offset int `json:"offset,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func ValidWorkspaceWriteRequest(p WorkspaceWritePayload) bool { + if p.Step == "begin" { + for _, id := range []string{p.EnvironmentID, p.SessionID} { + v, err := uuid.Parse(id) + if err != nil || v == uuid.Nil || v.String() != id { + return false + } + } + digest, err := hex.DecodeString(p.SHA256) + return err == nil && len(digest) == 32 && strings.ToLower(p.SHA256) == p.SHA256 && + p.SizeBytes >= 0 && p.SizeBytes <= WorkspaceWriteMaxBytes && p.Offset == 0 && len(p.Data) == 0 && + len(p.Path) <= 4096 && p.Path != "." && fs.ValidPath(p.Path) && !strings.ContainsAny(p.Path, "\\\x00\r\n") + } + if p.EnvironmentID != "" || p.SessionID != "" || p.Path != "" || p.SizeBytes != 0 || p.SHA256 != "" { + return false + } + switch p.Step { + case "chunk": + return p.Offset >= 0 && p.Offset <= WorkspaceWriteMaxBytes && len(p.Data) > 0 && len(p.Data) <= WorkspaceWriteChunkBytes + case "commit": + return p.Offset == 0 && len(p.Data) == 0 + default: + return false + } +} diff --git a/internal/agentskill/bundle.go b/internal/agentskill/bundle.go new file mode 100644 index 000000000..094292f59 --- /dev/null +++ b/internal/agentskill/bundle.go @@ -0,0 +1,166 @@ +// Package agentskill validates inert Skill bundles without native loading rules. +package agentskill + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "os" + "path" + "regexp" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +const ( + MaxArchiveBytes = 5 << 20 + MaxExpandedBytes = 20 << 20 + MaxFiles = 1000 +) + +var ErrInvalid = errors.New("invalid or unsupported Skill bundle") +var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:[-_][a-z0-9]+)*$`) + +type Metadata struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` +} + +type File struct { + Path string `json:"path"` + Data []byte `json:"data"` + Executable bool `json:"executable,omitempty"` +} + +// Read validates the full archive before exposing any files for installation. +func Read(archive []byte, expected Metadata) ([]File, error) { + if expected.Type != "inline" || !namePattern.MatchString(expected.Name) || len(expected.Name) > 64 || expected.Description == "" || !utf8.ValidString(expected.Description) || len(archive) > MaxArchiveBytes { + return nil, ErrInvalid + } + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil || len(reader.File) == 0 || len(reader.File) > MaxFiles { + return nil, ErrInvalid + } + root := "" + seen := map[string]bool{} + files := []File{} + total := 0 + manifest := false + for _, entry := range reader.File { + name := strings.TrimSuffix(entry.Name, "/") + if !utf8.ValidString(name) || len(name) > 4096 || strings.ContainsAny(name, "\\\x00\r\n") || path.Clean(name) != name || path.IsAbs(name) { + return nil, ErrInvalid + } + parts := strings.SplitN(name, "/", 2) + if parts[0] == "." || parts[0] == ".." || parts[0] == "" { + return nil, ErrInvalid + } + if root == "" { + root = parts[0] + } + if root != parts[0] || seen[name] || entry.Flags&1 != 0 { + return nil, ErrInvalid + } + seen[name] = true + if entry.Mode().Type() == os.ModeDir { + continue + } + if !entry.Mode().IsRegular() || len(parts) != 2 || entry.UncompressedSize64 > MaxExpandedBytes || total+int(entry.UncompressedSize64) > MaxExpandedBytes { + return nil, ErrInvalid + } + stream, err := entry.Open() + if err != nil { + return nil, ErrInvalid + } + body, readErr := io.ReadAll(io.LimitReader(stream, int64(MaxExpandedBytes-total)+1)) + closeErr := stream.Close() + if readErr != nil || closeErr != nil || len(body) > MaxExpandedBytes-total { + return nil, ErrInvalid + } + total += len(body) + if parts[1] == "SKILL.md" { + if ValidateManifest(body, expected) != nil { + return nil, ErrInvalid + } + manifest = true + } + files = append(files, File{Path: parts[1], Data: body, Executable: entry.Mode().Perm()&0111 != 0}) + } + if !manifest { + return nil, ErrInvalid + } + regular := map[string]bool{} + for _, f := range files { + regular[f.Path] = true + } + for _, f := range files { + for parent := path.Dir(f.Path); parent != "."; parent = path.Dir(parent) { + if regular[parent] { + return nil, ErrInvalid + } + } + } + return files, nil +} + +// ValidateManifest accepts portable descriptive metadata, not native activation controls. +func ValidateManifest(body []byte, expected Metadata) error { + if len(body) > 256<<10 || !utf8.Valid(body) { + return ErrInvalid + } + text := strings.ReplaceAll(string(body), "\r\n", "\n") + if !strings.HasPrefix(text, "---\n") { + return ErrInvalid + } + end := strings.Index(text[4:], "\n---") + if end < 0 { + return ErrInvalid + } + end += 4 + tail := text[end+4:] + if tail != "" && !strings.HasPrefix(tail, "\n") { + return ErrInvalid + } + decoder := yaml.NewDecoder(strings.NewReader(text[4:end])) + var document yaml.Node + if decoder.Decode(&document) != nil || len(document.Content) != 1 { + return ErrInvalid + } + var extra yaml.Node + if decoder.Decode(&extra) != io.EOF { + return ErrInvalid + } + node := document.Content[0] + if node.Kind != yaml.MappingNode { + return ErrInvalid + } + fields := map[string]*yaml.Node{} + for i := 0; i < len(node.Content); i += 2 { + key, value := node.Content[i], node.Content[i+1] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" || fields[key.Value] != nil { + return ErrInvalid + } + fields[key.Value] = value + switch key.Value { + case "name", "description", "license", "compatibility": + if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { + return ErrInvalid + } + case "metadata": + var metadata map[string]string + if value.Kind != yaml.MappingNode || value.Decode(&metadata) != nil { + return ErrInvalid + } + default: + return ErrInvalid + } + } + if fields["name"] == nil || fields["description"] == nil || fields["name"].Value != expected.Name || fields["description"].Value != expected.Description { + return ErrInvalid + } + return nil +} diff --git a/internal/agentskill/bundle_test.go b/internal/agentskill/bundle_test.go new file mode 100644 index 000000000..b725e7710 --- /dev/null +++ b/internal/agentskill/bundle_test.go @@ -0,0 +1,71 @@ +package agentskill + +import ( + "archive/zip" + "bytes" + "io/fs" + "testing" +) + +func TestBundle(t *testing.T) { + metadata := Metadata{Type: "inline", Name: "proof-skill", Description: "Run the proof."} + manifest := []byte("---\nname: proof-skill\ndescription: Run the proof.\n---\nRun scripts/check.py.\n") + makeArchive := func(paths []string, bodies [][]byte, mode fs.FileMode) []byte { + var b bytes.Buffer + w := zip.NewWriter(&b) + for i, p := range paths { + h := &zip.FileHeader{Name: p, Method: zip.Deflate} + h.SetMode(mode) + f, err := w.CreateHeader(h) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write(bodies[i]); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + data := makeArchive([]string{"folder/SKILL.md", "folder/scripts/check.py"}, [][]byte{manifest, {0, 1, 2}}, 0755) + files, err := Read(data, metadata) + if err != nil || len(files) != 2 || !bytes.Equal(files[1].Data, []byte{0, 1, 2}) || !files[1].Executable { + t.Fatalf("files=%+v err=%v", files, err) + } + for _, tc := range []struct { + name string + paths []string + bodies [][]byte + mode fs.FileMode + }{ + {"escape", []string{"folder/SKILL.md", "folder/../../private"}, [][]byte{manifest, {}}, 0600}, + {"duplicate", []string{"folder/SKILL.md", "folder/SKILL.md"}, [][]byte{manifest, manifest}, 0600}, + {"multiple roots", []string{"folder/SKILL.md", "other/file"}, [][]byte{manifest, {}}, 0600}, + {"symlink", []string{"folder/SKILL.md"}, [][]byte{manifest}, fs.ModeSymlink | 0600}, + {"file parent", []string{"folder/SKILL.md", "folder/a", "folder/a/b"}, [][]byte{manifest, {}, {}}, 0600}, + {"expanded limit", []string{"folder/SKILL.md", "folder/large"}, [][]byte{manifest, bytes.Repeat([]byte{0}, MaxExpandedBytes)}, 0600}, + {"missing manifest", []string{"folder/file"}, [][]byte{{}}, 0600}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := Read(makeArchive(tc.paths, tc.bodies, tc.mode), metadata); err == nil { + t.Fatal("invalid archive accepted") + } + }) + } + if _, err := Read(make([]byte, MaxArchiveBytes+1), metadata); err == nil { + t.Fatal("archive byte limit ignored") + } + for _, front := range []string{ + "name: other\ndescription: Run the proof.", + "name: proof-skill\nname: proof-skill\ndescription: Run the proof.", + "name: proof-skill\ndescription: Run the proof.\nhooks: {}", + "name: proof-skill\ndescription: Run the proof.\ncontext: fork", + "name: proof-skill\ndescription: Run the proof.\nallowed-tools: Bash", + } { + if ValidateManifest([]byte("---\n"+front+"\n---\nText"), metadata) == nil { + t.Fatal("unsupported manifest accepted") + } + } +} diff --git a/internal/obs/log/api.go b/internal/obs/log/api.go new file mode 100644 index 000000000..0b12a74d3 --- /dev/null +++ b/internal/obs/log/api.go @@ -0,0 +1,40 @@ +// Direct slog.{Info,Warn,Error,Debug,Default} use outside this package +// is blocked by .golangci.yml forbidigo so the ctx-first signatures +// here are how trace_id auto-injection stays enforceable. +package log + +import ( + "context" + "log/slog" +) + +// Info logs via slog.Default with ctx attached so ContextHandler can +// inject trace_id/span_id from ctx. +func Info(ctx context.Context, msg string, args ...any) { + slog.Default().InfoContext(ctx, msg, args...) +} + +func Warn(ctx context.Context, msg string, args ...any) { + slog.Default().WarnContext(ctx, msg, args...) +} + +func Error(ctx context.Context, msg string, args ...any) { + slog.Default().ErrorContext(ctx, msg, args...) +} + +func Debug(ctx context.Context, msg string, args ...any) { + slog.Default().DebugContext(ctx, msg, args...) +} + +// Bg returns slog.Default for ctx-less startup/init/shutdown sites. +// Using Bg() in any handler-path code is a bug — it bypasses trace +// attribution silently. +func Bg() *slog.Logger { + return slog.Default() +} + +// With binds attrs to a child logger that still routes through +// ContextHandler, so InfoContext etc. still pick up trace_id from ctx. +func With(args ...any) *slog.Logger { + return slog.Default().With(args...) +} diff --git a/internal/obs/log/api_test.go b/internal/obs/log/api_test.go new file mode 100644 index 000000000..3be844741 --- /dev/null +++ b/internal/obs/log/api_test.go @@ -0,0 +1,63 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestInfoEmitsTraceID(t *testing.T) { + carrier, _ := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil)))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + Info(WithTrace(context.Background(), carrier), "hello", "k", "v") + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &got); err != nil { + t.Fatalf("unmarshal: %v\nraw=%s", err, buf.String()) + } + if got[AttrTraceID] != "0af7651916cd43dd8448eb211c80319c" { + t.Fatalf("trace_id: %v", got[AttrTraceID]) + } + if got["k"] != "v" { + t.Fatalf("user attr lost: %v", got["k"]) + } +} + +// TestBgOmitsTraceID: Bg().Info must not emit trace_id — that's how +// "log line came from outside a request" stays distinguishable. +func TestBgOmitsTraceID(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil)))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + Bg().Info("startup") + if strings.Contains(buf.String(), AttrTraceID) { + t.Fatalf("Bg().Info should NOT emit trace_id; got %s", buf.String()) + } +} + +func TestWithBindsAttrsAndPreservesTrace(t *testing.T) { + carrier, _ := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil)))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + logger := With("component", "test") + logger.InfoContext(WithTrace(context.Background(), carrier), "msg") + + out := buf.String() + if !strings.Contains(out, `"component":"test"`) { + t.Fatalf("static attr missing: %s", out) + } + if !strings.Contains(out, "0af7651916cd43dd8448eb211c80319c") { + t.Fatalf("trace_id missing on logger built by With(): %s", out) + } +} diff --git a/internal/obs/log/background.go b/internal/obs/log/background.go new file mode 100644 index 000000000..f999bb9a3 --- /dev/null +++ b/internal/obs/log/background.go @@ -0,0 +1,37 @@ +package log + +import ( + "context" +) + +// StartBackgroundTrace returns a child ctx carrying a fresh Carrier +// so any log under it picks up a stable trace_id. Use at the top of +// every non-HTTP logical-request entrypoint (sweeper tick, WS envelope +// handler, CLI command). For a sub-step that should keep the trace, +// use ChildSpan instead. +func StartBackgroundTrace(parent context.Context, op string) (context.Context, Carrier) { + if parent == nil { + parent = context.Background() + } + c := NewCarrier() + ctx := WithTrace(parent, c) + if op != "" { + _ = op + } + return ctx, c +} + +// ChildSpan returns a ctx with the parent's trace_id and a fresh span_id. +// If the parent has no carrier, behaves like StartBackgroundTrace so +// callers don't have to branch on presence. +func ChildSpan(parent context.Context) (context.Context, Carrier) { + if parent == nil { + parent = context.Background() + } + if existing, ok := TraceFromContext(parent); ok { + child := existing.ChildSpan() + return WithTrace(parent, child), child + } + c := NewCarrier() + return WithTrace(parent, c), c +} diff --git a/internal/obs/log/carrier.go b/internal/obs/log/carrier.go new file mode 100644 index 000000000..dc6702632 --- /dev/null +++ b/internal/obs/log/carrier.go @@ -0,0 +1,88 @@ +package log + +import ( + "errors" + "strings" +) + +// Carrier is the in-memory form of a W3C `traceparent` value +// (`00-{32hex trace_id}-{16hex span_id}-{2hex flags}`). We do not +// parse `tracestate`. Sampled tracks the low bit of the flags byte; +// auto-generated carriers default to true. +type Carrier struct { + Trace TraceID + Span SpanID + Sampled bool +} + +// HeaderName is the canonical HTTP header for W3C traceparent. +const HeaderName = "Traceparent" + +// version is the only W3C version this package accepts on the wire. +const version = "00" + +// NewCarrier returns a fresh Carrier with newly-minted trace + span IDs. +func NewCarrier() Carrier { + return Carrier{ + Trace: NewTraceID(), + Span: NewSpanID(), + Sampled: true, + } +} + +// ChildSpan returns a Carrier sharing this Trace but with a fresh Span. +func (c Carrier) ChildSpan() Carrier { + return Carrier{Trace: c.Trace, Span: NewSpanID(), Sampled: c.Sampled} +} + +// String formats the Carrier as a W3C traceparent. Returns "" for a +// zero-trace Carrier so callers can use it as a presence check. +func (c Carrier) String() string { + if c.Trace.IsZero() || c.Span.IsZero() { + return "" + } + flags := "00" + if c.Sampled { + flags = "01" + } + var b strings.Builder + b.Grow(55) + b.WriteString(version) + b.WriteByte('-') + b.WriteString(c.Trace.String()) + b.WriteByte('-') + b.WriteString(c.Span.String()) + b.WriteByte('-') + b.WriteString(flags) + return b.String() +} + +// ParseTraceparent parses a W3C traceparent string. Any deviation +// (wrong length, bad hex, reserved sentinels) yields an error and +// callers should treat it as "no trace present". +func ParseTraceparent(s string) (Carrier, error) { + s = strings.TrimSpace(s) + if len(s) != 55 { + return Carrier{}, errors.New("traceparent must be 55 chars") + } + parts := strings.Split(s, "-") + if len(parts) != 4 { + return Carrier{}, errors.New("traceparent must have 4 hyphen-separated fields") + } + if parts[0] != version { + return Carrier{}, errors.New("unsupported traceparent version") + } + trace, err := ParseTraceID(parts[1]) + if err != nil { + return Carrier{}, err + } + span, err := ParseSpanID(parts[2]) + if err != nil { + return Carrier{}, err + } + if len(parts[3]) != 2 { + return Carrier{}, errors.New("traceparent flags must be 2 hex chars") + } + sampled := parts[3] == "01" || parts[3] == "03" + return Carrier{Trace: trace, Span: span, Sampled: sampled}, nil +} diff --git a/internal/obs/log/carrier_test.go b/internal/obs/log/carrier_test.go new file mode 100644 index 000000000..63f227d3e --- /dev/null +++ b/internal/obs/log/carrier_test.go @@ -0,0 +1,87 @@ +package log + +import ( + "strings" + "testing" +) + +func TestCarrierRoundTrip(t *testing.T) { + c := NewCarrier() + encoded := c.String() + if len(encoded) != 55 { + t.Fatalf("traceparent length: want 55, got %d (%q)", len(encoded), encoded) + } + if !strings.HasPrefix(encoded, "00-") || !strings.HasSuffix(encoded, "-01") { + t.Fatalf("traceparent shape: %q", encoded) + } + got, err := ParseTraceparent(encoded) + if err != nil { + t.Fatalf("ParseTraceparent on our own output: %v", err) + } + if got.Trace != c.Trace || got.Span != c.Span { + t.Fatalf("round-trip mismatch: out=%+v in=%+v", got, c) + } + if !got.Sampled { + t.Fatalf("sampled flag should round-trip true; got false") + } +} + +func TestParseTraceparentRejects(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"empty", ""}, + {"too-short", "00-1234"}, + {"too-long", "00-" + strings.Repeat("a", 32) + "-" + strings.Repeat("b", 16) + "-01-extra"}, + {"wrong-version", "ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}, + {"all-zero-trace", "00-" + strings.Repeat("0", 32) + "-b7ad6b7169203331-01"}, + {"all-zero-span", "00-0af7651916cd43dd8448eb211c80319c-" + strings.Repeat("0", 16) + "-01"}, + {"bad-hex-trace", "00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-b7ad6b7169203331-01"}, + {"bad-hex-span", "00-0af7651916cd43dd8448eb211c80319c-zzzzzzzzzzzzzzzz-01"}, + {"three-fields", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := ParseTraceparent(c.in); err == nil { + t.Fatalf("expected error for %q", c.in) + } + }) + } +} + +// TestParseTraceparentSampledBit: 00 = not sampled, 01 = sampled. +// "03" (sampled + random bit) also accepted as sampled per W3C +// "ignore unknown flag bits". +func TestParseTraceparentSampledBit(t *testing.T) { + cases := []struct { + flags string + sampled bool + }{ + {"00", false}, + {"01", true}, + {"03", true}, + } + const trace = "0af7651916cd43dd8448eb211c80319c" + const span = "b7ad6b7169203331" + for _, c := range cases { + t.Run(c.flags, func(t *testing.T) { + got, err := ParseTraceparent("00-" + trace + "-" + span + "-" + c.flags) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if got.Sampled != c.sampled { + t.Fatalf("sampled: want %v got %v", c.sampled, got.Sampled) + } + }) + } +} + +// TestCarrierZeroValueString: zero formats as "" so callers can use +// the formatted string as a "did we have a carrier" boolean. +func TestCarrierZeroValueString(t *testing.T) { + var zero Carrier + if got := zero.String(); got != "" { + t.Fatalf("zero carrier should format as empty; got %q", got) + } +} diff --git a/internal/obs/log/context.go b/internal/obs/log/context.go new file mode 100644 index 000000000..c382d98d4 --- /dev/null +++ b/internal/obs/log/context.go @@ -0,0 +1,64 @@ +package log + +import ( + "context" + "log/slog" +) + +// traceCtxKey is an unexported type so external packages cannot +// collide on the same context key. +type traceCtxKey struct{} + +// WithTrace returns ctx annotated with c. A zero-trace Carrier is a +// no-op so callers don't need a separate nil-check. +func WithTrace(ctx context.Context, c Carrier) context.Context { + if c.Trace.IsZero() { + return ctx + } + return context.WithValue(ctx, traceCtxKey{}, c) +} + +// TraceFromContext returns the Carrier attached by WithTrace, or +// (zero, false) when none is present. +func TraceFromContext(ctx context.Context) (Carrier, bool) { + if ctx == nil { + return Carrier{}, false + } + c, ok := ctx.Value(traceCtxKey{}).(Carrier) + if !ok || c.Trace.IsZero() { + return Carrier{}, false + } + return c, true +} + +// Ctx returns a ctxLogger so `log.Ctx(ctx).Info(...)` reads cleaner +// than `slog.Default().InfoContext(ctx, ...)`. ctxLogger is stateless +// w.r.t. trace IDs — every call re-reads ctx so a child span minted +// via StartBackgroundTrace is not cached as a stale carrier. +func Ctx(ctx context.Context) ctxLogger { return ctxLogger{ctx: ctx} } + +type ctxLogger struct { + ctx context.Context +} + +func (l ctxLogger) Debug(msg string, args ...any) { + slog.Default().DebugContext(l.ctx, msg, args...) +} + +func (l ctxLogger) Info(msg string, args ...any) { + slog.Default().InfoContext(l.ctx, msg, args...) +} + +func (l ctxLogger) Warn(msg string, args ...any) { + slog.Default().WarnContext(l.ctx, msg, args...) +} + +func (l ctxLogger) Error(msg string, args ...any) { + slog.Default().ErrorContext(l.ctx, msg, args...) +} + +// With returns a slog.Logger with the supplied attrs bound. Ctx-derived +// trace attrs still apply on subsequent InfoContext calls. +func (l ctxLogger) With(args ...any) *slog.Logger { + return slog.Default().With(args...) +} diff --git a/internal/obs/log/discard.go b/internal/obs/log/discard.go new file mode 100644 index 000000000..97fef6a5b --- /dev/null +++ b/internal/obs/log/discard.go @@ -0,0 +1,13 @@ +package log + +import ( + "io" + "log/slog" +) + +// Discard returns a *slog.Logger that drops every record. Wrapped in +// ContextHandler so tests behave identically to production. +func Discard() *slog.Logger { + inner := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 1}) + return slog.New(NewContextHandler(inner)) +} diff --git a/internal/obs/log/handler.go b/internal/obs/log/handler.go new file mode 100644 index 000000000..b41247f1e --- /dev/null +++ b/internal/obs/log/handler.go @@ -0,0 +1,51 @@ +package log + +import ( + "context" + "log/slog" +) + +// AttrTraceID / AttrSpanID are slog attr keys for the W3C IDs. Stable +// snake_case so log scrapers can build dashboards on a fixed name. +const ( + AttrTraceID = "trace_id" + AttrSpanID = "span_id" +) + +// ContextHandler wraps a slog.Handler and injects trace_id/span_id +// from the record's ctx. Records with no Carrier pass through +// unchanged so background tasks don't get fake all-zero IDs. +type ContextHandler struct { + inner slog.Handler +} + +func NewContextHandler(inner slog.Handler) *ContextHandler { + if inner == nil { + inner = slog.Default().Handler() + } + return &ContextHandler{inner: inner} +} + +func (h *ContextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error { + if carrier, ok := TraceFromContext(ctx); ok { + r.AddAttrs( + slog.String(AttrTraceID, carrier.Trace.String()), + slog.String(AttrSpanID, carrier.Span.String()), + ) + } + return h.inner.Handle(ctx, r) +} + +// WithAttrs / WithGroup MUST re-wrap so slog.Default().With(...) does +// not unwrap us and silently lose trace_id injection. +func (h *ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &ContextHandler{inner: h.inner.WithAttrs(attrs)} +} + +func (h *ContextHandler) WithGroup(name string) slog.Handler { + return &ContextHandler{inner: h.inner.WithGroup(name)} +} diff --git a/internal/obs/log/handler_test.go b/internal/obs/log/handler_test.go new file mode 100644 index 000000000..ae9e11a18 --- /dev/null +++ b/internal/obs/log/handler_test.go @@ -0,0 +1,64 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestContextHandlerInjectsTrace(t *testing.T) { + carrier, err := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + if err != nil { + t.Fatalf("seed parse: %v", err) + } + var buf bytes.Buffer + logger := slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil))) + ctx := WithTrace(context.Background(), carrier) + logger.InfoContext(ctx, "hello", "k", "v") + + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &got); err != nil { + t.Fatalf("unmarshal: %v\nraw=%s", err, buf.String()) + } + if got[AttrTraceID] != "0af7651916cd43dd8448eb211c80319c" { + t.Fatalf("trace_id: %v", got[AttrTraceID]) + } + if got[AttrSpanID] != "b7ad6b7169203331" { + t.Fatalf("span_id: %v", got[AttrSpanID]) + } + if got["k"] != "v" { + t.Fatalf("user attr lost: %v", got["k"]) + } +} + +// TestContextHandlerSkipsWhenNoTrace: no Carrier → no trace_id attr. +// Callers rely on "no trace_id field" to mean "outside a request". +func TestContextHandlerSkipsWhenNoTrace(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil))) + logger.InfoContext(context.Background(), "no trace here") + if strings.Contains(buf.String(), AttrTraceID) { + t.Fatalf("trace_id should be absent when ctx has no carrier; got %s", buf.String()) + } +} + +// TestContextHandlerWithAttrsPreservesInjection: if WithAttrs unwraps +// our handler, trace injection silently stops the moment someone calls +// logger.With(...). Guard against that regression. +func TestContextHandlerWithAttrsPreservesInjection(t *testing.T) { + carrier, _ := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + var buf bytes.Buffer + root := slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil))) + child := root.With("component", "test") + child.InfoContext(WithTrace(context.Background(), carrier), "msg") + out := buf.String() + if !strings.Contains(out, "0af7651916cd43dd8448eb211c80319c") { + t.Fatalf("trace_id missing after With(): %s", out) + } + if !strings.Contains(out, `"component":"test"`) { + t.Fatalf("static attr missing: %s", out) + } +} diff --git a/internal/obs/log/http.go b/internal/obs/log/http.go new file mode 100644 index 000000000..09a8b4a22 --- /dev/null +++ b/internal/obs/log/http.go @@ -0,0 +1,35 @@ +package log + +import ( + "net/http" +) + +// HTTPMiddleware adopts a valid inbound `traceparent`, mints a fresh +// Carrier otherwise, echoes it on the response, and installs it on +// ctx so downstream `log.Ctx(ctx).Info(...)` gets trace attrs. A +// malformed header is treated identically to a missing one — never +// 500 a real request over a logging concern. +func HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + carrier, ok := parseInbound(r.Header.Get(HeaderName)) + if !ok { + carrier = NewCarrier() + } + w.Header().Set(HeaderName, carrier.String()) + ctx := WithTrace(r.Context(), carrier) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// parseInbound returns (carrier, true) only when the header parses as +// a valid W3C traceparent. Missing and malformed both yield false. +func parseInbound(headerValue string) (Carrier, bool) { + if headerValue == "" { + return Carrier{}, false + } + c, err := ParseTraceparent(headerValue) + if err != nil { + return Carrier{}, false + } + return c, true +} diff --git a/internal/obs/log/http_test.go b/internal/obs/log/http_test.go new file mode 100644 index 000000000..c8930f03b --- /dev/null +++ b/internal/obs/log/http_test.go @@ -0,0 +1,122 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHTTPMiddlewareAdoptsInboundHeader(t *testing.T) { + const tp = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil)))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + h := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ctx(r.Context()).Info("handler ran") + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set(HeaderName, tp) + h.ServeHTTP(rec, req) + + if rec.Header().Get(HeaderName) != tp { + t.Fatalf("response header echo: want %q got %q", tp, rec.Header().Get(HeaderName)) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &got); err != nil { + t.Fatalf("unmarshal: %v\nraw=%s", err, buf.String()) + } + if got[AttrTraceID] != "0af7651916cd43dd8448eb211c80319c" { + t.Fatalf("trace_id: %v", got[AttrTraceID]) + } +} + +func TestHTTPMiddlewareMintsForMissingHeader(t *testing.T) { + var observed string + h := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, ok := TraceFromContext(r.Context()) + if !ok { + t.Fatalf("expected ctx to have a fresh carrier") + } + observed = c.String() + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h.ServeHTTP(rec, req) + + got := rec.Header().Get(HeaderName) + if got == "" { + t.Fatalf("missing response header") + } + if got != observed { + t.Fatalf("response header differs from ctx carrier: header=%q ctx=%q", got, observed) + } +} + +// TestHTTPMiddlewareIgnoresMalformedHeader: bad header is treated as +// "no header" — never 500 a real request over a logging concern. +func TestHTTPMiddlewareIgnoresMalformedHeader(t *testing.T) { + called := false + h := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + c, _ := TraceFromContext(r.Context()) + if c.Trace.IsZero() { + t.Fatalf("middleware should mint a carrier even with bad inbound header") + } + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(HeaderName, "garbage") + h.ServeHTTP(rec, req) + if !called { + t.Fatalf("handler not invoked") + } +} + +func TestStartBackgroundTraceMintsFresh(t *testing.T) { + ctx, c := StartBackgroundTrace(context.Background(), "test.op") + if c.Trace.IsZero() { + t.Fatalf("StartBackgroundTrace returned zero carrier") + } + got, ok := TraceFromContext(ctx) + if !ok || got.Trace != c.Trace { + t.Fatalf("ctx carrier mismatch: %+v want %+v", got, c) + } +} + +// TestChildSpanKeepsTraceRotatesSpan: child shares trace_id, has a +// different span_id. +func TestChildSpanKeepsTraceRotatesSpan(t *testing.T) { + parentCtx, parent := StartBackgroundTrace(context.Background(), "") + _, child := ChildSpan(parentCtx) + if child.Trace != parent.Trace { + t.Fatalf("child should keep trace; parent=%s child=%s", + parent.Trace.String(), child.Trace.String()) + } + if child.Span == parent.Span { + t.Fatalf("child should rotate span; both=%s", child.Span.String()) + } +} + +func TestCtxLoggerEmits(t *testing.T) { + carrier, _ := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(NewContextHandler(slog.NewJSONHandler(&buf, nil)))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + Ctx(WithTrace(context.Background(), carrier)).Info("via ctxLogger", "k", "v") + if !strings.Contains(buf.String(), "0af7651916cd43dd8448eb211c80319c") { + t.Fatalf("Ctx(ctx).Info should pipe ctx into handler: %s", buf.String()) + } +} diff --git a/internal/obs/log/init.go b/internal/obs/log/init.go new file mode 100644 index 000000000..1c85c7b72 --- /dev/null +++ b/internal/obs/log/init.go @@ -0,0 +1,107 @@ +package log + +import ( + "io" + "log/slog" + "os" + "strings" + "sync" +) + +// Config drives Init. +type Config struct { + // Format is "json" or "text". Empty auto-detects: text on a TTY, + // JSON otherwise. + Format string + // Level is the minimum slog level. Empty defaults to Info. + Level slog.Level + // AddSource toggles slog's filename:line attribute (~hundreds of + // ns per line — fine in dev, costly in prod). + AddSource bool + // Out is the destination writer. Nil defaults to os.Stderr. + Out io.Writer +} + +// ConfigFromEnv reads: +// +// PARSAR_LOG_FORMAT = json | text (default: auto) +// PARSAR_LOG_LEVEL = debug | info | warn | error (default: info) +// PARSAR_LOG_ADD_SOURCE = 0 | 1 (default: 0) +// +// Unknown values fall back to defaults — Init runs before most +// error-handling exists, so "boot anyway" beats "panic on typo". +func ConfigFromEnv() Config { + cfg := Config{ + Format: strings.ToLower(strings.TrimSpace(os.Getenv("PARSAR_LOG_FORMAT"))), + Level: parseLevel(os.Getenv("PARSAR_LOG_LEVEL")), + AddSource: os.Getenv("PARSAR_LOG_ADD_SOURCE") == "1", + Out: os.Stderr, + } + return cfg +} + +// isTerminal reports whether f is a character device (TTY) so the JSON +// vs. text auto-detect doesn't need the golang.org/x/term dep. +func isTerminal(f *os.File) bool { + if f == nil { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error", "err": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// initOnce guarantees Init's slog.SetDefault side-effect runs at most +// once per process so tests don't fight over the global handler. +var initOnce sync.Once + +// Init installs ContextHandler as slog.Default. Calling more than once +// is a no-op — only one global slog handler exists. +func Init(cfg Config) { + initOnce.Do(func() { + slog.SetDefault(buildLogger(cfg)) + }) +} + +// buildLogger is split out so tests can build a logger without +// triggering the global SetDefault side-effect. +func buildLogger(cfg Config) *slog.Logger { + out := cfg.Out + if out == nil { + out = os.Stderr + } + opts := &slog.HandlerOptions{ + Level: cfg.Level, + AddSource: cfg.AddSource, + } + format := cfg.Format + if format == "" { + format = "json" + if f, ok := out.(*os.File); ok && isTerminal(f) { + format = "text" + } + } + var inner slog.Handler + switch format { + case "text": + inner = slog.NewTextHandler(out, opts) + default: + inner = slog.NewJSONHandler(out, opts) + } + return slog.New(NewContextHandler(inner)) +} diff --git a/internal/obs/log/trace.go b/internal/obs/log/trace.go new file mode 100644 index 000000000..83f5ad912 --- /dev/null +++ b/internal/obs/log/trace.go @@ -0,0 +1,86 @@ +// Package log wraps log/slog with a context-aware handler that injects +// W3C trace IDs from ctx so a single request — HTTP, WS envelope, +// sweeper tick — can be grepped end-to-end by `trace_id`. +package log + +import ( + "crypto/rand" + "encoding/hex" + "errors" +) + +// TraceID is the W3C trace-id: 16 random bytes, rendered as 32 +// lowercase hex chars. All-zero is reserved and rejected by parsers. +type TraceID [16]byte + +// SpanID is the W3C span-id: 8 random bytes, 16 lowercase hex chars. +// All-zero is reserved. +type SpanID [8]byte + +// NewTraceID returns a fresh random TraceID. On the vanishingly rare +// crypto/rand failure the result is the zero ID; the handler then +// skips trace-attr injection rather than emitting all-zero strings. +func NewTraceID() TraceID { + var id TraceID + _, _ = rand.Read(id[:]) + return id +} + +func NewSpanID() SpanID { + var id SpanID + _, _ = rand.Read(id[:]) + return id +} + +func (t TraceID) String() string { return hex.EncodeToString(t[:]) } + +func (s SpanID) String() string { return hex.EncodeToString(s[:]) } + +func (t TraceID) IsZero() bool { + for _, b := range t { + if b != 0 { + return false + } + } + return true +} + +func (s SpanID) IsZero() bool { + for _, b := range s { + if b != 0 { + return false + } + } + return true +} + +// ParseTraceID decodes a 32-char lowercase hex string. Rejects +// all-zero per W3C reserved-sentinel rule. +func ParseTraceID(s string) (TraceID, error) { + var out TraceID + if len(s) != 32 { + return out, errors.New("trace id must be 32 hex chars") + } + if _, err := hex.Decode(out[:], []byte(s)); err != nil { + return out, errors.New("trace id is not hex") + } + if out.IsZero() { + return out, errors.New("trace id is all-zero") + } + return out, nil +} + +// ParseSpanID decodes a 16-char lowercase hex string. Rejects all-zero. +func ParseSpanID(s string) (SpanID, error) { + var out SpanID + if len(s) != 16 { + return out, errors.New("span id must be 16 hex chars") + } + if _, err := hex.Decode(out[:], []byte(s)); err != nil { + return out, errors.New("span id is not hex") + } + if out.IsZero() { + return out, errors.New("span id is all-zero") + } + return out, nil +} diff --git a/internal/runtimecrypto/cmd/emit-fixture/main.go b/internal/runtimecrypto/cmd/emit-fixture/main.go new file mode 100644 index 000000000..bfacfa768 --- /dev/null +++ b/internal/runtimecrypto/cmd/emit-fixture/main.go @@ -0,0 +1,76 @@ +// One-shot generator for the cross-language wire-format fixture. Run +// by hand: +// +// go run ./internal/runtimecrypto/cmd/emit-fixture > internal/runtimecrypto/testdata/wire_v1.json +// +// The output is COMMITTED and read-only at test time. `//go:build +// ignore` keeps this invisible to `go build ./...` / `go test ./...`, +// and the stderr WARNING forces the operator to notice they are about +// to mutate a wire-locked artefact. A *_test.go that wrote the file +// would silently green-light protocol drift on every CI run. + +//go:build ignore + +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + + runtimecrypto "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" + "golang.org/x/crypto/nacl/box" +) + +type fixture struct { + Description string `json:"description"` + WireFormat string `json:"wire_format"` + RecipientPubB64 string `json:"recipient_public_key_b64"` + RecipientPrivB64 string `json:"recipient_private_key_b64"` + CipherB64 string `json:"cipher_b64"` + PlaintextUTF8 string `json:"plaintext_utf8"` +} + +func main() { + fmt.Fprintln(os.Stderr, + "WARNING: this regenerates internal/runtimecrypto/testdata/wire_v1.json.") + fmt.Fprintln(os.Stderr, + " Only commit the new bytes if you intentionally bumped the wire protocol version.") + fmt.Fprintln(os.Stderr, + " Re-generating without a version bump silently breaks the protocol-version lock.") + + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + fmt.Fprintf(os.Stderr, "keygen: %v\n", err) + os.Exit(1) + } + pubB64 := base64.StdEncoding.EncodeToString(pub[:]) + privB64 := base64.StdEncoding.EncodeToString(priv[:]) + + plaintext := `{"api_key":"sk-wire-fixture","provider":"anthropic"}` + cipherB64, err := runtimecrypto.SealForRuntime([]byte(plaintext), pubB64) + if err != nil { + fmt.Fprintf(os.Stderr, "seal: %v\n", err) + os.Exit(1) + } + + out := fixture{ + Description: "Wire fixture for Parsar runtime credential envelope. " + + "Recipient keypair is committed for test reproducibility. DO NOT use these " + + "keys in any other context. Wire format: NaCl SealAnonymous (X25519 + " + + "XSalsa20-Poly1305), nonce = BLAKE2b-24(ephPub || recipientPub).", + WireFormat: "nacl_sealed_box_v1", + RecipientPubB64: pubB64, + RecipientPrivB64: privB64, + CipherB64: cipherB64, + PlaintextUTF8: plaintext, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintf(os.Stderr, "encode: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/runtimecrypto/runtime_seal.go b/internal/runtimecrypto/runtime_seal.go new file mode 100644 index 000000000..a06eb0eed --- /dev/null +++ b/internal/runtimecrypto/runtime_seal.go @@ -0,0 +1,110 @@ +// Package runtimecrypto implements envelope-encryption for shipping +// sensitive payloads (model API keys, per-run secrets) from the +// Parsar server to a paired Agent Daemon without putting plaintext +// on the wire. +// +// Algorithm: NaCl sealed box (X25519 + XSalsa20-Poly1305) via +// box.SealAnonymous. Nonce = BLAKE2b-24(ephPub || recipientPub). +// Wire format: base64(stdEncoding) so it travels safely inside JSON. +// The committed static fixture (testdata/wire_v1.json) keeps the wire +// format locked across refactors. +// +// Threat model: +// - Server logs / debug dumps / DB middlewares see ciphertext only. +// - Daemon-machine compromise is OUT of scope (private key lives +// there and the host is trusted by definition). +// - Server compromise is OUT of scope (a malicious server could +// skip encryption entirely). +package runtimecrypto + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + + "golang.org/x/crypto/nacl/box" +) + +// PublicKeySize / PrivateKeySize match nacl/box (32 bytes Curve25519). +const ( + PublicKeySize = 32 + PrivateKeySize = 32 +) + +// ErrInvalidPublicKey wraps any reason the recipient public key is +// unusable (wrong length, bad base64). API layer returns 400. +var ErrInvalidPublicKey = errors.New("runtime crypto: invalid public key") + +// ErrDecryptFailed is returned when ciphertext fails authentication +// (tampered, wrong recipient key, malformed envelope). Generic by +// design — exposing the specific reason helps attackers. +var ErrDecryptFailed = errors.New("runtime crypto: decrypt failed") + +// SealForRuntime encrypts plaintext for the runtime identified by its +// base64-encoded X25519 public key (as stored in +// runtimes.config.runner_public_key). +// +// Output is base64(stdEncoding) of an anonymous sealed box: each call +// generates a fresh ephemeral keypair and embeds the public half in +// the envelope. Receiver derives the shared key from that and its own +// private key. +func SealForRuntime(plaintext []byte, runnerPublicKeyB64 string) (string, error) { + pub, err := decodePublicKey(runnerPublicKeyB64) + if err != nil { + return "", err + } + sealed, err := box.SealAnonymous(nil, plaintext, &pub, rand.Reader) + if err != nil { + return "", fmt.Errorf("runtime crypto: seal: %w", err) + } + return base64.StdEncoding.EncodeToString(sealed), nil +} + +// OpenSeal decrypts a SealForRuntime output using the recipient's +// keypair. Kept here for round-trip tests and to lock the wire format +// down in one place. +func OpenSeal(cipherB64 string, publicKey, privateKey [32]byte) ([]byte, error) { + sealed, err := base64.StdEncoding.DecodeString(cipherB64) + if err != nil { + return nil, ErrDecryptFailed + } + out, ok := box.OpenAnonymous(nil, sealed, &publicKey, &privateKey) + if !ok { + return nil, ErrDecryptFailed + } + return out, nil +} + +// GenerateRuntimeKeypair returns a fresh (publicKey, privateKey) pair +// as base64. Caller MUST persist the private key with mode 0600 and +// never log it. +func GenerateRuntimeKeypair() (publicKeyB64, privateKeyB64 string, err error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("runtime crypto: keygen: %w", err) + } + return base64.StdEncoding.EncodeToString(pub[:]), + base64.StdEncoding.EncodeToString(priv[:]), + nil +} + +// DecodeKey turns a base64-encoded 32-byte key into a [32]byte so +// daemon code and the API layer share one parser instead of inlining +// base64 + length checks at every caller. +func DecodeKey(b64 string) ([32]byte, error) { + var out [32]byte + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return out, ErrInvalidPublicKey + } + if len(raw) != PublicKeySize { + return out, ErrInvalidPublicKey + } + copy(out[:], raw) + return out, nil +} + +func decodePublicKey(b64 string) ([32]byte, error) { + return DecodeKey(b64) +} diff --git a/internal/runtimecrypto/runtime_seal_test.go b/internal/runtimecrypto/runtime_seal_test.go new file mode 100644 index 000000000..5852a5a4d --- /dev/null +++ b/internal/runtimecrypto/runtime_seal_test.go @@ -0,0 +1,135 @@ +package runtimecrypto + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "testing" +) + +func TestSealOpenRoundTrip(t *testing.T) { + pub, priv, err := GenerateRuntimeKeypair() + if err != nil { + t.Fatalf("keygen: %v", err) + } + pubArr, err := DecodeKey(pub) + if err != nil { + t.Fatalf("decode pub: %v", err) + } + privArr, err := DecodeKey(priv) + if err != nil { + t.Fatalf("decode priv: %v", err) + } + + plain := []byte(`{"api_key":"sk-secret","provider":"anthropic"}`) + cipher, err := SealForRuntime(plain, pub) + if err != nil { + t.Fatalf("seal: %v", err) + } + if cipher == "" { + t.Fatal("empty cipher") + } + if bytes.Contains([]byte(cipher), []byte("sk-secret")) { + t.Fatal("ciphertext leaks plaintext") + } + got, err := OpenSeal(cipher, pubArr, privArr) + if err != nil { + t.Fatalf("open: %v", err) + } + if !bytes.Equal(got, plain) { + t.Errorf("decrypt mismatch:\n got %q\n want %q", got, plain) + } +} + +// Each call uses a fresh ephemeral keypair so the same plaintext +// yields different ciphertexts. +func TestSealNonDeterministic(t *testing.T) { + pub, _, err := GenerateRuntimeKeypair() + if err != nil { + t.Fatalf("keygen: %v", err) + } + c1, _ := SealForRuntime([]byte("hello"), pub) + c2, _ := SealForRuntime([]byte("hello"), pub) + if c1 == c2 { + t.Errorf("two seals of same plaintext should differ; got identical") + } +} + +// Tampered ciphertext must fail open (Poly1305 authenticator). +func TestOpenTamperedFails(t *testing.T) { + pub, priv, _ := GenerateRuntimeKeypair() + pubArr, _ := DecodeKey(pub) + privArr, _ := DecodeKey(priv) + cipher, _ := SealForRuntime([]byte("payload"), pub) + + raw, _ := base64.StdEncoding.DecodeString(cipher) + raw[len(raw)/2] ^= 0xFF + tampered := base64.StdEncoding.EncodeToString(raw) + if _, err := OpenSeal(tampered, pubArr, privArr); err != ErrDecryptFailed { + t.Errorf("tampered ciphertext: got err=%v, want ErrDecryptFailed", err) + } +} + +// Wrong recipient private key must fail open. +func TestOpenWrongKeyFails(t *testing.T) { + pub, _, _ := GenerateRuntimeKeypair() + cipher, _ := SealForRuntime([]byte("payload"), pub) + + otherPub, otherPriv, _ := GenerateRuntimeKeypair() + otherPubArr, _ := DecodeKey(otherPub) + otherPrivArr, _ := DecodeKey(otherPriv) + if _, err := OpenSeal(cipher, otherPubArr, otherPrivArr); err != ErrDecryptFailed { + t.Errorf("wrong key: got err=%v, want ErrDecryptFailed", err) + } +} + +// Bad public key (wrong length / non-base64) must error, not panic. +func TestSealRejectsBadPublicKey(t *testing.T) { + cases := []string{ + "", + "not-base64-!!!", + base64.StdEncoding.EncodeToString([]byte("short")), + } + for _, k := range cases { + if _, err := SealForRuntime([]byte("x"), k); err != ErrInvalidPublicKey { + t.Errorf("SealForRuntime(%q): got err=%v, want ErrInvalidPublicKey", k, err) + } + } +} + +func TestKeypairUnique(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 16; i++ { + pub, priv, _ := GenerateRuntimeKeypair() + if seen[pub] { + t.Fatalf("duplicate pub at i=%d", i) + } + if seen[priv] { + t.Fatalf("duplicate priv at i=%d", i) + } + seen[pub] = true + seen[priv] = true + } +} + +// Guard against a zero/empty-ciphertext regression if crypto/rand is +// not wired into the build. +func TestSealNotEmptyEvenForEmptyPlaintext(t *testing.T) { + pub, _, _ := GenerateRuntimeKeypair() + cipher, err := SealForRuntime([]byte{}, pub) + if err != nil { + t.Fatalf("seal empty: %v", err) + } + // nacl sealed-box overhead = 32 (ephemeral pub) + 16 (poly1305) = 48 + raw, _ := base64.StdEncoding.DecodeString(cipher) + if len(raw) < 48 { + t.Errorf("ciphertext too short: %d bytes, want >= 48", len(raw)) + } +} + +func TestRandomReaderAvailable(t *testing.T) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("crypto/rand unavailable: %v", err) + } +} diff --git a/internal/runtimecrypto/runtime_seal_wire_test.go b/internal/runtimecrypto/runtime_seal_wire_test.go new file mode 100644 index 000000000..1f439cb11 --- /dev/null +++ b/internal/runtimecrypto/runtime_seal_wire_test.go @@ -0,0 +1,56 @@ +package runtimecrypto + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// wireFixture matches the JSON shape emitted by cmd/emit-fixture and +// committed to testdata/wire_v1.json. Explicit fields so a future +// schema bump (wire_format != nacl_sealed_box_v1) breaks decoding +// instead of silently passing. +type wireFixture struct { + Description string `json:"description"` + WireFormat string `json:"wire_format"` + RecipientPubB64 string `json:"recipient_public_key_b64"` + RecipientPrivB64 string `json:"recipient_private_key_b64"` + CipherB64 string `json:"cipher_b64"` + PlaintextUTF8 string `json:"plaintext_utf8"` +} + +// TestOpenStaticWireFixture decrypts the committed wire vector via +// the production OpenSeal path. Canonical regression guard for +// cross-language protocol drift — if either side's algorithm +// (Go nacl/box, Node tweetnacl + @noble/hashes) changes, this fails +// on the first commit after the drift. +func TestOpenStaticWireFixture(t *testing.T) { + path := filepath.Join("testdata", "wire_v1.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var f wireFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if f.WireFormat != "nacl_sealed_box_v1" { + t.Fatalf("wire_format=%q, want nacl_sealed_box_v1 (test is locked to v1)", f.WireFormat) + } + pub, err := DecodeKey(f.RecipientPubB64) + if err != nil { + t.Fatalf("decode pub: %v", err) + } + priv, err := DecodeKey(f.RecipientPrivB64) + if err != nil { + t.Fatalf("decode priv: %v", err) + } + got, err := OpenSeal(f.CipherB64, pub, priv) + if err != nil { + t.Fatalf("open static fixture: %v", err) + } + if string(got) != f.PlaintextUTF8 { + t.Errorf("plaintext mismatch:\n got %q\n want %q", got, f.PlaintextUTF8) + } +} diff --git a/internal/runtimecrypto/testdata/wire_v1.json b/internal/runtimecrypto/testdata/wire_v1.json new file mode 100644 index 000000000..78f259502 --- /dev/null +++ b/internal/runtimecrypto/testdata/wire_v1.json @@ -0,0 +1,8 @@ +{ + "description": "Wire fixture for Parsar runtime credential envelope. Recipient keypair is committed for test reproducibility. DO NOT use these keys in any other context. Wire format: NaCl SealAnonymous (X25519 + XSalsa20-Poly1305), nonce = BLAKE2b-24(ephPub || recipientPub).", + "wire_format": "nacl_sealed_box_v1", + "recipient_public_key_b64": "kxmWMYeGYw4zwKGkoXBQUh3Ac6CCD0jUechNvXEwRSk=", + "recipient_private_key_b64": "6b/6ZGaX77mlq1Q/ZUZd/T67wNc9orIpSslluBMYXz8=", + "cipher_b64": "+xJ0WbN+YwxXhccjZDMJfkRNdICegZpBkkDg2lS1gEpqVXh3ABfYWtz0vxR9zOf9JpeD0z6yprJyDdWkhn8lHbf3Mp+0Z1sMAp9BHoHO61FCGB3DdnZiHp7dTI4NAKM62Zs+LQ==", + "plaintext_utf8": "{\"api_key\":\"sk-wire-fixture\",\"provider\":\"anthropic\"}" +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..308540414 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "parsar-core", + "private": true, + "packageManager": "pnpm@10.30.3", + "scripts": { + "typecheck": "pnpm --filter @parsar/claude-sdk-adapter typecheck", + "test": "pnpm --filter @parsar/claude-sdk-adapter test" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/agents-client/README.md b/packages/agents-client/README.md new file mode 100644 index 000000000..5acb6a258 --- /dev/null +++ b/packages/agents-client/README.md @@ -0,0 +1,56 @@ +# Agents API Go client + +`v1` configures the [official openai-go SDK](https://github.com/openai/openai-go/tree/v3.61.0), +pinned in the root `go.mod`. It returns the SDK's Session service directly. Request +types, response parsing, cursor pagination, events and errors remain SDK-owned. +The external protocol baseline remains the pinned Python SDK in +[`contracts/agents-api/upstream.json`](../../contracts/agents-api/upstream.json). + +```go +import ( + agentsclient "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +sessions, err := agentsclient.New(agentsclient.Config{ + BaseURL: serviceBaseURL, // Includes /v1; use TLS for remote connections. + APIKey: serviceKey, // Execution tenant identity, not a product login token. +}) +if err != nil { + return err +} +session, err := sessions.New(ctx, openai.BetaAgentSessionNewParams{ + Agent: openai.BetaAgentSessionNewParamsAgent{ + Model: openai.String("requested-model"), + Instructions: openai.String("Follow the supplied instructions."), + }, + Environment: openai.EnvironmentParamUnion{ + OfParamNone: &openai.EnvironmentParamNone{}, + }, +}, option.WithHeader("Idempotency-Key", operationID)) +``` + +Use a stable, non-secret operation ID for a creation retry, with the same request. +Omitting the key creates a new Session on each call. SDK retries are disabled by +default. Requests honor the caller's context; the default HTTP timeout is 30 seconds. +An optional trusted HTTP client can configure the transport/timeout. Its cookie jar +is ignored and redirects are rejected. No OpenAI environment credentials or product +session cookies are inherited. Per-request SDK options are trusted application code; +do not accept them from end users. + +Use `sessions.Get`, `sessions.List` and the returned page's `GetNextPage` directly. +Errors can be inspected using `errors.As(err, &apiErr)` with `*openai.Error`. +SDK errors retain the request and response: log selected status/code fields, not +raw errors, request dumps or credentials. Constructing this client does not switch +Parsar's current execution flow or grant workspace/user permissions. + +The SDK includes more methods than the server currently supports. Only the +[documented Session subset](../../contracts/agents-api/README.md) is implemented; +other methods receive explicit service errors. Team orchestration belongs in Parsar +and depends on `openai-agents-python`, not this client package. + +`make check-go` includes configuration tests. The real-service harness in +`services/agents-api/tests/official_client.py` runs `TestService` with fresh tenants +and a dedicated PostgreSQL database. It validates Go-created Sessions through the +official Python SDK as well. No product database or model calls are involved. diff --git a/packages/agents-client/v1/client.go b/packages/agents-client/v1/client.go new file mode 100644 index 000000000..47076eaec --- /dev/null +++ b/packages/agents-client/v1/client.go @@ -0,0 +1,58 @@ +// Package v1 configures the official Go SDK for Parsar's independent Agents API. +package v1 + +import ( + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +type Config struct { + // BaseURL includes the API prefix, for example https://agents.example/v1. + BaseURL string + APIKey string + // HTTPClient optionally supplies a trusted transport and timeout. The client + // is copied; its cookie jar and redirect policy are not used. + HTTPClient *http.Client +} + +// New returns the official Session service. It does not load OpenAI environment +// credentials or switch Parsar's execution path. Callers use SDK types, pagination +// and *openai.Error directly, and supply Idempotency-Key for creation retries. +// SDK retries are disabled; an uncertain write can be retried with the same key. +func New(cfg Config) (openai.BetaAgentSessionService, error) { + u, err := url.Parse(cfg.BaseURL) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return openai.BetaAgentSessionService{}, errors.New("agents API base URL must be an absolute HTTP(S) URL without credentials, query or fragment") + } + if strings.TrimSpace(cfg.APIKey) == "" || strings.ContainsAny(cfg.APIKey, " \t\r\n") { + return openai.BetaAgentSessionService{}, errors.New("an explicit agents API key without whitespace is required") + } + h := http.Client{Timeout: 30 * time.Second} + if cfg.HTTPClient != nil { + h = *cfg.HTTPClient + } + // Product cookies and redirected credentials must not cross this boundary. + h.Jar = nil + h.CheckRedirect = func(*http.Request, []*http.Request) error { return errors.New("agents API redirects are disabled") } + return openai.NewBetaAgentSessionService( + option.WithBaseURL(strings.TrimRight(u.String(), "/")+"/"), + option.WithAPIKey(cfg.APIKey), + option.WithHTTPClient(&h), + option.WithMaxRetries(0), + ), nil +} + +// NewAgents exposes the complete pinned Agents API using the same isolated transport. +func NewAgents(cfg Config) (openai.BetaAgentService, error) { + sessions, err := New(cfg) + if err != nil { + return openai.BetaAgentService{}, err + } + return openai.NewBetaAgentService(sessions.Options...), nil +} diff --git a/packages/agents-client/v1/client_test.go b/packages/agents-client/v1/client_test.go new file mode 100644 index 000000000..0c798dbd9 --- /dev/null +++ b/packages/agents-client/v1/client_test.go @@ -0,0 +1,85 @@ +package v1_test + +import ( + "context" + "errors" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + client "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + "github.com/openai/openai-go/v3" +) + +func TestExplicitServiceIdentityAndNoSDKRetries(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "unrelated-key") + t.Setenv("OPENAI_BASE_URL", "http://unrelated.invalid") + t.Setenv("OPENAI_ORG_ID", "unrelated-org") + calls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/prefix/v1/agents/sessions/session-1" || r.Header.Get("Authorization") != "Bearer service-key" || r.Header.Get("OpenAI-Beta") != "agents=v1" || r.Header.Get("OpenAI-Organization") != "" { + t.Error("incorrect service address or identity") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"code":"unavailable","message":"try later","type":"server_error"}}`)) + })) + defer s.Close() + c, err := client.New(client.Config{BaseURL: s.URL + "/prefix/v1", APIKey: "service-key"}) + if err != nil { + t.Fatal(err) + } + _, err = c.Get(context.Background(), "session-1") + var apiErr *openai.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != 503 || apiErr.Code != "unavailable" || calls != 1 { + t.Fatalf("expected one request and the SDK error: %v", err) + } +} + +func TestRedirectsAndProductCookies(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/unexpected" || r.Header.Get("Cookie") != "" { + t.Error("followed redirect or sent product cookies") + } + w.Header().Set("Location", "/unexpected") + w.WriteHeader(http.StatusTemporaryRedirect) + })) + defer s.Close() + jar, _ := cookiejar.New(nil) + u, _ := url.Parse(s.URL) + jar.SetCookies(u, []*http.Cookie{{Name: "product-session", Value: "private"}}) + h := &http.Client{Jar: jar, Timeout: time.Second} + c, err := client.New(client.Config{BaseURL: s.URL + "/v1/", APIKey: "service-key", HTTPClient: h}) + if err != nil { + t.Fatal(err) + } + _, err = c.Get(context.Background(), "session-1") + if err == nil || !strings.Contains(err.Error(), "redirects are disabled") || h.Jar != jar || h.CheckRedirect != nil { + t.Fatal("redirect policy failed or modified the caller's client") + } +} + +func TestConfigurationAndContext(t *testing.T) { + for _, base := range []string{"relative", "ftp://host/v1", "http://user:secret@host/v1", "http://host/v1?key=secret"} { + if _, err := client.New(client.Config{BaseURL: base, APIKey: "key"}); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("invalid base URL accepted or exposed") + } + } + if _, err := client.New(client.Config{BaseURL: "http://localhost/v1"}); err == nil { + t.Fatal("accepted missing service key") + } + c, err := client.New(client.Config{BaseURL: "http://localhost/v1", APIKey: "key"}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Get(ctx, "session-1"); !errors.Is(err, context.Canceled) { + t.Fatalf("lost context cancellation: %v", err) + } +} diff --git a/packages/agents-client/v1/service_test.go b/packages/agents-client/v1/service_test.go new file mode 100644 index 000000000..cf817f716 --- /dev/null +++ b/packages/agents-client/v1/service_test.go @@ -0,0 +1,107 @@ +package v1_test + +import ( + "context" + "errors" + "os" + "slices" + "testing" + "time" + + client "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +// The official-client harness starts the actual service with a dedicated test +// database and fresh tenant keys, then supplies these explicit test variables. +func TestService(t *testing.T) { + base, key, otherKey := os.Getenv("AGENTS_API_CLIENT_TEST_BASE_URL"), os.Getenv("AGENTS_API_CLIENT_TEST_KEY"), os.Getenv("AGENTS_API_CLIENT_TEST_OTHER_KEY") + if base == "" && key == "" && otherKey == "" { + t.Skip("real service test is run by services/agents-api/tests/official_client.py") + } + if base == "" || key == "" || otherKey == "" { + t.Fatal("all real service test settings are required") + } + newClient := func(token string) openai.BetaAgentSessionService { + t.Helper() + c, err := client.New(client.Config{BaseURL: base, APIKey: token}) + if err != nil { + t.Fatal(err) + } + return c + } + a, b, invalid := newClient(key), newClient(otherKey), newClient("invalid-key") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + input := openai.BetaAgentSessionNewParams{ + Agent: openai.BetaAgentSessionNewParamsAgent{Model: openai.String("go-client-test-model"), Instructions: openai.String("Keep this configuration.")}, + Environment: openai.EnvironmentParamUnion{OfParamNone: &openai.EnvironmentParamNone{}}, + Metadata: map[string]string{"workspace": "not-an-identity"}, + } + retry := option.WithHeader("Idempotency-Key", "go-client-first") + first, err := a.New(ctx, input, retry) + if err != nil { + t.Fatal(err) + } + if first.ID == "" || first.Object != "agent.session" || first.Status != "idle" || first.Agent.Model != "go-client-test-model" || first.Agent.Instructions != "Keep this configuration." || first.Environment.Type != "none" { + t.Fatal("incorrect resolved Session") + } + read, err := a.Get(ctx, first.ID) + if err != nil || read.RawJSON() != first.RawJSON() { + t.Fatalf("retrieve: %v", err) + } + replay, err := a.New(ctx, input, retry) + if err != nil || replay.ID != first.ID { + t.Fatalf("retry: %v", err) + } + expectStatus := func(err error, status int) { + t.Helper() + var apiErr *openai.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != status || apiErr.Code == "" { + t.Fatalf("expected SDK HTTP %d error: %v", status, err) + } + } + changed := input + changed.Agent.Instructions = openai.String("Changed") + _, err = a.New(ctx, changed, retry) + expectStatus(err, 409) + for _, key := range []string{"go-client-second", "go-client-third"} { + if _, err := a.New(ctx, input, option.WithHeader("Idempotency-Key", key)); err != nil { + t.Fatal(err) + } + } + list := func(order openai.BetaAgentSessionListParamsOrder) []string { + t.Helper() + page, err := a.List(ctx, openai.BetaAgentSessionListParams{Limit: openai.Int(1), Order: order}) + var ids []string + for err == nil && page != nil { + for _, session := range page.Data { + ids = append(ids, session.ID) + } + if len(ids) > 3 { + t.Fatal("unexpected or repeating page") + } + page, err = page.GetNextPage() + } + if err != nil || len(ids) != 3 { + t.Fatalf("pagination: %v", err) + } + return ids + } + asc, desc := list(openai.BetaAgentSessionListParamsOrderAsc), list(openai.BetaAgentSessionListParamsOrderDesc) + slices.Reverse(desc) + if !slices.Equal(asc, desc) || !slices.Contains(asc, first.ID) { + t.Fatal("incorrect creation ordering") + } + other, err := b.New(ctx, input, retry) + if err != nil || other.ID == first.ID { + t.Fatalf("tenant-scoped creation: %v", err) + } + _, err = b.Get(ctx, first.ID) + expectStatus(err, 404) + _, err = b.List(ctx, openai.BetaAgentSessionListParams{After: openai.String(first.ID)}) + expectStatus(err, 404) + _, err = invalid.Get(ctx, first.ID) + expectStatus(err, 401) +} diff --git a/packages/claude-sdk-adapter/package.json b/packages/claude-sdk-adapter/package.json new file mode 100644 index 000000000..805df9c93 --- /dev/null +++ b/packages/claude-sdk-adapter/package.json @@ -0,0 +1,21 @@ +{ + "name": "@parsar/claude-sdk-adapter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc", + "test": "pnpm build && node --test tests/*.test.mjs" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.269", + "@modelcontextprotocol/sdk": "1.30.0" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "files": [ + "dist" + ] +} diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts new file mode 100644 index 000000000..59fd12a2b --- /dev/null +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -0,0 +1,165 @@ +import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_directories.js"; +import { getSessionInfo, query, startup, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; +import { WorkspaceReads, type WorkspaceReadEvent } from "./workspace_reads.js"; +import { Inputs, type InputEvent } from "./inputs.js"; +import { resultUsage, type NativeUsage } from "./usage.js"; +import { spawnNative } from "./native.js"; +import { MessageObserver, type MessageEvent } from "./messages.js"; +import { createFunctionServer } from "./functions.js"; +import { FunctionBridge, type FunctionEvent } from "./function_bridge.js"; +import { MCPProfile } from "./mcp.js"; +import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; +import { CommandObserver, type CommandEvent } from "./command_observer.js"; +import { WorkspaceProfile } from "./workspace.js"; +import { immediatePrompt, type Prepare, type Start } from "./request.js"; +import { recoverSession } from "./recovery.js"; +export { parseStart, type Start } from "./request.js"; + +export type Event = + | WorkspaceDirectoryEvent + | WorkspaceReadEvent + | MessageEvent + | InputEvent + | FunctionEvent + | MCPEvent + | CommandEvent + | { type: "prepared" } + | { type: "usage"; session_id: string; result_id: string; usage: NativeUsage } + | { type: "delta"; delta: string } + | { type: "result"; session_id: string; text: string } + | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled" }; + +export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediatePrompt(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { + const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); + const names = definitions.map(tool => `mcp__functions__${tool.name}`); + const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names); + const commands = workspace ? new CommandObserver() : undefined; + if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); + if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); + if (request.require_history && !request.resume) { + const recovered = await recoverSession(request.cwd); + if (!recovered) { + await emit({ type: "error", code: "history_unavailable" }); + return; + } + request = { ...request, resume: recovered }; + } + if (request.resume && !await getSessionInfo(request.resume, { dir: request.cwd })) { + await emit({ type: "error", code: "history_unavailable" }); + return; + } + const mcpServers: Record = Object.create(null); + if (definitions.length) mcpServers.functions = createFunctionServer(definitions, functions.invoke); + const profile = request.mcp_http_servers === undefined ? undefined : new MCPProfile(request.mcp_http_servers, names); + const mcp = profile ? new MCPObserver(profile.identities) : undefined; + if (profile) Object.assign(mcpServers, profile.servers); + const children: Promise[] = []; + let result: Extract | undefined; + let nativeID = ""; + const resultIDs = new Set(); + let failed = false; + const messages = request.observe_messages ? new MessageObserver() : undefined; + let stream: ReturnType | undefined; + let warm: WarmQuery | undefined; + let nativeAlive = false; + const closeInputs = () => inputs.close(); + abort.signal.addEventListener("abort", closeInputs, { once: true }); + try { + if (abort.signal.aborted) throw new Error("cancelled"); + const options: Options = { + cwd: request.cwd, + env: workspace?.options.env ?? { ...process.env }, + model: request.model, + systemPrompt: request.system_prompt, + ...(request.resume ? { resume: request.resume } : {}), + tools: [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], + ...(profile ? { + agent: "parsar_root", disallowedTools: profile.denied, + hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, + agents: { parsar_root: { description: "Execution root.", prompt: request.system_prompt, + model: request.model, tools: profile.allowed } }, + } : {}), + persistSession: true, includePartialMessages: true, abortController: abort, + canUseTool: async () => ({ behavior: "deny", message: "Tools are unavailable in this execution profile." }), + ...(workspace?.options ?? {}), + mcpServers, + spawnClaudeCodeProcess: options => { + const child = spawnNative(options); + nativeAlive = true; + child.once("exit", () => { nativeAlive = false; }); + children.push(new Promise(resolve => child.once("close", code => { nativeAlive = false; resolve(code); }))); + return child; + }, + }; + if (request.type === "prepare") { + warm = await startup({ options, initializeTimeoutMs: 15000 }); + stream = warm.query(inputs); + const initialized = await stream.initializationResult(); + if (initialized.hooks_applied !== true || children.length !== 1 || !nativeAlive || abort.signal.aborted) { + throw new Error("preparation unavailable"); + } + reads.bind(stream, request.cwd); + if (process.platform === "linux") await directories.bind(request.cwd); + await emit({ type: "prepared" }); + } else if (profile) { + if (inputs.hasInput) throw new Error("MCP input released before initialization"); + warm = await startup({ options, initializeTimeoutMs: 15000 }); + stream = warm.query(inputs); + const initialized = await stream.initializationResult(); + if (initialized.hooks_applied !== true || children.length !== 1) throw new Error("MCP initialization unavailable"); + if (request.mcp_http_servers?.some(server => server.required)) profile.verifyRequired(await stream.mcpServerStatus()); + if (abort.signal.aborted || !nativeAlive) throw new Error("MCP initialization interrupted"); + inputs.release(request.prompt); + } else stream = query({ prompt: inputs, options }); + for await (const message of stream) { + await functions.consume(message, nativeID); + if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); + if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); + if (messages) for (const event of messages.consume(message)) await emit(event); + if (message.type === "system" && message.subtype === "init") { + nativeID = message.session_id; + if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); + if (workspace) workspace.verify(message.tools, message.mcp_servers); + else if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID); + else if (message.tools.length !== names.length || message.tools.some(name => !names.includes(name)) || + message.mcp_servers.length !== (definitions.length ? 1 : 0) || + message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected")) { + throw new Error("unexpected native configuration"); + } + for (const event of inputs.start(nativeID)) await emit(event); + } else if (!messages && message.type === "stream_event" && message.parent_tool_use_id === null && + message.event.type === "content_block_delta" && message.event.delta.type === "text_delta") { + await emit({ type: "delta", delta: message.event.delta.text }); + } else if (message.type === "result") { + if (!message.uuid || resultIDs.has(message.uuid) || !nativeID || message.session_id !== nativeID) throw new Error("invalid native result identity"); + resultIDs.add(message.uuid); + await emit({ type: "usage", session_id: nativeID, result_id: message.uuid, usage: resultUsage(message) }); + for (const event of inputs.consume(message)) await emit(event); + if (message.subtype !== "success" || message.is_error) throw new Error("unsuccessful native result"); + result = { type: "result", session_id: nativeID, text: message.result }; + } + if (message.type !== "result") for (const event of inputs.consume(message)) await emit(event); + } + functions.assertComplete(); + mcp?.assertComplete(); + commands?.assertComplete(); + } catch { + failed = true; + } finally { + profile?.close(); + inputs.close(); + functions.close(); + try { await directories.close(); } catch { failed = true; } + await reads.close(); + stream?.close(); + warm?.close(); + abort.signal.removeEventListener("abort", closeInputs); + const exits = await Promise.all(children); + if (!exits.length || exits.some(code => code !== 0)) failed = true; + if (mcp) for (const event of mcp.close()) await emit(event); + if (commands) for (const event of commands.close()) await emit(event); + } + if (abort.signal.aborted) await emit({ type: "error", code: "cancelled" }); + else if (failed || !result || !inputs.complete) await emit({ type: "error", code: "execution_failed" }); + else await emit(result); +} diff --git a/packages/claude-sdk-adapter/src/command_observer.ts b/packages/claude-sdk-adapter/src/command_observer.ts new file mode 100644 index 000000000..2bd911456 --- /dev/null +++ b/packages/claude-sdk-adapter/src/command_observer.ts @@ -0,0 +1,111 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { isDeepStrictEqual } from "node:util"; + +type Observation = { + kind: "command"; + status: "in_progress" | "completed" | "failed" | "incomplete"; + command: string; + output?: string; +}; +type Call = { observation: Observation; input: unknown; result?: unknown }; +export type CommandEvent = { + type: "command_observation"; session_id: string; id: string; + stage: "before" | "after"; observation: Observation; +}; + +export class CommandObserver { + private readonly calls = new Map(); + private readonly otherCalls = new Set(); + private sessionID = ""; + + *consume(message: SDKMessage, sessionID: string, hasInput: boolean): Generator { + if ((message.type !== "assistant" && message.type !== "user") || message.parent_tool_use_id !== null || + ("isSynthetic" in message && message.isSynthetic) || ("isReplay" in message && message.isReplay)) return; + if (!hasInput || !sessionID || message.session_id !== sessionID || this.sessionID && this.sessionID !== sessionID) { + throw new Error("invalid command session identity"); + } + this.sessionID = sessionID; + const content = message.message.content; + if (!Array.isArray(content)) return; + if (message.type === "assistant") { + if (message.error) return; + for (const block of message.message.content) { + if (block.type !== "tool_use") continue; + if (block.name !== "Bash") { + if (this.calls.has(block.id)) throw new Error("conflicting command call identity"); + this.otherCalls.add(block.id); + continue; + } + const input = block.input; + if (typeof block.id !== "string" || !block.id || !input || typeof input !== "object" || Array.isArray(input) || + !("command" in input) || typeof input.command !== "string" || !input.command.trim()) { + throw new Error("invalid native command call"); + } + if (this.otherCalls.has(block.id)) throw new Error("conflicting command call identity"); + const previous = this.calls.get(block.id); + if (previous) { + if (!isDeepStrictEqual(previous.input, input)) throw new Error("conflicting command call identity"); + continue; + } + const observation: Observation = { kind: "command", status: "in_progress", command: input.command }; + this.calls.set(block.id, { observation, input }); + yield this.event(block.id, "before", observation); + } + } else { + const results = content.filter(block => block.type === "tool_result"); + for (const block of results) { + const call = this.calls.get(block.tool_use_id); + if (!call) continue; + if (block.is_error !== undefined && typeof block.is_error !== "boolean") throw new Error("invalid command result status"); + const native = results.length === 1 ? message.tool_use_result : undefined; + const result = { content: block.content, is_error: !!block.is_error, native }; + if (call.result !== undefined) { + if (!isDeepStrictEqual(call.result, result)) throw new Error("conflicting command result"); + continue; + } + if (call.observation.status !== "in_progress") throw new Error("command result followed closure"); + let interrupted = false; + if (native && typeof native === "object" && !Array.isArray(native)) { + if ("backgroundTaskId" in native || "timedOutAfterMs" in native || + ("backgroundedByUser" in native && native.backgroundedByUser === true)) { + throw new Error("unexpected background command"); + } + if ("interrupted" in native) { + if (typeof native.interrupted !== "boolean" || !("stdout" in native) || typeof native.stdout !== "string" || + !("stderr" in native) || typeof native.stderr !== "string") throw new Error("invalid command interruption evidence"); + interrupted = native.interrupted; + } + } + const observation: Observation = { ...call.observation, + status: interrupted ? "incomplete" : block.is_error ? "failed" : "completed" }; + // Preserve native per-call text; separate streams do not establish interleaving. + if (typeof block.content === "string") observation.output = block.content; + else if (Array.isArray(block.content) && block.content.length === 1 && block.content[0]?.type === "text" && + typeof block.content[0].text === "string") { + observation.output = block.content[0].text; + } + call.observation = observation; + call.result = result; + yield this.event(block.tool_use_id, "after", observation); + } + } + } + + assertComplete(): void { + if ([...this.calls.values()].some(call => call.observation.status === "in_progress")) throw new Error("unconfirmed command result"); + } + + close(): CommandEvent[] { + const events: CommandEvent[] = []; + for (const [id, call] of this.calls) { + if (call.observation.status !== "in_progress") continue; + call.observation = { ...call.observation, status: "incomplete" }; + events.push(this.event(id, "after", call.observation)); + } + return events; + } + + private event(id: string, stage: "before" | "after", observation: Observation): CommandEvent { + return { type: "command_observation", session_id: this.sessionID, id, stage, observation: { ...observation } }; + } +} diff --git a/packages/claude-sdk-adapter/src/function_bridge.ts b/packages/claude-sdk-adapter/src/function_bridge.ts new file mode 100644 index 000000000..8e43a3a38 --- /dev/null +++ b/packages/claude-sdk-adapter/src/function_bridge.ts @@ -0,0 +1,97 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { isDeepStrictEqual } from "node:util"; +import type { FunctionCall, FunctionHandler } from "./functions.js"; + +export type FunctionResult = { + type: "function_result"; + call_id: string; + delivery_id: string; + success: boolean; + content: { type: "input_text"; text: string }[]; +}; +export type FunctionEvent = + | { type: "function_call"; call: { call_id: string; name: string; arguments: Record } } + | { type: "function_applied"; call_id: string; delivery_id: string }; +type Pending = { + resolve: (result: CallToolResult) => void; + reject: (error: Error) => void; + cleanup: () => void; + result?: FunctionResult; +}; + +export class FunctionBridge { + private readonly pending = new Map(); + private readonly seen = new Set(); + constructor(private readonly emit: (event: FunctionEvent) => Promise) {} + + readonly invoke: FunctionHandler = async (call: FunctionCall, signal: AbortSignal) => { + signal.throwIfAborted(); + if (this.seen.has(call.id)) throw new Error("Repeated native call identity."); + this.seen.add(call.id); + const waiting = new Promise((resolve, reject) => { + const stop = () => { + this.pending.delete(call.id); + reject(new Error("Native function call aborted.")); + }; + signal.addEventListener("abort", stop, { once: true }); + this.pending.set(call.id, { resolve, reject, cleanup: () => signal.removeEventListener("abort", stop) }); + }); + const [, result] = await Promise.all([ + this.emit({ type: "function_call", call: { call_id: call.id, name: call.name, arguments: call.arguments } }), + waiting, + ]); + return result; + }; + + submit(line: string): void { + if (Buffer.byteLength(line) > 1024 * 1024) throw new Error("Invalid bridge input."); + const value: unknown = JSON.parse(line); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid function result."); + const result = value as FunctionResult; + if (Object.keys(result).some(key => !["type", "call_id", "delivery_id", "success", "content"].includes(key)) || + result.type !== "function_result" || typeof result.call_id !== "string" || !result.call_id || + typeof result.delivery_id !== "string" || !result.delivery_id || typeof result.success !== "boolean" || + !Array.isArray(result.content) || result.content.some(part => !part || part.type !== "input_text" || + typeof part.text !== "string" || Object.keys(part).some(key => key !== "type" && key !== "text"))) { + throw new Error("Invalid function result."); + } + const pending = this.pending.get(result.call_id); + if (!pending || pending.result) throw new Error("Function result is not pending."); + pending.result = result; + pending.resolve({ content: result.content.map(part => ({ type: "text", text: part.text })), isError: !result.success }); + } + + async consume(message: SDKMessage, sessionID: string): Promise { + if (message.type !== "user" || message.parent_tool_use_id !== null || message.session_id !== sessionID || + message.isSynthetic || ("isReplay" in message && message.isReplay) || !Array.isArray(message.message.content)) return; + for (const block of message.message.content) { + if (block.type !== "tool_result") continue; + const pending = this.pending.get(block.tool_use_id); + if (!pending) continue; + const result = pending.result; + if (!result) throw new Error("Native response preceded host result."); + // Native error results join MCP text blocks; retain the original parts in the host. + const expected = result.success ? result.content.map(part => ({ type: "text", text: part.text })) : + result.content.map(part => part.text).join("\n"); + if (!!block.is_error !== !result.success || !isDeepStrictEqual(block.content, expected)) { + throw new Error("Native function response differs from submitted result."); + } + await this.emit({ type: "function_applied", call_id: result.call_id, delivery_id: result.delivery_id }); + pending.cleanup(); + this.pending.delete(result.call_id); + } + } + + assertComplete(): void { + if (this.pending.size) throw new Error("Native function results are unconfirmed."); + } + + close(): void { + for (const pending of this.pending.values()) { + pending.cleanup(); + pending.reject(new Error("Execution ended before the function completed.")); + } + this.pending.clear(); + } +} diff --git a/packages/claude-sdk-adapter/src/functions.ts b/packages/claude-sdk-adapter/src/functions.ts new file mode 100644 index 000000000..86c14ffae --- /dev/null +++ b/packages/claude-sdk-adapter/src/functions.ts @@ -0,0 +1,30 @@ +import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, + type Tool, type CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; + +export type FunctionCall = { id: string; name: string; arguments: Record }; +export type FunctionHandler = (call: FunctionCall, signal: AbortSignal) => Promise; + +export function createFunctionServer(definitions: Tool[], invoke: FunctionHandler): McpSdkServerConfigWithInstance { + const tools = structuredClone(definitions); + const names = new Set(); + for (const tool of tools) { + if (!tool.name || names.has(tool.name)) throw new Error("Function names must be nonempty and unique."); + names.add(tool.name); + } + const instance = new McpServer({ name: "functions", version: "1.0.0" }, { capabilities: { tools: {} } }); + instance.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map(tool => ({ ...tool, _meta: { ...tool._meta, "anthropic/alwaysLoad": true } })), + })); + instance.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + if (!names.has(request.params.name)) throw new McpError(ErrorCode.InvalidParams, "Unknown function."); + // The pinned native harness supplies this identity independently of the MCP request ID. + const id = request.params._meta?.["claudecode/toolUseId"]; + if (typeof id !== "string" || !id) throw new McpError(ErrorCode.InvalidParams, "Native function call identity is missing."); + return invoke({ id, name: request.params.name, arguments: request.params.arguments ?? {} }, extra.signal); + }); + return { type: "sdk", name: "functions", instance }; +} diff --git a/packages/claude-sdk-adapter/src/inputs.ts b/packages/claude-sdk-adapter/src/inputs.ts new file mode 100644 index 000000000..2e7853d60 --- /dev/null +++ b/packages/claude-sdk-adapter/src/inputs.ts @@ -0,0 +1,91 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { randomUUID } from "node:crypto"; + +export type InputEvent = + | { type: "input_ready" | "input_closed"; session_id: string } + | { type: "input_applied" | "input_rejected"; input_id: string }; +type Input = { id?: string; applied: boolean; completed: boolean }; + +// This is an SDK input iterator and receipt ledger, never a model/tool loop. +export class Inputs implements AsyncIterable { + private readonly submitted = new Map(); + private readonly ids = new Set(); + private readonly queue: SDKUserMessage[] = []; + private wake?: () => void; + private ended = false; + private sessionID = ""; + + constructor(prompt?: string) { if (prompt !== undefined) this.release(prompt); } + + release(prompt: string): void { + if (this.ended || this.submitted.size || !prompt.trim()) throw new Error("Invalid initial input."); + this.enqueue(prompt); + } + + start(sessionID: string): InputEvent[] { + if (this.ended || !sessionID || this.sessionID && this.sessionID !== sessionID) throw new Error("Invalid input session identity."); + // Native streaming queries initialize each native turn within the same Session. + if (this.sessionID) return []; + this.sessionID = sessionID; + return [{ type: "input_ready", session_id: sessionID }]; + } + + submit(value: unknown): InputEvent[] { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid input."); + const input = value as Record; + if (input.type !== "steer" || Object.keys(input).some(key => !["type", "input_id", "text"].includes(key)) || + typeof input.input_id !== "string" || !input.input_id.trim() || input.input_id.length > 256 || + typeof input.text !== "string" || !input.text.trim()) throw new Error("Invalid input."); + // The native consumed-UUID list has 64 slots, including the opening prompt. + if (this.ended || !this.sessionID || this.submitted.size >= 64 || this.ids.has(input.input_id)) { + return [{ type: "input_rejected", input_id: input.input_id }]; + } + this.ids.add(input.input_id); + this.enqueue(input.text, input.input_id); + return []; + } + + private enqueue(text: string, id?: string): void { + const uuid = randomUUID(); + this.submitted.set(uuid, { id, applied: false, completed: false }); + this.queue.push({ type: "user", uuid, session_id: this.sessionID, parent_tool_use_id: null, + message: { role: "user", content: text } }); + this.wake?.(); + } + + consume(message: SDKMessage): InputEvent[] { + if (message.type !== "assistant" && message.type !== "stream_event" && message.type !== "result") return []; + if (("parent_tool_use_id" in message && message.parent_tool_use_id !== null) || + ("isReplay" in message && message.isReplay) || ("isSynthetic" in message && message.isSynthetic)) return []; + if (message.session_id !== this.sessionID || !this.sessionID) throw new Error("Invalid consumed-input session."); + const uuids = message.user_message_uuids ?? (message.user_message_uuid ? [message.user_message_uuid] : []); + const consumed = uuids.flatMap(uuid => this.submitted.has(uuid) ? [this.submitted.get(uuid)!] : []); + if (message.type === "result" && !consumed.length) throw new Error("Unattributed native result."); + const receipts: InputEvent[] = []; + for (const input of consumed) { + if (!input.applied && input.id) receipts.push({ type: "input_applied", input_id: input.id }); + input.applied = true; + if (message.type === "result") input.completed = true; + } + if (message.type === "result" && this.complete) { + this.close(); + // Close admission before releasing receipt waiters at the final result. + receipts.unshift({ type: "input_closed", session_id: this.sessionID }); + } + return receipts; + } + + get complete(): boolean { return [...this.submitted.values()].every(input => input.completed); } + + get hasInput(): boolean { return this.submitted.size > 0; } + + close(): void { this.ended = true; this.wake?.(); } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length) yield this.queue.shift()!; + else if (this.ended) return; + else await new Promise(resolve => { this.wake = resolve; }); + } + } +} diff --git a/packages/claude-sdk-adapter/src/main.ts b/packages/claude-sdk-adapter/src/main.ts new file mode 100644 index 000000000..21b0e69f3 --- /dev/null +++ b/packages/claude-sdk-adapter/src/main.ts @@ -0,0 +1,71 @@ +import { WorkspaceDirectories } from "./workspace_directories.js"; +import { WorkspaceReads } from "./workspace_reads.js"; +import { Inputs } from "./inputs.js"; +import { FunctionBridge } from "./function_bridge.js"; +import { createInterface } from "node:readline"; +import { execute, type Event } from "./adapter.js"; +import { immediatePrompt, parseRequest, preparedPrompt } from "./request.js"; + +const abort = new AbortController(); +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +const stop = () => abort.abort(); +process.once("SIGTERM", stop); +process.once("SIGINT", stop); +lines.once("close", stop); +const emit = (event: Event): Promise => new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(event) + "\n", error => error ? reject(error) : resolve()); +}); +try { + const input = lines[Symbol.asyncIterator](); + const first = await input.next(); + if (first.done || Buffer.byteLength(first.value) > 1024 * 1024) throw new Error("invalid_request"); + const request = parseRequest(first.value); + let phase = request.type === "prepare" ? "preparing" : "running"; + let invalid = false; + const output = async (event: Event) => { + if (event.type === "prepared") phase = "prepared"; + await emit(invalid && (event.type === "error" || event.type === "result") ? { type: "error", code: "invalid_request" } : event); + }; + const functions = new FunctionBridge(output); + const reads = new WorkspaceReads(output, abort); + const directories = new WorkspaceDirectories(output, abort); + const prompts = new Inputs(immediatePrompt(request)); + const incoming = (async () => { + try { + for await (const line of { [Symbol.asyncIterator]: () => input }) { + if (Buffer.byteLength(line) > 1024 * 1024) throw new Error("Invalid input."); + const value: unknown = JSON.parse(line); + if (value && typeof value === "object" && "type" in value && value.type === "workspace_read") { + reads.submit(value as Record); + } else if (value && typeof value === "object" && "type" in value && value.type === "workspace_directory") { + directories.submit(value as Record); + } else if (request.type === "prepare" && phase !== "running") { + if (phase !== "prepared" || abort.signal.aborted) throw new Error("invalid_request"); + prompts.release(preparedPrompt(value)); + phase = "running"; + } else if (value && typeof value === "object" && "type" in value && value.type === "steer") { + for (const event of prompts.submit(value)) await output(event); + } else functions.submit(line); + } + } + catch { invalid = request.type === "prepare"; abort.abort(); } + })(); + try { await execute(request, output, abort, functions, prompts, reads, directories); } + catch { await output({ type: "error", code: "execution_failed" }); } + finally { + prompts.close(); + functions.close(); + lines.removeListener("close", stop); + lines.close(); + process.stdin.destroy(); + await incoming; + } +} catch { + await emit({ type: "error", code: "invalid_request" }); +} finally { + lines.removeListener("close", stop); + lines.close(); + process.stdin.destroy(); + process.removeListener("SIGTERM", stop); + process.removeListener("SIGINT", stop); +} diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts new file mode 100644 index 000000000..b7ddc3e12 --- /dev/null +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -0,0 +1,139 @@ +import type { HookCallback, McpServerConfig, McpServerStatus } from "@anthropic-ai/claude-agent-sdk"; + +export type HTTPServer = { + server_label: string; + server_url: string; + allowed_tools: string[] | null; + required?: boolean; + bearer_token_env_var?: string; +}; +export type ToolIdentity = { server: string; name: string }; + +function nativeToolName(server: string, tool: string): string { + let name = tool.replace(/[^a-zA-Z0-9_-]/g, "_"); + if (tool.startsWith("claude.ai ")) name = name.replace(/_+/g, "_").replace(/^_|_$/g, ""); + return `mcp__${server}__${name}`; +} + +export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error("invalid_request"); + const labels = new Set(); + const references = new Set(); + for (const server of value) { + if (!server || typeof server !== "object" || + Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var", "required"].includes(key)) || + (server.required !== undefined && typeof server.required !== "boolean") || + typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || + server.server_label === "functions" || labels.has(server.server_label) || + typeof server.server_url !== "string" || + (server.allowed_tools !== null && (!Array.isArray(server.allowed_tools) || + server.allowed_tools.some((name: unknown) => typeof name !== "string" || !/^[a-zA-Z0-9_.-]+$/.test(name))))) { + throw new Error("invalid_request"); + } + const url = new URL(server.server_url); + if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || + server.server_url.includes("?") || server.server_url.includes("#")) throw new Error("invalid_request"); + if (server.bearer_token_env_var !== undefined) { + const reference = server.bearer_token_env_var; + if (url.protocol !== "https:" || typeof reference !== "string" || + !/^PARSAR_MCP_BEARER_[A-Z2-7]{26,}$/.test(reference) || references.has(reference)) throw new Error("invalid_request"); + references.add(reference); + } + labels.add(server.server_label); + } + return value; +} + +export class MCPProfile { + readonly servers: Record = Object.create(null); + readonly allowed: string[]; + readonly denied: string[] = []; + readonly identities = new Map(); + private sessionID = ""; + private admitted = false; + private release!: (ready: boolean) => void; + private readonly ready = new Promise(resolve => { this.release = resolve; }); + + readonly beforeTool: HookCallback = async (input, id, { signal }) => { + let stopped!: () => void; + const interrupted = new Promise(resolve => { + stopped = () => resolve(false); + signal.addEventListener("abort", stopped, { once: true }); + }); + try { + if (!signal.aborted && await Promise.race([this.ready, interrupted]) && !signal.aborted && this.admitted && + input.hook_event_name === "PreToolUse" && input.agent_id === undefined && input.session_id === this.sessionID && + (id === undefined || id === input.tool_use_id) && + (this.identities.has(input.tool_name) || this.functions.includes(input.tool_name))) return {}; + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", + permissionDecisionReason: "Tool is outside the verified execution profile." } }; + } finally { + signal.removeEventListener("abort", stopped); + } + }; + + constructor(private readonly declarations: HTTPServer[], private readonly functions: string[]) { + this.allowed = [...functions]; + for (const server of declarations) { + const prefix = `mcp__${server.server_label}__`; + const reference = server.bearer_token_env_var; + if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); + // An explicit empty Authorization suppresses native OAuth and automatic auth. + // Keep bearer references literal: SDK server configuration enters native argv. + this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, + headers: { Authorization: reference ? `Bearer \${${reference}}` : "" } }; + if (server.allowed_tools === null) this.allowed.push(prefix + "*"); + else if (!server.allowed_tools.length) this.denied.push(prefix + "*"); + else this.allowed.push(...server.allowed_tools.map(name => nativeToolName(server.server_label, name))); + } + } + + verify(inventory: string[], statuses: McpServerStatus[], sessionID: string): void { + // Native status.config can contain expanded headers. Retain only identities; + // never publish or persist the private SDK control response. + this.admitted = false; + const expected = new Map(); + const declared = new Map(this.declarations.map(server => [server.server_label, server])); + const seen = new Set(); + const nativeNames = new Set(this.functions); + for (const status of statuses) { + if (seen.has(status.name)) throw new Error("duplicate native MCP server"); + seen.add(status.name); + if (status.name === "functions" && this.functions.length) { + if (status.status !== "connected") throw new Error("native function server unavailable"); + continue; + } + const server = declared.get(status.name); + if (!server || status.status !== "connected") throw new Error("native MCP server unavailable or undeclared"); + for (const tool of status.tools ?? []) { + const native = nativeToolName(server.server_label, tool.name); + if (nativeNames.has(native)) throw new Error("ambiguous native MCP identity"); + nativeNames.add(native); + if (server.allowed_tools !== null && !server.allowed_tools.includes(tool.name)) continue; + expected.set(native, { server: server.server_label, name: tool.name }); + } + } + if (seen.size !== declared.size + (this.functions.length ? 1 : 0) || + inventory.length !== expected.size + this.functions.length || new Set(inventory).size !== inventory.length || + inventory.some(name => !expected.has(name) && !this.functions.includes(name))) { + throw new Error("unexpected native MCP inventory"); + } + this.identities.clear(); + for (const [name, identity] of expected) this.identities.set(name, identity); + this.sessionID = sessionID; + this.admitted = true; + this.release(true); + } + + verifyRequired(statuses: McpServerStatus[]): void { + for (const server of this.declarations.filter(server => server.required)) { + const matches = statuses.filter(status => status.name === server.server_label); + if (matches.length !== 1 || matches[0].status !== "connected") { + throw new Error("required native MCP server unavailable"); + } + } + } + + close(): void { this.admitted = false; this.release(false); } +} diff --git a/packages/claude-sdk-adapter/src/mcp_observer.ts b/packages/claude-sdk-adapter/src/mcp_observer.ts new file mode 100644 index 000000000..6aaccaefb --- /dev/null +++ b/packages/claude-sdk-adapter/src/mcp_observer.ts @@ -0,0 +1,73 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { isDeepStrictEqual } from "node:util"; +import type { ToolIdentity } from "./mcp.js"; + +type Observation = ToolIdentity & { + kind: "mcp"; + status: "in_progress" | "completed" | "failed" | "incomplete"; + arguments: unknown; + output: unknown; + error: unknown; +}; +export type MCPEvent = { type: "mcp_observation"; id: string; stage: "before" | "after"; observation: Observation }; + +export class MCPObserver { + private readonly calls = new Map(); + constructor(private readonly tools: Map) {} + + consume(message: SDKMessage, sessionID: string): MCPEvent[] { + if ((message.type !== "assistant" && message.type !== "user") || message.parent_tool_use_id !== null || + ("isSynthetic" in message && message.isSynthetic) || ("isReplay" in message && message.isReplay)) return []; + if (message.session_id !== sessionID || !sessionID) throw new Error("invalid MCP session identity"); + const content = message.message.content; + if (!Array.isArray(content)) return []; + const events: MCPEvent[] = []; + if (message.type === "assistant") { + if (message.error) return []; + for (const block of message.message.content) { + if (block.type !== "tool_use") continue; + const identity = this.tools.get(block.name); + if (!identity) continue; + if (!block.id) throw new Error("missing MCP call identity"); + const previous = this.calls.get(block.id); + if (previous) { + if (previous.name !== identity.name || previous.server !== identity.server || + !isDeepStrictEqual(previous.arguments, block.input)) throw new Error("conflicting MCP call identity"); + continue; + } + const observation: Observation = { ...identity, kind: "mcp", status: "in_progress", arguments: block.input, output: null, error: null }; + this.calls.set(block.id, observation); + events.push({ type: "mcp_observation", id: block.id, stage: "before", observation: { ...observation } }); + } + } else { + const results = content.filter(block => block.type === "tool_result"); + for (const block of results) { + const observation = this.calls.get(block.tool_use_id); + if (!observation) continue; + if (observation.status !== "in_progress") throw new Error("repeated MCP result"); + // This is native tool output, not a reconstructed original MCP envelope. + const value = results.length === 1 && "tool_use_result" in message && message.tool_use_result !== undefined ? + message.tool_use_result : block.content ?? null; + observation.status = block.is_error ? "failed" : "completed"; + if (block.is_error) observation.error = value; + else observation.output = value; + events.push({ type: "mcp_observation", id: block.tool_use_id, stage: "after", observation: { ...observation } }); + } + } + return events; + } + + assertComplete(): void { + if ([...this.calls.values()].some(call => call.status === "in_progress")) throw new Error("unconfirmed MCP result"); + } + + close(): MCPEvent[] { + const events: MCPEvent[] = []; + for (const [id, observation] of this.calls) { + if (observation.status !== "in_progress") continue; + observation.status = "incomplete"; + events.push({ type: "mcp_observation", id, stage: "after", observation: { ...observation } }); + } + return events; + } +} diff --git a/packages/claude-sdk-adapter/src/messages.ts b/packages/claude-sdk-adapter/src/messages.ts new file mode 100644 index 000000000..246b50875 --- /dev/null +++ b/packages/claude-sdk-adapter/src/messages.ts @@ -0,0 +1,71 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +export type MessageEvent = + | { type: "delta"; delta: string; item_id: string } + | { type: "output_message"; message: { id: string; status: "in_progress" | "completed"; text?: string } }; + +type ActiveMessage = { + id: string; + blocks: Map; + block?: number; + observed: boolean; + failed: boolean; +}; + +export class MessageObserver { + private active?: ActiveMessage; + + consume(message: SDKMessage): MessageEvent[] { + if ((message.type !== "stream_event" && message.type !== "assistant") || + message.parent_tool_use_id !== null) return []; + if (message.type === "assistant") { + if (message.error) { + if (this.active) this.active.failed = true; + return []; + } + const text = message.message.content.filter(block => block.type === "text"); + if (!text.length) return []; + const active = this.active; + if (!active || message.message.id !== active.id || active.block === undefined || + !active.blocks.has(active.block)) throw new Error("unmatched native text snapshot"); + // The SDK delivers one completed content block, before content_block_stop. + active.blocks.set(active.block, text.map(block => block.text).join("")); + return []; + } + const event = message.event; + if (event.type === "message_start") { + if (!event.message.id) throw new Error("missing native message identity"); + this.active = { id: event.message.id, blocks: new Map(), observed: false, failed: false }; + return []; + } + const active = this.active; + if (!active) throw new Error("native message start is missing"); + if (event.type === "content_block_start") { + active.block = event.index; + if (event.content_block.type !== "text") return []; + active.blocks.set(event.index, event.content_block.text); + const events: MessageEvent[] = []; + if (!active.observed) { + active.observed = true; + events.push({ type: "output_message", message: { id: active.id, status: "in_progress" } }); + } + if (event.content_block.text) events.push({ type: "delta", item_id: active.id, delta: event.content_block.text }); + return events; + } + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + const previous = active.blocks.get(event.index); + if (previous === undefined || active.block !== event.index) throw new Error("native text block start is missing"); + active.blocks.set(event.index, previous + event.delta.text); + return event.delta.text ? [{ type: "delta", item_id: active.id, delta: event.delta.text }] : []; + } + if (event.type === "content_block_stop") active.block = undefined; + if (event.type === "message_stop") { + this.active = undefined; + if (active.observed && !active.failed) { + const text = [...active.blocks].sort(([a], [b]) => a - b).map(([, text]) => text).join(""); + return [{ type: "output_message", message: { id: active.id, status: "completed", text } }]; + } + } + return []; + } +} diff --git a/packages/claude-sdk-adapter/src/native.ts b/packages/claude-sdk-adapter/src/native.ts new file mode 100644 index 000000000..db52b3f8b --- /dev/null +++ b/packages/claude-sdk-adapter/src/native.ts @@ -0,0 +1,13 @@ +import type { SpawnOptions } from "@anthropic-ai/claude-agent-sdk"; +import { spawn } from "node:child_process"; + +export function spawnNative(options: SpawnOptions) { + const child = spawn(options.command, options.args, { + cwd: options.cwd, env: options.env, signal: options.signal, + stdio: ["pipe", "pipe", "pipe"], + }); + // Custom spawners bypass the SDK stderr reader. Drain without exporting diagnostics. + child.stderr.resume(); + child.on("error", () => {}); + return child; +} diff --git a/packages/claude-sdk-adapter/src/recovery.ts b/packages/claude-sdk-adapter/src/recovery.ts new file mode 100644 index 000000000..c1d547ee4 --- /dev/null +++ b/packages/claude-sdk-adapter/src/recovery.ts @@ -0,0 +1,11 @@ + +// The deployment gives this API Session its own native state directory. +export async function recoverSession(cwd: string): Promise { + const { getSessionInfo, getSessionMessages, listSessions } = await import("@anthropic-ai/claude-agent-sdk"); + const sessions = await listSessions({ dir: cwd, includeWorktrees: false, limit: 2 }); + if (sessions.length !== 1 || sessions[0].cwd !== cwd) return undefined; + const id = sessions[0].sessionId; + const info = await getSessionInfo(id, { dir: cwd }); + if (!info || info.cwd !== cwd || (await getSessionMessages(id, { dir: cwd, limit: 1 })).length === 0) return undefined; + return id; +} diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts new file mode 100644 index 000000000..655c96e11 --- /dev/null +++ b/packages/claude-sdk-adapter/src/request.ts @@ -0,0 +1,63 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { isAbsolute } from "node:path"; +import { parseHTTPServers, type HTTPServer } from "./mcp.js"; +import { parseWorkspace, type Workspace } from "./workspace.js"; + +export type Start = { + type: "start"; + prompt: string; + model: string; + system_prompt: string; + cwd: string; + resume?: string; + require_history?: boolean; + observe_messages?: boolean; + functions?: { name: string; description: string; parameters: Tool["inputSchema"] }[]; + mcp_http_servers?: HTTPServer[]; + workspace?: Workspace; +}; +export type Prepare = Omit & { type: "prepare"; workspace: Workspace }; + +// MCP startup confirms its hooks before the native input iterator yields. +export function immediatePrompt(request: Start | Prepare): string | undefined { + return request.type === "start" && request.mcp_http_servers === undefined ? request.prompt : undefined; +} + +export function parseRequest(line: string): Start | Prepare { + const value: unknown = JSON.parse(line); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); + const request = value as Record; + const allowed = new Set(["type", "prompt", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "functions", "mcp_http_servers", "workspace"]); + if (Object.keys(request).some(key => !allowed.has(key)) || + (request.type !== "start" && request.type !== "prepare") || + (request.type === "start" ? typeof request.prompt !== "string" || !request.prompt.trim() : "prompt" in request) || + typeof request.model !== "string" || !request.model.trim() || + typeof request.system_prompt !== "string" || + typeof request.cwd !== "string" || !isAbsolute(request.cwd) || + (request.observe_messages !== undefined && typeof request.observe_messages !== "boolean") || + (request.require_history !== undefined && typeof request.require_history !== "boolean") || + (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); + if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => + !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || + !tool.parameters || tool.parameters.type !== "object"))) throw new Error("invalid_request"); + parseHTTPServers(request.mcp_http_servers); + const workspace = parseWorkspace(request.workspace, request.cwd); + if (request.require_history && !workspace) throw new Error("invalid_request"); + if ((workspace && "mcp_http_servers" in request) || + (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); + return request as Start | Prepare; +} + +export function parseStart(line: string): Start { + const request = parseRequest(line); + if (request.type !== "start") throw new Error("invalid_request"); + return request; +} + +export function preparedPrompt(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); + const request = value as Record; + if (request.type !== "start" || Object.keys(request).some(key => key !== "type" && key !== "prompt") || + typeof request.prompt !== "string" || !request.prompt.trim()) throw new Error("invalid_request"); + return request.prompt; +} diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts new file mode 100644 index 000000000..8b819e545 --- /dev/null +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { constants } from "node:fs"; +import { access, readFile, realpath } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +// This companion checks the exact bridge used by execution, without a model query. +try { + assert(Number(process.versions.node.split(".")[0]) >= 20, "unsupported_node"); + const root = await realpath(fileURLToPath(new URL("..", import.meta.url))); + const inside = async (path: string) => { + const resolved = await realpath(path); + const rel = relative(root, resolved); + assert(rel !== ".." && !rel.startsWith(".." + sep), "external_dependency"); + return resolved; + }; + const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8")); + const require = createRequire(join(root, "package.json")); + const sdkDir = dirname(await inside(require.resolve("@anthropic-ai/claude-agent-sdk"))); + const sdk = JSON.parse(await readFile(join(sdkDir, "package.json"), "utf8")); + const mcp = JSON.parse(await readFile(await inside(join(root, "node_modules/@modelcontextprotocol/sdk/package.json")), "utf8")); + // pnpm deploy annotates pinned versions with resolved peer suffixes. + assert.equal(sdk.version, manifest.dependencies["@anthropic-ai/claude-agent-sdk"].split("(")[0]); + assert.equal(mcp.version, manifest.dependencies["@modelcontextprotocol/sdk"].split("(")[0]); + const libc = process.platform === "linux" && !(process.report?.getReport() as { header: { glibcVersionRuntime?: string } }).header.glibcVersionRuntime ? "-musl" : ""; + const nativeName = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}${libc}`; + const sdkRequire = createRequire(join(sdkDir, "package.json")); + const nativePath = await inside(sdkRequire.resolve(`${nativeName}/package.json`)); + const native = JSON.parse(await readFile(nativePath, "utf8")); + assert.equal(native.version, sdk.version); + const binary = await inside(join(dirname(nativePath), process.platform === "win32" ? "claude.exe" : "claude")); + await access(binary, constants.X_OK); + const options = { encoding: "utf8" as const, timeout: 5000, killSignal: "SIGKILL" as const, maxBuffer: 64 * 1024, cwd: root }; + const version = spawnSync(binary, ["--version"], options); + assert.equal(version.error, undefined, "native_unavailable"); + assert.equal(version.status, 0, "native_unavailable"); + assert.match(sdk.claudeCodeVersion, /^\d+\.\d+\.\d+$/); + const nativeVersion = `${sdk.claudeCodeVersion} (Claude Code)`; + assert.equal(version.stdout.trim(), nativeVersion, "unexpected_native_version"); + const entrypoint = await inside(process.argv[2] ?? join(root, "dist/main.js")); + assert.equal(entrypoint, await realpath(join(root, "dist/main.js")), "unexpected_entrypoint"); + const smoke = spawnSync(process.execPath, [entrypoint], { ...options, input: "" }); + assert.equal(smoke.error, undefined, "bridge_unavailable"); + assert.equal(smoke.status, 0, "bridge_unavailable"); + assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); +} catch { + // Native diagnostics can include operator environment; never forward them. + process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); + process.exitCode = 1; +} diff --git a/packages/claude-sdk-adapter/src/usage.ts b/packages/claude-sdk-adapter/src/usage.ts new file mode 100644 index 000000000..e7ad54077 --- /dev/null +++ b/packages/claude-sdk-adapter/src/usage.ts @@ -0,0 +1,9 @@ +import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; + +export type NativeUsage = Pick; + +// Keep the SDK scopes and estimate provenance intact. This is not public token accounting. +export function resultUsage(message: SDKResultMessage): NativeUsage { + return structuredClone({ usage: message.usage, modelUsage: message.modelUsage, + total_cost_usd: message.total_cost_usd, subtype: message.subtype, is_error: message.is_error }); +} diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts new file mode 100644 index 000000000..3bb802dd0 --- /dev/null +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -0,0 +1,185 @@ +import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; +import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export type Workspace = { + home: string; + state: string; + scratch: string; + protected_dirs: string[]; + dependency_path: string; + env_names: string[]; + skills?: WorkspaceSkill[]; + tool_environment?: boolean; + system_packages?: boolean; + network_access?: "enabled" | "disabled"; +}; + +const environmentNames = new Set([ + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", +]); +const credentialNames = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]; +const nativeTools = ["Bash", "Read", "Edit"]; +const denial = "Tool is outside the workspace execution profile."; +const invalidPath = /[\x00-\x1f\x7f\\:*?\[\]{}()]/; +const contains = (root: string, path: string) => path === root || path.startsWith(root + "/"); + +function directory(value: unknown, canonical: boolean): string { + if (typeof value !== "string" || !isAbsolute(value) || value === "/" || invalidPath.test(value)) { + throw new Error("invalid_request"); + } + try { + const actual = realpathSync(value); + if (!statSync(actual).isDirectory() || (canonical && actual !== value)) throw new Error(); + return actual; + } catch { throw new Error("invalid_request"); } +} + +export function parseWorkspace(value: unknown, cwd: string): Workspace | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); + const config = value as Record; + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "tool_environment", "system_packages", "skills"].includes(key)) || + (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || + (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || + (config.system_packages === true && config.tool_environment !== true) || + (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled") || + !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || + typeof config.dependency_path !== "string" || !config.dependency_path || + config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || + new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); + const roots = [cwd, config.home, config.state, config.scratch, ...config.protected_dirs].map(path => directory(path, true)); + if (roots.some((root, index) => roots.some((other, otherIndex) => index !== otherIndex && contains(root, other)))) { + throw new Error("invalid_request"); + } + const dependencies = config.dependency_path.split(":").map(path => { + const actual = directory(path, false); + if (roots.some(root => contains(root, resolve(path)) || contains(resolve(path), root) || + contains(root, actual) || contains(actual, root))) throw new Error("invalid_request"); + return actual; + }); + return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }), dependency_path: dependencies.join(":") } as Workspace; +} + +export class WorkspaceProfile { + readonly options: Options; + private readonly skillNames: readonly string[]; + + constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = []) { + config = parseWorkspace(config, cwd)!; + // SDK history lookup reads the bridge environment, independently of query.env. + if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || + process.env.CLAUDE_CODE_PROJECT_DIR_NAME !== undefined) throw new Error("invalid_request"); + const env: Record = { + PATH: config.dependency_path, HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", + DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + }; + for (const name of config.env_names) { + const value = process.env[name]; + if (value === undefined) throw new Error("invalid_request"); + env[name] = value; + } + if (config.system_packages) { + env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/agents-api-tool-root"; + env.PARSAR_RUNTIME_TOOL_SCRATCH = config.scratch; + } + const skills = workspaceSkills(config.state, config.skills ?? []); + this.skillNames = skills?.names ?? []; + const skillTools = skills ? ["Skill"] : []; + const protectedRoots = [config.home, config.state, ...config.protected_dirs]; + this.options = { + env, tools: [...nativeTools, ...skillTools], + ...(skills ? { plugins: [{ type: "local" as const, path: skills.path, skipMcpDiscovery: true }] } : {}), allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, + settingSources: [], permissionMode: "default", persistSession: true, + settings: { + ...(skills ? { disableSkillShellExecution: true } : {}), + permissions: { + blockReadsOutsideWorkingDirectories: true, disableBypassPermissionsMode: "disable", + deny: [...protectedRoots, "/proc", "/sys"].flatMap(path => [ + `Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`, + ]), + }, + }, + sandbox: { + enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, + excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, + filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, + denyWrite: [...protectedRoots, ...(skills ? ["/environment/initialization/capabilities"] : []), + ...(config.system_packages ? ["/environment/packages/system"] : [])], allowRead: [] }, + credentials: { + envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), + files: protectedRoots.map(path => ({ path, mode: "deny" })), + }, + network: { allowedDomains: config.network_access === "enabled" ? ["*"] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, + }, + canUseTool: this.canUseTool, + hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, + }; + } + + verify(tools: string[], servers: { name: string; status: string }[]): void { + const expected = [...nativeTools, ...this.functions, ...(this.skillNames.length ? ["Skill"] : [])]; + if (servers.length !== (this.functions.length ? 1 : 0) || + servers.some(server => server.name !== "functions" || server.status !== "connected") || + tools.length !== expected.length || new Set(tools).size !== tools.length || + tools.some(name => !expected.includes(name))) throw new Error("unexpected native workspace inventory"); + } + + readonly canUseTool: CanUseTool = async (name, input, { signal, agentID }) => { + if (!signal.aborted && agentID === undefined && this.permits(name, input)) { + return { behavior: "allow", updatedInput: this.absoluteInput(name, input) }; + } + return { behavior: "deny", message: denial }; + }; + + readonly beforeTool: HookCallback = async (input, id, { signal }) => { + if (!signal.aborted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && + (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { + if (input.tool_name === "Bash" && this.config.tool_environment) { + const toolInput = input.tool_input as Record; + const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; + return { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, + command: ". /environment/initialization/tool-env.sh && eval -- " + quote(toolInput.command as string) } } }; + } + return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", + updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; + } + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: denial } }; + }; + + private absoluteInput(name: string, input: Record): Record { + return name !== "Read" && name !== "Edit" ? input : { ...input, file_path: resolve(this.cwd, input.file_path as string) }; + } + + private permits(name: string, value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const input = value as Record; + if (this.functions.includes(name)) return true; + if (name === "Skill") return typeof input.skill === "string" && this.skillNames.includes(input.skill); + if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && + (input.run_in_background === undefined || input.run_in_background === false) && + (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); + if ((name !== "Read" && name !== "Edit") || typeof input.file_path !== "string" || !input.file_path || + /[\x00-\x1f]/.test(input.file_path)) return false; + const path = resolve(this.cwd, input.file_path); + if (!contains(this.cwd, path)) return false; + let existing = path; + const missing: string[] = []; + try { + while (true) { + try { lstatSync(existing); break; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || existing === this.cwd) return false; + missing.unshift(existing.slice(dirname(existing).length + 1)); + existing = dirname(existing); + } + } + return contains(this.cwd, join(realpathSync(existing), ...missing)); + } catch { return false; } + } +} diff --git a/packages/claude-sdk-adapter/src/workspace_directories.ts b/packages/claude-sdk-adapter/src/workspace_directories.ts new file mode 100644 index 000000000..c36c48cbc --- /dev/null +++ b/packages/claude-sdk-adapter/src/workspace_directories.ts @@ -0,0 +1,90 @@ +import { constants } from "node:fs"; +import { lstat, open, opendir, type FileHandle } from "node:fs/promises"; + +export type WorkspaceDirectoryEntry = { name: string; kind: "file" | "directory" | "symlink" | "other"; size_bytes?: number }; +export type WorkspaceDirectoryEvent = { type: "workspace_directory"; id: string } & ( + { entries: WorkspaceDirectoryEntry[]; truncated: boolean } | + { error: "invalid" | "busy" | "unavailable" | "uncertain" | "not_found" | "permission" } +); +const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; +const anchored = (handle: FileHandle) => `/proc/self/fd/${handle.fd}`; + +export class WorkspaceDirectories { + private root?: FileHandle; + private stopped = false; + private pending?: Promise; + + constructor(private readonly emit: (event: WorkspaceDirectoryEvent) => Promise, private readonly abort: AbortController) {} + + async bind(root: string): Promise { + if (process.platform !== "linux" || this.root || this.stopped) throw new Error("directory listing unavailable"); + this.root = await open(root, flags); + } + + submit(value: Record): void { + if (typeof value.id !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(value.id)) throw new Error("invalid_request"); + const id = value.id; + let error: "invalid" | "busy" | "unavailable" | undefined; + if (Buffer.byteLength(JSON.stringify(value)) > 8192 || Object.keys(value).some(key => !["type", "id", "directory", "max_entries"].includes(key)) || + typeof value.directory !== "string" || /[\x00\\\r\n]/.test(value.directory) || + (value.directory !== "" && value.directory.split("/").some(part => !part || part === "." || part === "..")) || + !Number.isInteger(value.max_entries) || (value.max_entries as number) < 1 || (value.max_entries as number) > 1000) error = "invalid"; + else if (this.stopped || this.abort.signal.aborted || !this.root) error = "unavailable"; + else if (this.pending) error = "busy"; + if (error) { void this.emit({ type: "workspace_directory", id, error }).catch(() => this.abort.abort()); return; } + this.pending = this.list(id, value.directory as string, value.max_entries as number).finally(() => { this.pending = undefined; }); + } + + private async list(id: string, directory: string, maxEntries: number): Promise { + try { + const result = await this.enumerate(directory, maxEntries); + await this.emit({ type: "workspace_directory", id, ...result }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const classified = code === "ENOENT" ? "not_found" : code === "ENOTDIR" ? "invalid" : + ["EACCES", "EPERM", "ELOOP"].includes(code ?? "") ? "permission" : "uncertain"; + await this.emit({ type: "workspace_directory", id, error: classified }).catch(() => this.abort.abort()); + if (classified === "uncertain") { this.stopped = true; this.abort.abort(); } + } + } + + private async enumerate(directory: string, maxEntries: number): Promise<{ entries: WorkspaceDirectoryEntry[]; truncated: boolean }> { + const handles: FileHandle[] = []; + try { + let parent = this.root!; + for (const component of directory === "" ? [] : directory.split("/")) { + parent = await open(`${anchored(parent)}/${component}`, flags); + handles.push(parent); + } + // Node supports byte names, but its opendir typings omit the buffer encoding. + const dir = await opendir(anchored(parent), { encoding: "buffer" as BufferEncoding }); + try { + const entries: WorkspaceDirectoryEntry[] = []; + while (true) { + if (this.abort.signal.aborted) throw new Error("owner closed"); + const entry = await dir.read(); + if (!entry) return { entries, truncated: false }; + if (entries.length === maxEntries) return { entries, truncated: true }; + const rawName: unknown = entry.name; + if (!Buffer.isBuffer(rawName)) throw new Error("invalid entry name"); + const name = rawName.toString("utf8"); + if (!Buffer.from(name).equals(rawName) || !name || /[\x00/]/.test(name) || name === "." || name === "..") throw new Error("unsupported entry name"); + const stat = await lstat(`${anchored(parent)}/${name}`); + const kind = stat.isFile() ? "file" : stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "other"; + if (kind === "file" && (!Number.isSafeInteger(stat.size) || stat.size < 0)) throw new Error("invalid file size"); + entries.push({ name, kind, ...(kind === "file" ? { size_bytes: stat.size } : {}) }); + } + } finally { await dir.close(); } + } finally { + const closed = await Promise.allSettled(handles.reverse().map(handle => handle.close())); + if (closed.some(result => result.status === "rejected")) throw new Error("directory close failed"); + } + } + + async close(): Promise { + this.stopped = true; + await this.pending; + await this.root?.close(); + this.root = undefined; + } +} diff --git a/packages/claude-sdk-adapter/src/workspace_reads.ts b/packages/claude-sdk-adapter/src/workspace_reads.ts new file mode 100644 index 000000000..fc9c6aa4e --- /dev/null +++ b/packages/claude-sdk-adapter/src/workspace_reads.ts @@ -0,0 +1,57 @@ +import type { Query } from "@anthropic-ai/claude-agent-sdk"; +import { join } from "node:path"; + +export type WorkspaceReadEvent = { type: "workspace_read"; id: string } & ( + { data_base64: string; truncated: boolean } | { error: "invalid" | "busy" | "unavailable" | "uncertain" } +); + +export class WorkspaceReads { + private query?: Pick; + private root = ""; + private stopped = false; + private pending?: Promise; + + constructor(private readonly emit: (event: WorkspaceReadEvent) => Promise, private readonly abort: AbortController) {} + + bind(query: Pick, root: string): void { this.query = query; this.root = root; } + + submit(value: Record): void { + if (typeof value.id !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(value.id)) throw new Error("invalid_request"); + const id = value.id; + let error: "invalid" | "busy" | "unavailable" | undefined; + if (Buffer.byteLength(JSON.stringify(value)) > 8192 || Object.keys(value).some(key => !["type", "id", "path", "max_bytes"].includes(key)) || + typeof value.path !== "string" || !value.path || /[\x00\\\r\n]/.test(value.path) || value.path.split("/").some(part => !part || part === "." || part === "..") || + !Number.isInteger(value.max_bytes) || (value.max_bytes as number) < 1 || (value.max_bytes as number) > 1048576) error = "invalid"; + else if (this.stopped || this.abort.signal.aborted || !this.query) error = "unavailable"; + else if (this.pending) error = "busy"; + if (error) { void this.emit({ type: "workspace_read", id, error }).catch(() => this.abort.abort()); return; } + const query = this.query!; + const path = join(this.root, value.path as string); + const maxBytes = value.max_bytes as number; + this.pending = this.read(query, id, path, maxBytes).finally(() => { this.pending = undefined; }); + } + + private async read(query: Pick, id: string, path: string, maxBytes: number): Promise { + let timer: ReturnType | undefined; + const native = query.readFile(path, { maxBytes, encoding: "base64" }); + try { + const result = await Promise.race([native, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("native read timeout")), 10000); + })]); + if (!result || result.encoding !== "base64" || result.absPath !== path || typeof result.contents !== "string" || + (result.truncated !== undefined && typeof result.truncated !== "boolean") || result.contents.length > 4 * Math.ceil(maxBytes / 3)) throw new Error("uncertain"); + const bytes = Buffer.from(result.contents, "base64"); + if (bytes.toString("base64") !== result.contents || bytes.length > maxBytes || (result.truncated && bytes.length !== maxBytes)) throw new Error("uncertain"); + await this.emit({ type: "workspace_read", id, data_base64: result.contents, truncated: result.truncated === true }); + } catch { + this.stopped = true; + await this.emit({ type: "workspace_read", id, error: "uncertain" }).catch(() => {}); + this.abort.abort(); + // SDK close resolves its retained request waiter; timeout alone is not native settlement. + query.close(); + await native.catch(() => null); + } finally { clearTimeout(timer); } + } + + async close(): Promise { this.stopped = true; await this.pending; } +} diff --git a/packages/claude-sdk-adapter/src/workspace_skills.ts b/packages/claude-sdk-adapter/src/workspace_skills.ts new file mode 100644 index 000000000..4ba85305c --- /dev/null +++ b/packages/claude-sdk-adapter/src/workspace_skills.ts @@ -0,0 +1,44 @@ +import { lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type WorkspaceSkill = { type: "inline"; name: string; description: string }; +export const skillRoot = "/environment/initialization/capabilities/skills"; +const pluginName = "environment-skills"; + +export function parseSkills(value: unknown): WorkspaceSkill[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 50) throw new Error("invalid_request"); + const seen = new Set(); + for (const skill of value) { + if (!skill || typeof skill !== "object" || Array.isArray(skill) || + Object.keys(skill).some(key => !["type", "name", "description"].includes(key)) || + skill.type !== "inline" || typeof skill.name !== "string" || !/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(skill.name) || + skill.name.length > 64 || typeof skill.description !== "string" || !skill.description || seen.has(skill.name)) throw new Error("invalid_request"); + seen.add(skill.name); + } + return value as WorkspaceSkill[]; +} + +// The adapter generates the native plugin envelope; public Skill archives never +// supply a plugin manifest, settings, hooks or an MCP installation. +export function workspaceSkills(state: string, skills: readonly WorkspaceSkill[]): { path: string; names: string[] } | undefined { + if (!skills.length) return undefined; + for (const skill of skills) { + const root = join(skillRoot, skill.name); + const body = readFileSync(join(root, "SKILL.md"), "utf8"); + if (/(?<=^|\s)!`[^`]+`/m.test(body) || /```!\s*\n?[\s\S]*?\n?```/.test(body)) { + throw new Error("unsupported native Skill activation"); + } + } + const path = join(state, "environment-skills"); + mkdirSync(join(path, ".claude-plugin"), { recursive: true, mode: 0o700 }); + writeFileSync(join(path, ".claude-plugin", "plugin.json"), JSON.stringify({ name: pluginName, description: "Environment Skills" }), { mode: 0o600 }); + const link = join(path, "skills"); + try { + if (!lstatSync(link).isSymbolicLink() || readlinkSync(link) !== skillRoot) throw new Error("invalid native Skill root"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + symlinkSync(skillRoot, link); + } + return { path, names: skills.map(skill => `${pluginName}:${skill.name}`) }; +} diff --git a/packages/claude-sdk-adapter/tests/command_observer.test.mjs b/packages/claude-sdk-adapter/tests/command_observer.test.mjs new file mode 100644 index 000000000..fbfd61803 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/command_observer.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CommandObserver } from "../dist/command_observer.js"; + +const assistant = (id, command = "printf 'native output\\n'", input = {}) => ({ type: "assistant", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id, name: "Bash", input: { command, ...input } }] } }); +const user = (id, content, is_error = false) => ({ type: "user", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_result", tool_use_id: id, content, is_error }] } }); +const consume = (observer, message, session = "session", hasInput = true) => [...observer.consume(message, session, hasInput)]; +const success = "OBS_SUCCESS_STDERR\nOBS_SUCCESS_STDOUT"; +const failure = "Exit code 7\nOBS_FAILURE_STDERR\nOBS_FAILURE_STDOUT"; + +test("root Bash calls preserve native identity, exact command and observed success/failure text", () => { + const observer = new CommandObserver(); + const command = " printf 'OBS_SUCCESS_STDOUT\\n'; printf 'OBS_SUCCESS_STDERR\\n' >&2 "; + const before = consume(observer, assistant("success", command)); + assert.deepEqual(before, [{ type: "command_observation", session_id: "session", id: "success", stage: "before", + observation: { kind: "command", status: "in_progress", command } }]); + consume(observer, assistant("failed", "printf 'OBS_FAILURE_STDOUT\\n'; printf 'OBS_FAILURE_STDERR\\n' >&2; exit 7")); + const completed = consume(observer, { ...user("success", success), + tool_use_result: { stdout: success, stderr: "", interrupted: false, isImage: false, noOutputExpected: false } })[0]; + const failed = consume(observer, { ...user("failed", failure, true), tool_use_result: "Error: " + failure })[0]; + assert.equal(completed.observation.output, success); + assert.equal(completed.observation.status, "completed"); + assert.equal(failed.observation.output, failure); + assert.equal(failed.observation.status, "failed"); + for (const event of [completed, failed]) { + assert.deepEqual(Object.keys(event.observation).sort(), ["command", "kind", "output", "status"]); + } + observer.assertComplete(); + assert.deepEqual(observer.close(), []); +}); + +test("replayed, synthetic, child, non-Bash and assistant-error messages cannot create observations", () => { + const observer = new CommandObserver(); + const read = assistant("read"); + read.message.content[0].name = "Read"; + for (const message of [{ ...assistant("a"), parent_tool_use_id: "parent" }, { ...assistant("a"), isReplay: true }, + { ...assistant("a"), isSynthetic: true }, { ...assistant("a"), error: "server_error" }, read, + { type: "tool_progress", tool_use_id: "a", elapsed_time_seconds: 3 }, + { type: "command_lifecycle", uuid: "a", status: "completed" }, + { type: "stream_event", parent_tool_use_id: null, event: { type: "content_block_start", content_block: { type: "tool_use", id: "a", name: "Bash" } } }]) { + assert.deepEqual(consume(observer, message), []); + } + assert.deepEqual(consume(observer, user("old", "old output")), []); + consume(observer, assistant("a")); + for (const fields of [{ isReplay: true }, { isSynthetic: true }, { parent_tool_use_id: "parent" }]) { + assert.deepEqual(consume(observer, { ...user("a", "not observed"), ...fields }), []); + } + assert.throws(() => observer.assertComplete(), /unconfirmed/); + assert.equal(observer.close()[0].observation.status, "incomplete"); +}); + +test("input, Session and call identities are required without deriving them from configuration", () => { + for (const [session, hasInput] of [["", true], ["other", true], ["session", false]]) { + const observer = new CommandObserver(); + assert.throws(() => consume(observer, assistant("a"), session, hasInput), /session identity/); + assert.deepEqual(observer.close(), []); + } + for (const [id, command] of [["", "pwd"], [7, "pwd"], ["a", ""], ["a", " "], ["a", 7]]) { + assert.throws(() => consume(new CommandObserver(), assistant(id, command)), /command call/); + } + const observer = new CommandObserver(); + consume(observer, assistant("a")); + assert.throws(() => consume(observer, { ...user("a", "output"), session_id: "other" }), /session identity/); + assert.throws(() => consume(observer, { ...assistant("b"), session_id: "other" }, "other"), /session identity/); + assert.equal(observer.close()[0].id, "a"); +}); + +test("a native identity cannot be reused across Bash and another tool", () => { + for (const firstBash of [false, true]) { + const observer = new CommandObserver(); + const bash = assistant("shared"); + const read = assistant("shared"); + read.message.content[0].name = "Read"; + consume(observer, firstBash ? bash : read); + assert.throws(() => consume(observer, firstBash ? read : bash), /conflicting/); + assert.equal(observer.close().length, firstBash ? 1 : 0); + } +}); + +test("identical calls and results are observed once while conflicting identities cannot replace them", () => { + const observer = new CommandObserver(); + const call = assistant("a", "pwd", { timeout: 10 }); + consume(observer, call); + assert.deepEqual(consume(observer, call), []); + assert.throws(() => consume(observer, assistant("a", "pwd", { timeout: 20 })), /conflicting/); + const result = user("a", "output"); + consume(observer, result); + assert.deepEqual(consume(observer, result), []); + assert.deepEqual(consume(observer, call), []); + assert.throws(() => consume(observer, user("a", "different")), /conflicting/); + assert.throws(() => consume(observer, user("a", "output", true)), /conflicting/); + assert.deepEqual(observer.close(), []); +}); + +test("missing and ambiguous outputs remain absent while per-call results stay separate", () => { + const observer = new CommandObserver(); + for (const id of ["empty", "missing", "rich", "a", "b", "single"]) consume(observer, assistant(id)); + assert.equal(consume(observer, user("empty", ""))[0].observation.output, ""); + assert.equal("output" in consume(observer, user("missing", undefined))[0].observation, false); + assert.equal("output" in consume(observer, user("rich", [{ type: "text", text: "one" }, { type: "text", text: "two" }]))[0].observation, false); + const batch = user("a", "first"); + batch.message.content.push(...user("b", "second", true).message.content); + batch.tool_use_result = { stdout: "unassignable", stderr: "", interrupted: true }; + assert.deepEqual(consume(observer, batch).map(event => [event.id, event.observation.status, event.observation.output]), + [["a", "completed", "first"], ["b", "failed", "second"]]); + assert.equal(consume(observer, user("single", [{ type: "text", text: "exact" }]))[0].observation.output, "exact"); + observer.assertComplete(); +}); + +test("trustworthy interruption retains output and shutdown leaves unfinished calls incomplete", () => { + const observer = new CommandObserver(); + consume(observer, assistant("interrupted")); + const interrupted = consume(observer, { ...user("interrupted", "partial", true), + tool_use_result: { stdout: "partial", stderr: "", interrupted: true } })[0]; + assert.equal(interrupted.observation.status, "incomplete"); + assert.equal(interrupted.observation.output, "partial"); + consume(observer, assistant("unfinished")); + const closed = observer.close(); + assert.deepEqual(closed.map(event => [event.id, event.observation.status, "output" in event.observation]), [["unfinished", "incomplete", false]]); + assert.deepEqual(observer.close(), []); + assert.throws(() => consume(observer, user("unfinished", "too late")), /closure/); +}); + +test("unexpected background and malformed interruption evidence cannot become completion", () => { + for (const native of [{ stdout: "", stderr: "", interrupted: false, backgroundTaskId: "task" }, + { stdout: "", stderr: "", interrupted: false, timedOutAfterMs: 10 }, { interrupted: true }, + { stdout: "", stderr: "", interrupted: "true" }]) { + const observer = new CommandObserver(); + consume(observer, assistant("a")); + assert.throws(() => consume(observer, { ...user("a", "native text"), tool_use_result: native })); + assert.equal(observer.close()[0].observation.status, "incomplete"); + } +}); + +test("a later invalid block does not hide the earlier observed call or invent an unmatched closure", () => { + const observer = new CommandObserver(); + const message = assistant("a"); + message.message.content.push(...assistant("bad", "").message.content); + const iterator = observer.consume(message, "session", true); + assert.equal(iterator.next().value.id, "a"); + assert.throws(() => iterator.next(), /command call/); + assert.deepEqual(observer.close().map(event => event.id), ["a"]); +}); diff --git a/packages/claude-sdk-adapter/tests/execution.test.mjs b/packages/claude-sdk-adapter/tests/execution.test.mjs new file mode 100644 index 000000000..232512a5e --- /dev/null +++ b/packages/claude-sdk-adapter/tests/execution.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +// Exercise execute() against controlled SDK event interleavings. The native child +// is real, but these cases are not live provider acceptance. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const sdk = 'export async function getSessionInfo(){return {sessionId:"native"};} export function query(options){return globalThis.queryFixture(options);} export function startup(){throw new Error("Unexpected preparation");}'; +registerHooks({ resolve(specifier, context, next) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; + return next(specifier,context); +}}); +const { execute } = await import(${JSON.stringify(new URL("../dist/adapter.js", import.meta.url).href)}); +const { Inputs } = await import(${JSON.stringify(new URL("../dist/inputs.js", import.meta.url).href)}); +const { FunctionBridge } = await import(${JSON.stringify(new URL("../dist/function_bridge.js", import.meta.url).href)}); +const mode = process.argv[1]; +const events = []; +const inputs = new Inputs("opening text"); +const abort = new AbortController(); +let invocation; +let functions; +const emit = async event => { + events.push(event); + if (event.type === "input_ready") inputs.submit({type:"steer",input_id:"extra",text:"later text"}); + if (mode === "later-function" && event.type === "usage" && !invocation) { + // The SDK dispatches MCP controls independently while the output consumer is + // yielding the previous native turn's result. This call belongs to the next turn. + invocation = functions.invoke({id:"call-next",name:"lookup",arguments:{}},abort.signal); + invocation.catch(() => {}); + } +}; +functions = new FunctionBridge(emit); +const init = {type:"system",subtype:"init",session_id:"native",tools:mode === "later-function"?["mcp__functions__lookup"]:[],mcp_servers:mode === "later-function"?[{name:"functions",status:"connected"}]:[]}; +const result = (uuid, ids, failed = false) => ({type:"result",uuid,session_id:"native",user_message_uuids:ids, + subtype:failed?"error_during_execution":"success",is_error:failed,result:"later answer", + usage:{input_tokens:2,output_tokens:1},modelUsage:{},total_cost_usd:0.01}); +globalThis.queryFixture = ({prompt,options}) => { + const child = options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:process.env,signal:abort.signal}); + return { + close(){child.stdin.end();}, + async *[Symbol.asyncIterator](){ + const iterator = prompt[Symbol.asyncIterator](); + const first = (await iterator.next()).value; + yield init; + const second = (await iterator.next()).value; + if(mode === "error-receipt") { yield result("failed",[first.uuid,second.uuid],true); return; } + yield result("first",[first.uuid]); + assert.ok(invocation,"next turn's control request did not reach the pending ledger"); + yield init; + functions.submit(JSON.stringify({type:"function_result",call_id:"call-next",delivery_id:"delivery",success:true,content:[{type:"input_text",text:"value"}]})); + await invocation; + yield {type:"user",session_id:"native",parent_tool_use_id:null,message:{role:"user",content:[{type:"tool_result",tool_use_id:"call-next",content:[{type:"text",text:"value"}],is_error:false}]}}; + yield result("second",[second.uuid]); + }, + }; +}; +await execute({type:"start",prompt:"opening text",model:"fixture",system_prompt:"",cwd:process.cwd(), + ...(mode === "later-function" ? {functions:[{name:"lookup",description:"fixture",parameters:{type:"object",properties:{}}}]} : {})},emit,abort,functions,inputs); +assert.equal(inputs.complete,true); +assert.equal(events.filter(e=>e.type === "input_applied" && e.input_id === "extra").length,1); +if(mode === "error-receipt") { + assert.equal(events.at(-1).type,"error"); + assert.equal(events.at(-1).code,"execution_failed"); + assert.equal(events.filter(e=>e.type === "usage").length,1); + assert.equal(events.some(e=>e.type === "result"),false); +} else { + assert.equal(events.at(-1).type,"result"); + assert.equal(events.filter(e=>e.type === "function_applied").length,1); + assert.equal(events.filter(e=>e.type === "usage").length,2); + assert.equal(events.some(e=>e.type === "error"),false); +} +`; + +for (const mode of ["error-receipt", "later-function"]) { + test(`native result lifecycle: ${mode}`, { timeout: 15000 }, () => { + const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture, mode], { encoding: "utf8", timeout: 10000 }); + assert.equal(child.status, 0, child.stderr || child.error?.message); + }); +} diff --git a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs new file mode 100644 index 000000000..08f97db79 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FunctionBridge } from "../dist/function_bridge.js"; + +const call = id => ({ id, name: "lookup", arguments: { ids: ["same"] } }); +const result = (id, success = true) => ({ type: "function_result", call_id: id, delivery_id: `delivery-${id}`, success, + content: [{ type: "input_text", text: `first-${id}` }, { type: "input_text", text: `second-${id}` }] }); +const native = (value, content) => ({ type: "user", parent_tool_use_id: null, session_id: "native", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: value.call_id, + is_error: !value.success, content: content ?? (value.success ? value.content.map(part => ({ type: "text", text: part.text })) : value.content.map(part => part.text).join("\n")) }] } }); + +test("identical calls accept reversed results but require matching live native responses", { timeout: 5000 }, async () => { + const events = []; + const bridge = new FunctionBridge(async event => { events.push(event); }); + const first = bridge.invoke(call("a"), new AbortController().signal); + const second = bridge.invoke(call("b"), new AbortController().signal); + for (const value of [result("b", false), result("a")]) bridge.submit(JSON.stringify(value)); + const [a, b] = await Promise.all([first, second]); + assert.deepEqual(a.content.map(part => part.text), ["first-a", "second-a"]); + assert.equal(b.isError, true); + assert.deepEqual(events.map(event => event.type), ["function_call", "function_call"]); + assert.throws(() => bridge.assertComplete(), /unconfirmed/); + await bridge.consume({ ...native(result("a")), isReplay: true }, "native"); + await bridge.consume(native(result("a")), "other-session"); + await bridge.consume({ ...native(result("a")), parent_tool_use_id: "child" }, "native"); + assert.equal(events.length, 2); + await assert.rejects(bridge.consume(native(result("a"), [{ type: "text", text: "wrong" }]), "native"), /differs/); + await bridge.consume(native(result("b", false)), "native"); + await bridge.consume(native(result("a")), "native"); + assert.deepEqual(events.slice(2), [ + { type: "function_applied", call_id: "b", delivery_id: "delivery-b" }, + { type: "function_applied", call_id: "a", delivery_id: "delivery-a" }, + ]); + bridge.assertComplete(); + await assert.rejects(bridge.invoke(call("a"), new AbortController().signal), /Repeated/); +}); + +test("invalid results preserve pending calls, cancellation and close never acknowledge application", { timeout: 5000 }, async () => { + const events = []; + const bridge = new FunctionBridge(async event => { events.push(event); }); + const abort = new AbortController(); + const cancelled = bridge.invoke(call("a"), abort.signal); + const stopped = bridge.invoke(call("b"), new AbortController().signal); + const cancelCheck = assert.rejects(cancelled, /aborted/); + const stopCheck = assert.rejects(stopped, /ended/); + for (const bad of [null, { ...result("a"), success: null }, { ...result("a"), content: null }, + { ...result("a"), content: [{ type: "input_image", image_url: "https://example.invalid/image" }] }, result("unknown")]) { + assert.throws(() => bridge.submit(JSON.stringify(bad))); + } + abort.abort(); + bridge.close(); + await Promise.all([cancelCheck, stopCheck]); + await bridge.consume(native(result("a")), "native"); + assert.deepEqual(events.map(event => event.type), ["function_call", "function_call"]); +}); + +test("an aborted submitted result never becomes a native application receipt", { timeout: 5000 }, async () => { + const events = []; + const bridge = new FunctionBridge(async event => { events.push(event); }); + const abort = new AbortController(); + const waiting = bridge.invoke(call("a"), abort.signal); + bridge.submit(JSON.stringify(result("a", false))); + await waiting; + abort.abort(); + await bridge.consume(native(result("a", false)), "native"); + assert.deepEqual(events.map(event => event.type), ["function_call"]); +}); diff --git a/packages/claude-sdk-adapter/tests/functions.test.mjs b/packages/claude-sdk-adapter/tests/functions.test.mjs new file mode 100644 index 000000000..41aa68743 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/functions.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createFunctionServer } from "../dist/functions.js"; + +const schema = { + type: "object", additionalProperties: false, + $defs: { row: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, + properties: { + rows: { type: "array", items: { $ref: "#/$defs/row" }, minItems: 1 }, + choice: { anyOf: [{ type: "string", enum: ["ready"] }, { type: "null" }] }, + mode: { oneOf: [{ const: "read" }, { const: "write" }] }, + }, + required: ["rows", "choice", "mode"], +}; +const definition = () => ({ name: "lookup", description: "Synthetic lookup", inputSchema: structuredClone(schema) }); +const call = (id, args = { rows: [{ id: "42" }], choice: null, mode: "read" }) => ({ + name: "lookup", arguments: args, _meta: { "claudecode/toolUseId": id }, +}); +async function connect(t, tools, invoke) { + const server = createFunctionServer(tools, invoke); + const client = new Client({ name: "verification", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + t.after(async () => { await client.close(); await server.instance.close(); }); + await Promise.all([server.instance.connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +test("official MCP client receives a stable lossless schema snapshot", { timeout: 5000 }, async t => { + const tool = definition(); + tool._meta = { retained: "metadata" }; + const client = await connect(t, [tool], async () => ({ content: [] })); + tool.inputSchema.properties.mode = { type: "number" }; + const listed = await client.listTools(); + assert.deepEqual(listed.tools[0], { + ...definition(), _meta: { retained: "metadata", "anthropic/alwaysLoad": true }, + }); +}); + +test("identical concurrent calls retain native identity and ordered success/error content", { timeout: 5000 }, async t => { + const pending = new Map(); + let bothStarted; + const started = new Promise(resolve => { bothStarted = resolve; }); + const client = await connect(t, [definition()], (request, signal) => new Promise(resolve => { + assert.deepEqual(request.arguments, call("").arguments); + assert.equal(signal.aborted, false); + pending.set(request.id, resolve); + if (pending.size === 2) bothStarted(); + })); + const first = client.callTool(call("native-first")); + const second = client.callTool(call("native-second")); + await started; + const error = { isError: true, content: [{ type: "text", text: "failed" }, { type: "text", text: "reason" }] }; + pending.get("native-second")(error); + assert.deepEqual(await second, error); + const success = { isError: false, content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] }; + pending.get("native-first")(success); + assert.deepEqual(await first, success); +}); + +test("missing identities and unknown functions never invoke the host", { timeout: 5000 }, async t => { + let invoked = 0; + const client = await connect(t, [definition()], async () => { invoked++; return { content: [] }; }); + for (const request of [{ name: "lookup" }, call(""), call(42), { ...call("native"), name: "unknown" }]) { + await assert.rejects(client.callTool(request), /identity is missing|Unknown function/); + } + assert.equal(invoked, 0); +}); + +test("MCP cancellation reaches the individual host callback", { timeout: 5000 }, async t => { + let onStarted, onAborted; + const started = new Promise(resolve => { onStarted = resolve; }); + const aborted = new Promise(resolve => { onAborted = resolve; }); + const client = await connect(t, [definition()], async (request, signal) => { + onStarted(request.id); + await new Promise(resolve => { + signal.addEventListener("abort", () => { onAborted(request.id); resolve(); }, { once: true }); + }); + return { content: [] }; + }); + const controller = new AbortController(); + const result = client.callTool(call("cancel-native"), undefined, { signal: controller.signal }); + const rejection = assert.rejects(result); + assert.equal(await started, "cancel-native"); + controller.abort(); + await rejection; + assert.equal(await aborted, "cancel-native"); +}); + +test("ambiguous function registration is rejected", () => { + const invoke = async () => ({ content: [] }); + assert.throws(() => createFunctionServer([definition(), definition()], invoke), /unique/); + assert.throws(() => createFunctionServer([{ ...definition(), name: "" }], invoke), /nonempty/); +}); diff --git a/packages/claude-sdk-adapter/tests/inputs.test.mjs b/packages/claude-sdk-adapter/tests/inputs.test.mjs new file mode 100644 index 000000000..aea52251d --- /dev/null +++ b/packages/claude-sdk-adapter/tests/inputs.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Inputs } from "../dist/inputs.js"; + +const result = (ids, session_id = "native") => ({ type: "result", session_id, user_message_uuids: ids }); +const steer = (input_id, text = "additional text") => ({ type: "steer", input_id, text }); + +test("queued input survives the first result and completes only with its own native result", async () => { + const inputs = new Inputs("opening text"); + const stream = inputs[Symbol.asyncIterator](); + const first = (await stream.next()).value; + assert.deepEqual(inputs.start("native"), [{ type: "input_ready", session_id: "native" }]); + assert.deepEqual(inputs.submit(steer("second")), []); + // This result raced the SDK's read of the extra local input: native queue count is zero. + assert.deepEqual(inputs.consume({ ...result([first.uuid]), queued_turn_count: 0 }), []); + assert.equal(inputs.complete, false); + assert.deepEqual(inputs.start("native"), []); + assert.throws(() => inputs.start("different"), /identity/); + const second = (await stream.next()).value; + assert.equal(second.session_id, "native"); + assert.equal(second.message.content, "additional text"); + assert.notEqual(second.uuid, first.uuid); + assert.deepEqual(inputs.consume({ type: "assistant", parent_tool_use_id: null, session_id: "native", user_message_uuid: second.uuid }), [{ type: "input_applied", input_id: "second" }]); + assert.equal(inputs.complete, false); + assert.deepEqual(inputs.consume(result([second.uuid])), [{ type: "input_closed", session_id: "native" }]); + assert.equal(inputs.complete, true); + assert.equal((await stream.next()).done, true); + assert.deepEqual(inputs.submit(steer("too-late")), [{ type: "input_rejected", input_id: "too-late" }]); +}); + +test("native folds confirm all consumed UUIDs, never queue/user echoes or unrelated frames", async () => { + const inputs = new Inputs("first"); + const stream = inputs[Symbol.asyncIterator](); + const first = (await stream.next()).value; + inputs.start("native"); inputs.submit(steer("fold")); + const second = (await stream.next()).value; + for (const frame of [ + { type: "command_lifecycle", state: "queued", uuid: second.uuid }, + { type: "user", uuid: second.uuid, isReplay: true }, + { type: "assistant", parent_tool_use_id: "subagent", user_message_uuid: second.uuid }, + { type: "assistant", parent_tool_use_id: null, isSynthetic: true, user_message_uuid: second.uuid }, + { type: "stream_event", parent_tool_use_id: null, user_message_uuid: "unknown" }, + ]) assert.deepEqual(inputs.consume({ session_id: "native", ...frame }), []); + assert.equal(inputs.complete, false); + assert.throws(() => inputs.consume(result([second.uuid], "another-session")), /session/); + assert.throws(() => inputs.consume(result(["unknown"])), /Unattributed/); + assert.deepEqual(inputs.consume(result([first.uuid, second.uuid])), [ + { type: "input_closed", session_id: "native" }, { type: "input_applied", input_id: "fold" }, + ]); + assert.equal((await stream.next()).done, true); +}); + +test("reject before readiness, repeated identities and native receipt capacity without enqueueing", async () => { + const inputs = new Inputs("first"); + assert.deepEqual(inputs.submit(steer("early")), [{ type: "input_rejected", input_id: "early" }]); + inputs.start("native"); + for (let i = 0; i < 63; i++) assert.deepEqual(inputs.submit(steer(String(i))), []); + assert.deepEqual(inputs.submit(steer("0", "changed")), [{ type: "input_rejected", input_id: "0" }]); + assert.deepEqual(inputs.submit(steer("overflow")), [{ type: "input_rejected", input_id: "overflow" }]); + assert.throws(() => inputs.submit({ ...steer("bad"), environment: {} }), /Invalid/); + inputs.close(); + let count = 0; for await (const _ of inputs) count++; + assert.equal(count, 64); + assert.equal(inputs.complete, false); +}); diff --git a/packages/claude-sdk-adapter/tests/mcp.test.mjs b/packages/claude-sdk-adapter/tests/mcp.test.mjs new file mode 100644 index 000000000..279b189d5 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/mcp.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseStart } from "../dist/adapter.js"; +import { MCPProfile } from "../dist/mcp.js"; +import { MCPObserver } from "../dist/mcp_observer.js"; + +const declaration = allowed_tools => ({ server_label: "fixture", server_url: "https://example.invalid/mcp", allowed_tools }); +const native = name => `mcp__fixture__${name}`; +const statuses = [{ name: "fixture", status: "connected", tools: [{ name: "echo" }, { name: "fail" }] }]; +const assistant = (id, name = native("echo"), input = { value: 7 }) => ({ type: "assistant", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id, name, input }] } }); +const user = (id, content, is_error = false) => ({ type: "user", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_result", tool_use_id: id, content, is_error }] } }); + +test("native selection composes unrestricted, selected and empty servers with host functions", () => { + for (const selection of [null, ["echo"], []]) { + const profile = new MCPProfile([declaration(selection)], ["mcp__functions__lookup"]); + const expected = selection === null ? ["echo", "fail"] : selection; + profile.verify([...expected.map(native), "mcp__functions__lookup"], [...statuses, { name: "functions", status: "connected" }], "session"); + assert.deepEqual([...profile.identities.keys()], expected.map(native)); + assert.equal(profile.allowed.includes("mcp__functions__lookup"), true); + assert.deepEqual(profile.denied, selection?.length === 0 ? [native("*")] : []); + assert.throws(() => profile.verify([...expected.map(native), "Bash"], statuses, "session"), /inventory/); + assert.throws(() => profile.verify(expected.map(native), [...statuses, { name: "ambient", status: "connected" }], "session"), /undeclared/); + } +}); + +test("invalid remote declarations and wildcard injection fail at the bridge boundary", () => { + const start = { type: "start", prompt: "hello", model: "model", system_prompt: "", cwd: "/tmp" }; + for (const value of [null, {}, [declaration(["*"])], [{ ...declaration(null), server_label: "functions" }], + [{ ...declaration(null), server_url: "https://user:secret@example.invalid/mcp" }], + [{ ...declaration(null), server_url: "https://example.invalid/mcp?" }], + [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])]]) { + assert.throws(() => parseStart(JSON.stringify({ ...start, mcp_http_servers: value }))); + } + assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, + [declaration(null), { ...declaration([]), server_label: "empty" }]); +}); + +test("native tool spelling preserves original identity and rejects ambiguous aliases", () => { + for (const tools of [null, ["echo.v1"]]) { + const profile = new MCPProfile([declaration(tools)], []); + profile.verify([native("echo_v1")], [{ ...statuses[0], tools: [{ name: "echo.v1" }] }], "session"); + assert.deepEqual(profile.identities.get(native("echo_v1")), { server: "fixture", name: "echo.v1" }); + if (tools) assert.deepEqual(profile.allowed, [native("echo_v1")]); + assert.throws(() => profile.verify([native("echo_v1")], [{ ...statuses[0], tools: [{ name: "echo.v1" }, { name: "echo_v1" }] }], "session"), /ambiguous/); + } +}); + +test("native pre-tool admission waits for verified inventory and denies unsafe or cancelled setup", async () => { + const input = { hook_event_name: "PreToolUse", session_id: "session", tool_name: native("echo"), tool_use_id: "call", tool_input: {} }; + const signal = new AbortController().signal; + const p = new MCPProfile([declaration(["echo"])], []); + assert.deepEqual(p.servers.fixture.headers, { Authorization: "" }); + let released = false; + const pending = p.beforeTool(input, "call", { signal }).then(value => { released = true; return value; }); + await Promise.resolve(); + assert.equal(released, false); + p.verify([native("echo")], statuses, "session"); + assert.deepEqual(await pending, {}); + for (const invalid of [{ ...input, session_id: "other" }, { ...input, agent_id: "child" }, { ...input, tool_name: native("fail") }]) { + assert.equal((await p.beforeTool(invalid, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); + } + p.close(); + assert.equal((await p.beforeTool(input, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); + const rejected = new MCPProfile([declaration(["echo_v1"])], []); + const waiting = rejected.beforeTool({ ...input, tool_name: native("echo_v1") }, "call", { signal }); + // Native status may retain only the first of two normalized aliases. + assert.throws(() => rejected.verify([native("echo_v1")], [{ ...statuses[0], tools: [{ name: "echo.v1" }] }], "session"), /inventory/); + rejected.close(); + assert.equal((await waiting).hookSpecificOutput.permissionDecision, "deny"); + const controller = new AbortController(); + const stopped = new MCPProfile([declaration(null)], []).beforeTool(input, "call", { signal: controller.signal }); + controller.abort(); + assert.equal((await stopped).hookSpecificOutput.permissionDecision, "deny"); +}); + +function observer() { return new MCPObserver(new Map([[native("echo"), { server: "fixture", name: "echo" }]])); } + +test("parallel calls preserve actual native JSON, identity, nulls and error representation", () => { + const o = observer(); + assert.equal(o.consume(assistant("a"), "session")[0].observation.status, "in_progress"); + o.consume(assistant("b"), "session"); + assert.deepEqual(o.consume(assistant("a"), "session"), []); + const failed = { ...user("b", "MCP error", true), tool_use_result: "Error: MCP error" }; + assert.equal(o.consume(failed, "session")[0].observation.error, "Error: MCP error"); + const originalNative = { content: '{"value":7}', structuredContent: { value: 7 } }; + const completed = o.consume({ ...user("a", originalNative.content), tool_use_result: originalNative }, "session")[0]; + assert.deepEqual(completed.observation.output, originalNative); + assert.equal(completed.observation.error, null); + o.assertComplete(); + assert.deepEqual(o.close(), []); + assert.throws(() => o.consume(failed, "session"), /repeated/); +}); + +test("root ownership and exact correlation exclude replay, functions and nested work", () => { + const o = observer(); + for (const message of [{ ...assistant("a"), parent_tool_use_id: "parent" }, { ...assistant("a"), isReplay: true }, + { ...assistant("a"), isSynthetic: true }, assistant("host", "mcp__functions__lookup")]) { + assert.deepEqual(o.consume(message, "session"), []); + } + assert.throws(() => o.consume(assistant("a"), "other"), /session/); + o.consume(assistant("a"), "session"); + assert.throws(() => o.consume(assistant("a", native("echo"), { value: 8 }), "session"), /conflicting/); + assert.deepEqual(o.consume(user("unrelated", "text"), "session"), []); + assert.throws(() => o.assertComplete(), /unconfirmed/); + const events = o.close(); + assert.equal(events.length, 1); + assert.deepEqual(events[0].observation, { kind: "mcp", name: "echo", server: "fixture", arguments: { value: 7 }, status: "incomplete", output: null, error: null }); +}); + +test("batched results use per-call content, never duplicate a whole-message native result", () => { + const o = observer(); + o.consume(assistant("a"), "session"); + o.consume(assistant("b"), "session"); + const first = user("a", []), second = user("b", "failed", true); + first.message.content.push(...second.message.content); + first.tool_use_result = { unassignable: true }; + const events = o.consume(first, "session"); + assert.deepEqual(events.map(e => [e.id, e.observation.output, e.observation.error]), [["a", [], null], ["b", null, "failed"]]); +}); diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs new file mode 100644 index 000000000..e5d65034f --- /dev/null +++ b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseStart } from "../dist/adapter.js"; +import { MCPProfile } from "../dist/mcp.js"; + +const reference = "PARSAR_MCP_BEARER_" + "A".repeat(26); +const server = { server_label: "private", server_url: "https://example.invalid/mcp", allowed_tools: ["echo.v1"], bearer_token_env_var: reference }; +const start = servers => ({ type: "start", prompt: "hello", model: "fixture", system_prompt: "", cwd: "/tmp", mcp_http_servers: servers }); + +test("bearer references remain literal in native configuration and private status is not retained", t => { + const token = "fixture-private-bearer+/=="; + process.env[reference] = token; + t.after(() => { delete process.env[reference]; }); + const declarations = [server, { server_label: "anonymous", server_url: "http://example.invalid/mcp", allowed_tools: [] }]; + const parsed = parseStart(JSON.stringify(start(declarations))); + const profile = new MCPProfile(parsed.mcp_http_servers, []); + assert.equal(profile.servers.private.headers.Authorization, "Bearer ${" + reference + "}"); + assert.equal(profile.servers.anonymous.headers.Authorization, ""); + assert.equal(JSON.stringify(profile.servers).includes(token), false); + profile.verify(["mcp__private__echo_v1"], [ + { name: "private", status: "connected", tools: [{ name: "echo.v1" }], config: { ...profile.servers.private, headers: { Authorization: "Bearer " + token } } }, + { name: "anonymous", status: "connected", tools: [] }, + ], "session"); + assert.deepEqual([...profile.identities.values()], [{ server: "private", name: "echo.v1" }]); + assert.equal(JSON.stringify(profile).includes(token), false); + assert.deepEqual(parsed.mcp_http_servers, declarations); +}); + +test("untrusted header expressions, raw tokens and non-HTTPS authenticated declarations are rejected", () => { + for (const invalid of [ + { ...server, bearer_token: "fixture-secret" }, + { ...server, headers: { Authorization: "Bearer fixture-secret" } }, + { ...server, server_url: "http://example.invalid/mcp" }, + ...[null, "", "ANTHROPIC_AUTH_TOKEN", "PARSAR_MCP_BEARER_", "${OPERATOR_TOKEN}", reference + ":-fallback"].map(bearer_token_env_var => ({ ...server, bearer_token_env_var })), + ]) assert.throws(() => parseStart(JSON.stringify(start([invalid])))); + assert.throws(() => parseStart(JSON.stringify(start([server, { ...server, server_label: "other" }])))); +}); + +test("missing credential environment fails before the native query", () => { + const missing = "PARSAR_MCP_BEARER_" + "B".repeat(26); + delete process.env[missing]; + assert.throws(() => new MCPProfile([{ ...server, bearer_token_env_var: missing }], []), /missing MCP credential environment/); +}); diff --git a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs new file mode 100644 index 000000000..8bf2842df --- /dev/null +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +// Run the production entrypoint: its explicitly supplied Inputs must also be held. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const mode=process.argv[1]; +const sdk='export function startup(args){return globalThis.startup(args);} export function query(args){return globalThis.direct(args);} export async function getSessionInfo(){return process.argv[1]==="missing-history"?undefined:{sessionId:"native"};}'; +registerHooks({resolve(s,c,next){return s==="@anthropic-ai/claude-agent-sdk"?{url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}:next(s,c);}}); +function create(options){ + assert.deepEqual(options.tools,[]);assert.equal(options.strictMcpConfig,true);assert.equal(options.hooks.PreToolUse.length,1); + assert.equal(options.mcpServers.fixture.alwaysLoad,true); + const child=options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:options.env,signal:options.abortController.signal}); + process.send({kind:"spawn"}); + let iterator,pending,readiness=false; + const status=()=>[{name:"fixture",status:"connected",tools:[{name:"echo"}]},{name:"optional",status:"connected",tools:[]}]; + return {close(){child.stdin.end();},query(inputs){ + iterator=inputs[Symbol.asyncIterator]();pending=iterator.next(); + let yielded=false;pending.then(v=>{if(!v.done)yielded=true;}); + return {close(){child.stdin.end();},async initializationResult(){await new Promise(r=>setTimeout(r,20));assert.equal(yielded,false);return {hooks_applied:mode!=="missing-hooks"};}, + async mcpServerStatus(){ + if(!readiness && mode!=="optional"){ + await new Promise(r=>setTimeout(r,30));assert.equal(yielded,false);process.send({kind:"checked_before_input"}); + if(mode==="cancelled")options.abortController.abort(); + readiness=true; + if(mode==="missing")return []; + if(mode==="duplicate")return [...status(),status()[0]]; + if(mode==="pending"||mode==="failed")return [{name:"fixture",status:mode}]; + } + return status(); + }, + async *[Symbol.asyncIterator](){ + const first=await pending;if(first.done)return; + assert.ok(readiness||mode==="optional");process.send({kind:"input",text:first.value.message.content}); + yield {type:"system",subtype:"init",session_id:mode==="wrong-history"?"foreign":"native",tools:["mcp__fixture__echo"],mcp_servers:[]}; + yield {type:"result",uuid:"result",session_id:"native",user_message_uuids:[first.value.uuid],subtype:"success",is_error:false,result:"done",usage:{input_tokens:1,output_tokens:1},modelUsage:{}}; + } + }; + }}; +} +globalThis.startup=async({options})=>{process.send({kind:"warm"});return create(options);}; +globalThis.direct=()=>{throw new Error("MCP must use the shared startup path");}; +await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)}); +process.disconnect(); +`; + +for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "missing-hooks", "cancelled", "resume", "wrong-history", "missing-history", "optional"]) { + test(`required MCP entrypoint holds input through readiness: ${mode}`, async () => { + const cwd = mkdtempSync(join(tmpdir(), "parsar-required-")); + const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + const observations = []; + let stdout = "", stderr = ""; + child.stdout.on("data", b => { stdout += b; }); + child.stderr.on("data", b => { stderr += b; }); + child.on("message", value => observations.push(value)); + const closed = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); + const timer = setTimeout(() => child.kill("SIGKILL"), 8000); + try { + child.stdin.write(JSON.stringify({ type: "start", model: "fixed", prompt: "one input", system_prompt: "", cwd, + ...(mode.includes("history") || mode === "resume" ? { resume: "native" } : {}), + mcp_http_servers: [{ server_label: "fixture", server_url: "https://example.invalid/mcp", allowed_tools: ["echo"], required: mode !== "optional" }, + { server_label: "optional", server_url: "https://optional.invalid/mcp", allowed_tools: [], required: false }] }) + "\n"); + const exit = await closed; + assert.equal(exit.signal, null, stderr); + assert.equal(exit.code, 0, stderr); + const events = stdout.trim().split("\n").map(JSON.parse); + const success = ["connected", "resume", "optional"].includes(mode); + assert.equal(events.at(-1).type, success ? "result" : "error"); + assert.equal(observations.filter(v => v.kind === "input").length, success || mode === "wrong-history" ? 1 : 0); + assert.equal(events.some(e => e.type === "prepared"), false); + assert.equal(events.some(e => e.type === "input_ready"), success); + if (mode === "missing-history") assert.equal(observations.length, 0); + if (mode === "cancelled") assert.equal(events.at(-1).code, "cancelled"); + } finally { + clearTimeout(timer);child.kill("SIGKILL");await closed;rmSync(cwd, { recursive: true, force: true }); + } + }); +} diff --git a/packages/claude-sdk-adapter/tests/messages.test.mjs b/packages/claude-sdk-adapter/tests/messages.test.mjs new file mode 100644 index 000000000..c8b3fb5b3 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/messages.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { MessageObserver } from "../dist/messages.js"; +import { parseStart } from "../dist/adapter.js"; + +const stream = (event, parent = null) => ({ + type: "stream_event", uuid: randomUUID(), session_id: "session", parent_tool_use_id: parent, event, +}); +const start = id => stream({ type: "message_start", message: { id } }); +const block = (index, text = "") => stream({ type: "content_block_start", index, content_block: { type: "text", text } }); +const delta = (index, text) => stream({ type: "content_block_delta", index, delta: { type: "text_delta", text } }); +const blockStop = index => stream({ type: "content_block_stop", index }); +const stop = () => stream({ type: "message_stop" }); +const snapshot = (id, text, parent = null) => ({ + type: "assistant", uuid: randomUUID(), session_id: "session", parent_tool_use_id: parent, + message: { id, content: [{ type: "text", text }] }, +}); + +test("multiple native messages retain identity, incremental fragments and authoritative block snapshots", () => { + const observer = new MessageObserver(); + const output = []; + const consume = message => output.push(...observer.consume(message)); + consume(start("native-1")); + consume(block(0)); + consume(delta(0, "go ")); + consume(delta(0, "go ")); + consume(snapshot("native-1", "GO go ")); + consume(blockStop(0)); + assert.equal(output.filter(event => event.message?.status === "completed").length, 0); + consume(block(1)); + consume(delta(1, "尾")); + consume(snapshot("native-1", "尾")); + consume(blockStop(1)); + consume(stop()); + consume(start("native-2")); + consume(block(0, "next")); + consume(snapshot("native-2", "next")); + consume(blockStop(0)); + consume(stop()); + assert.deepEqual(output, [ + { type: "output_message", message: { id: "native-1", status: "in_progress" } }, + { type: "delta", item_id: "native-1", delta: "go " }, + { type: "delta", item_id: "native-1", delta: "go " }, + { type: "delta", item_id: "native-1", delta: "尾" }, + { type: "output_message", message: { id: "native-1", status: "completed", text: "GO go 尾" } }, + { type: "output_message", message: { id: "native-2", status: "in_progress" } }, + { type: "delta", item_id: "native-2", delta: "next" }, + { type: "output_message", message: { id: "native-2", status: "completed", text: "next" } }, + ]); +}); + +test("thinking, tools and nested assistant output do not create text messages", () => { + const observer = new MessageObserver(); + for (const type of ["thinking", "tool_use"]) { + assert.deepEqual(observer.consume(start(type)), []); + assert.deepEqual(observer.consume(stream({ type: "content_block_start", index: 0, content_block: { type } })), []); + assert.deepEqual(observer.consume({ ...snapshot(type, ""), message: { id: type, content: [{ type }] } }), []); + assert.deepEqual(observer.consume(blockStop(0)), []); + assert.deepEqual(observer.consume(stop()), []); + } + assert.deepEqual(observer.consume(stream({ type: "message_start", message: { id: "nested" } }, "tool-parent")), []); + assert.deepEqual(observer.consume(snapshot("nested", "hidden", "tool-parent")), []); + observer.consume(start("empty")); + assert.deepEqual(observer.consume(block(0)), [ + { type: "output_message", message: { id: "empty", status: "in_progress" } }, + ]); + observer.consume(blockStop(0)); + assert.deepEqual(observer.consume(stop()), [ + { type: "output_message", message: { id: "empty", status: "completed", text: "" } }, + ]); +}); + +test("interrupted or failed messages never become completed from a result or error snapshot", () => { + const observer = new MessageObserver(); + const output = [start("partial"), block(0), delta(0, "unfinished"), + { type: "result", subtype: "error_during_execution" }, + ].flatMap(message => observer.consume(message)); + assert.deepEqual(output, [ + { type: "output_message", message: { id: "partial", status: "in_progress" } }, + { type: "delta", item_id: "partial", delta: "unfinished" }, + ]); + assert.deepEqual(observer.consume({ ...snapshot("partial", "provider error"), error: "server_error" }), []); + assert.deepEqual(observer.consume(stop()), []); +}); + +test("unmatched text cannot acquire an invented identity", () => { + const observer = new MessageObserver(); + assert.throws(() => observer.consume(delta(0, "missing")), /start is missing/); + observer.consume(start("native")); + observer.consume(block(0)); + assert.throws(() => observer.consume(snapshot("other", "wrong")), /unmatched/); +}); + +test("observation opt-in is an optional boolean", () => { + const request = { type: "start", prompt: "hello", model: "model", system_prompt: "", cwd: "/tmp" }; + assert.equal(parseStart(JSON.stringify(request)).observe_messages, undefined); + assert.equal(parseStart(JSON.stringify({ ...request, observe_messages: true })).observe_messages, true); + assert.throws(() => parseStart(JSON.stringify({ ...request, observe_messages: "true" })), /invalid_request/); +}); diff --git a/packages/claude-sdk-adapter/tests/native.test.mjs b/packages/claude-sdk-adapter/tests/native.test.mjs new file mode 100644 index 000000000..f92755a37 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/native.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import test from "node:test"; +import { spawnNative } from "../dist/native.js"; + +test("native stderr cannot block stdout or process release", { timeout: 10000 }, async () => { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 3000); + const child = spawnNative({ + command: process.execPath, + args: ["-e", `process.stderr.write(Buffer.alloc(2 * 1024 * 1024, "x"), () => { + process.stdout.write("released"); + });`], + env: process.env, + signal: abort.signal, + }); + child.stdin.end(); + let output = ""; + child.stdout.on("data", data => { output += data; }); + try { + const [code, signal] = await once(child, "close"); + assert.equal(code, 0); + assert.equal(signal, null); + assert.equal(output, "released"); + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill("SIGKILL"); + } +}); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs new file mode 100644 index 000000000..7de7c4847 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parseRequest, preparedPrompt } from "../dist/request.js"; + +// Exercise the packaged entrypoint and real child ownership with a controlled SDK. +// Native dependency enforcement and model effects need separate native acceptance. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const mode = process.argv[1]; +const sdk = 'export function startup(args){return globalThis.startupFixture(args);} export function query(){throw new Error("Direct query used for preparation");} export async function getSessionInfo(){process.send({kind:"history"});return process.argv[1] === "missing-history" ? undefined : {sessionId:"native"};}'; +registerHooks({ resolve(specifier, context, next) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; + return next(specifier,context); +}}); +globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { + assert.equal(initializeTimeoutMs,15000); + assert.equal(options.model,"fixed-model"); + assert.equal(options.env.UNSELECTED_CANARY,undefined); + assert.equal(options.env.HOME,process.env.HOME); + assert.deepEqual(options.tools,["Bash","Read","Edit"]); + assert.equal(options.sandbox.failIfUnavailable,true); + assert.equal(options.hooks.PreToolUse.length,1); + const child = options.spawnClaudeCodeProcess({command:process.execPath,env:options.env,signal:options.abortController.signal, + args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"]}); + process.send({kind:"spawn",pid:child.pid}); + const closed = new Promise(resolve=>child.once("close",()=>{process.send({kind:"native_close"});resolve();})); + const close = () => child.stdin.end(); + await new Promise(resolve=>setTimeout(resolve,mode === "slow-startup" ? 200 : 30)); + if(mode === "startup-error") {close();await closed;throw new Error("Private native diagnostics");} + let consumed=false; + return { + close(){if(!consumed)close();}, + query(prompt){ + assert.equal(consumed,false);consumed=true; + if(mode === "native-exit")setTimeout(()=>child.kill("SIGTERM"),100); + return { + close, + async readFile(path,{maxBytes,encoding}) { + assert.equal(encoding,"base64"); + assert.equal(path,options.cwd+"/binary"); + process.send({kind:"read",path,maxBytes}); + await new Promise(resolve=>setTimeout(resolve,80)); + return {absPath:path,encoding,contents:Buffer.from([0,255,128,1]).toString("base64")}; + }, + async initializationResult(){return {hooks_applied:mode !== "missing-hooks"};}, + async *[Symbol.asyncIterator](){ + const command={type:"assistant",session_id:"native",parent_tool_use_id:null, + message:{content:[{type:"tool_use",id:"command",name:"Bash",input:{command:"printf 'prepared'"}}]}}; + if(mode === "command-before-input")yield command; + const iterator=prompt[Symbol.asyncIterator](); + const first=await Promise.race([iterator.next(),closed.then(()=>({done:true}))]); + if(first.done)return; + process.send({kind:"input",text:first.value.message.content}); + yield {type:"system",subtype:"init",session_id:mode === "resume-mismatch"?"other":"native",mcp_servers:[], + tools:mode === "bad-inventory"?["Bash","Read","Edit","Agent"]:["Bash","Read","Edit"]}; + const ids=[first.value.uuid]; + if(mode === "commands") { + yield command; + yield {type:"user",session_id:"native",parent_tool_use_id:null, + message:{content:[{type:"tool_result",tool_use_id:"command",content:"prepared",is_error:false}]}}; + } + if(mode === "steer") { + const second=await Promise.race([iterator.next(),closed.then(()=>({done:true}))]); + if(second.done)return; + ids.push(second.value.uuid); + } + await new Promise(resolve=>setTimeout(resolve,80)); + if(options.abortController.signal.aborted)return; + yield {type:"result",uuid:"result",session_id:"native",user_message_uuids:ids,subtype:"success",is_error:false, + result:"answer",usage:{input_tokens:1,output_tokens:1},modelUsage:{}}; + } + }; + } + }; +}; +await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)}); +process.disconnect(); +`; + +function placement() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "parsar-prepare-"))); + const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps"].map(name => { + const path = join(root, name); mkdirSync(path); return [name, path]; + })); + return { root, request: { type: "prepare", model: "fixed-model", system_prompt: "", cwd: dirs.workspace, + workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, dependency_path: dirs.deps, protected_dirs: [], env_names: [] } } }; +} + +test("prepare validates a workspace-only immutable configuration and prompt-only Start", () => { + const { root, request } = placement(); + try { + assert.deepEqual(parseRequest(JSON.stringify(request)), request); + assert.deepEqual(parseRequest(JSON.stringify({ ...request, functions: [] })), { ...request, functions: [] }); + for (const fields of [{ prompt: "" }, { prompt: "input" }, { workspace: undefined }, + { mcp_http_servers: [] }, { env: {} }, { resume: "" }, { type: "prepared" }]) { + assert.throws(() => parseRequest(JSON.stringify({ ...request, ...fields })), /invalid_request/); + } + assert.equal(preparedPrompt({ type: "start", prompt: "first" }), "first"); + for (const value of [{ type: "start", prompt: "" }, { type: "start", prompt: "first", model: "other" }, + { type: "start", prompt: "first", resume: "other" }, { type: "start", prompt: "first", workspace: request.workspace }, + { type: "steer", text: "first" }, null]) assert.throws(() => preparedPrompt(value), /invalid_request/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +async function launch(t, mode) { + const { root, request } = placement(); + const env = { ...process.env, HOME: request.workspace.home, CLAUDE_CONFIG_DIR: request.workspace.state, UNSELECTED_CANARY: "must-not-inherit" }; + delete env.CLAUDE_CODE_PROJECT_DIR_NAME; + const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { env, stdio: ["pipe", "pipe", "pipe", "ipc"] }); + const events = [], observations = []; + let stderr = "", buffer = "", notify = () => {}; + child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); + child.stdout.on("data", value => { + buffer += value; + while (buffer.includes("\n")) { + const end = buffer.indexOf("\n"); events.push(JSON.parse(buffer.slice(0, end))); buffer = buffer.slice(end + 1); + } + notify(); + }); + child.stderr.on("data", value => { stderr += value; }); + child.on("message", value => { observations.push(value); notify(); }); + const closed = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); + t.after(async () => { child.kill("SIGKILL"); await closed; rmSync(root, { recursive: true, force: true }); }); + const wait = async predicate => { + if (predicate()) return; + let timer; + try { + await new Promise((resolve, reject) => { + notify = () => { if (predicate()) resolve(); }; + timer = setTimeout(() => reject(new Error("Bridge observation timeout: " + stderr)), 5000); + }); + } finally { clearTimeout(timer); notify = () => {}; } + }; + const send = value => child.stdin.write(JSON.stringify(value) + "\n"); + const finish = async code => { + await wait(() => events.some(event => event.type === "result" || event.type === "error")); + assert.deepEqual(await closed, { code: 0, signal: null }, stderr); + assert.equal(events.filter(event => event.type === "error" || event.type === "result").length, 1); + if (code) assert.deepEqual(events.at(-1), { type: "error", code }); + else assert.equal(events.at(-1).type, "result"); + assert.equal(observations.filter(value => value.kind === "native_close").length, observations.filter(value => value.kind === "spawn").length); + }; + if (["missing-history", "resume", "resume-mismatch"].includes(mode)) request.resume = "native"; + return { child, request, events, observations, send, wait, finish }; +} + +for (const mode of ["release", "resume", "steer", "commands", "unused", "owner-cancel", "native-exit", "duplicate", "replacement", "early-steer", "bad-inventory", "resume-mismatch"]) { + test(`prepared entrypoint lifecycle: ${mode}`, { timeout: 10000 }, async t => { + const bridge = await launch(t, mode); + const { child, request, events, observations, send, wait, finish } = bridge; + send(request); + await wait(() => events.some(event => event.type === "prepared")); + await new Promise(resolve => setTimeout(resolve, 30)); + assert.deepEqual(events, [{ type: "prepared" }]); + assert.equal(observations.filter(value => value.kind === "spawn").length, 1); + assert.equal(observations.some(value => value.kind === "input"), false); + if (mode === "unused") { child.stdin.end(); await finish("cancelled"); } + else if (mode === "owner-cancel") { child.kill("SIGTERM"); await finish("cancelled"); } + else if (mode === "native-exit") await finish("execution_failed"); + else if (mode === "replacement") { send({ type: "start", prompt: "first", model: "changed" }); await finish("invalid_request"); } + else if (mode === "early-steer") { send({ type: "steer", input_id: "early", text: "first" }); await finish("invalid_request"); } + else { + send({ type: "start", prompt: "first" }); + if (mode === "duplicate") { send({ type: "start", prompt: "second" }); await finish("invalid_request"); } + else if (["bad-inventory", "resume-mismatch"].includes(mode)) { + await finish("execution_failed"); + assert.equal(events.some(event => event.type === "input_ready"), false); + } else { + await wait(() => events.some(event => event.type === "input_ready")); + if (mode === "steer") send({ type: "steer", input_id: "extra", text: "second" }); + await finish(); + assert.deepEqual(observations.filter(value => value.kind === "input"), [{ kind: "input", text: "first" }]); + if (mode === "steer") assert.ok(events.some(event => event.type === "input_applied" && event.input_id === "extra")); + if (mode === "resume") assert.equal(observations[0].kind, "history"); + if (mode === "commands") assert.deepEqual(events.filter(event=>event.type === "command_observation").map(event=> + [event.session_id,event.id,event.stage,event.observation.output]), + [["native","command","before",undefined],["native","command","after","prepared"]]); + } + } + }); +} + +test("preparation cannot observe commands before actual input", { timeout: 10000 }, async t => { + const { request, events, observations, send, finish } = await launch(t, "command-before-input"); + send(request); + await finish("execution_failed"); + assert.equal(events.some(event=>event.type === "command_observation"),false); + assert.equal(observations.some(value=>value.kind === "input"),false); +}); + +for (const mode of ["missing-history", "missing-hooks", "startup-error", "early-start", "cancel-startup"]) { + test(`preparation rejects before receipt: ${mode}`, { timeout: 10000 }, async t => { + const { child, request, events, observations, send, wait, finish } = await launch(t, mode === "cancel-startup" ? "slow-startup" : mode); + send(request); + if (mode === "early-start") send({ type: "start", prompt: "too early" }); + if (mode === "cancel-startup") { await wait(() => observations.some(value => value.kind === "spawn")); child.kill("SIGTERM"); } + await finish(mode === "missing-history" ? "history_unavailable" : mode === "early-start" ? "invalid_request" : mode === "cancel-startup" ? "cancelled" : "execution_failed"); + assert.equal(events.some(event => event.type === "prepared"), false); + assert.equal(observations.some(value => value.kind === "input"), false); + if (mode === "missing-history") assert.equal(observations.some(value => value.kind === "spawn"), false); + }); +} + + +test("prepared native reader stays on the same query across Start", { timeout: 10000 }, async t => { + const { request, events, observations, send, wait, finish } = await launch(t, "release"); + send(request); await wait(() => events.some(event => event.type === "prepared")); + send({type:"workspace_read",id:"before",path:"binary",max_bytes:4}); + await wait(()=>observations.some(value=>value.kind === "read")); + send({type:"start",prompt:"first"}); + await wait(()=>events.some(event=>event.type === "workspace_read")); + assert.deepEqual(events.find(event=>event.type === "workspace_read"),{type:"workspace_read",id:"before",data_base64:"AP+AAQ==",truncated:false}); + await finish(); + assert.equal(observations.filter(value=>value.kind === "spawn").length,1); +}); diff --git a/packages/claude-sdk-adapter/tests/recovery.test.mjs b/packages/claude-sdk-adapter/tests/recovery.test.mjs new file mode 100644 index 000000000..91fc85857 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/recovery.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +// Controlled history inventories test recovery admission, not native execution. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const mode=process.argv[1]; +const source='export async function listSessions(o){return globalThis.inventory(o);} export async function getSessionInfo(id,o){return globalThis.info(id,o);} export async function getSessionMessages(id,o){return globalThis.messages(id,o);}'; +registerHooks({resolve(s,c,next){return s==='@anthropic-ai/claude-agent-sdk'?{url:'data:text/javascript,'+encodeURIComponent(source),shortCircuit:true}:next(s,c);}}); +let reads=0; +globalThis.inventory=o=>{assert.deepEqual(o,{dir:'/workspace',includeWorktrees:false,limit:2});return mode==='empty'?[]:mode==='ambiguous'?[{sessionId:'a',cwd:'/workspace'},{sessionId:'b',cwd:'/workspace'}]:[{sessionId:'a',cwd:mode==='foreign'?'/other':'/workspace'}];}; +globalThis.info=(id,o)=>{reads++;assert.equal(id,'a');assert.deepEqual(o,{dir:'/workspace'});return mode==='missing'?undefined:{sessionId:'a',cwd:mode==='changed'?'/other':'/workspace'};}; +globalThis.messages=(id,o)=>{assert.equal(id,'a');assert.deepEqual(o,{dir:'/workspace',limit:1});return mode==='metadata-only'?[]:[{type:'user'}];}; +const {recoverSession}=await import('./dist/recovery.js'); +assert.equal(await recoverSession('/workspace'),mode==='valid'?'a':undefined); +if(['empty','ambiguous','foreign'].includes(mode))assert.equal(reads,0); +`; +for (const mode of ["empty", "ambiguous", "foreign", "missing", "changed", "metadata-only", "valid"]) { + test(`recovery inventory: ${mode}`, () => { + const child=spawnSync(process.execPath,["--input-type=module","-e",fixture,mode],{encoding:"utf8",timeout:5000}); + assert.equal(child.status,0,child.stderr||child.error?.message); + }); +} diff --git a/packages/claude-sdk-adapter/tests/usage.test.mjs b/packages/claude-sdk-adapter/tests/usage.test.mjs new file mode 100644 index 000000000..39e65a059 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/usage.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resultUsage } from "../dist/usage.js"; + +test("preserve separate SDK scopes and price provenance without recalculating", () => { + const first = { type: "result", subtype: "success", is_error: false, + usage: { input_tokens: 15, output_tokens: 8, cache_read_input_tokens: 30, cache_creation_input_tokens: 7 }, + modelUsage: { primary: { inputTokens: 15, outputTokens: 8, cacheReadInputTokens: 30, thinkingTokens: 3, costUSD: 0.03, costBasis: "unknown" }, + helper: { inputTokens: 100, outputTokens: 9, costUSD: 0.07, provider: "gateway" } }, + total_cost_usd: 0.1, result: "private response", session_id: "native" }; + const snapshot = resultUsage(first); + assert.deepEqual(snapshot, { usage: first.usage, modelUsage: first.modelUsage, total_cost_usd: 0.1, subtype: "success", is_error: false }); + first.modelUsage.primary.inputTokens = 900; + assert.equal(snapshot.modelUsage.primary.inputTokens, 15); + assert.equal(Object.hasOwn(snapshot.modelUsage.helper, "thinkingTokens"), false); + const resumed = resultUsage({ ...first, usage: { input_tokens: 2, output_tokens: 1 }, modelUsage: {}, total_cost_usd: 0.01 }); + assert.equal(resumed.usage.input_tokens, 2); + assert.equal(resumed.total_cost_usd, 0.01); +}); + +test("reported error usage survives and missing fields are not manufactured", () => { + const error = { subtype: "error_during_execution", is_error: true, total_cost_usd: 0.02, + usage: { input_tokens: 12, output_tokens: 4 }, modelUsage: {} }; + assert.deepEqual(resultUsage(error), error); + const missing = JSON.parse(JSON.stringify(resultUsage({ subtype: "error_during_execution", is_error: true }))); + assert.deepEqual(missing, { subtype: "error_during_execution", is_error: true }); +}); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs new file mode 100644 index 000000000..d070c4898 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parseStart } from "../dist/adapter.js"; +import { parseWorkspace, WorkspaceProfile } from "../dist/workspace.js"; + +function fixture(t) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "parsar-workspace-"))); + const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "protected", "deps"].map(name => { + const path = join(root, name); + mkdirSync(path); + return [name, path]; + })); + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.protected], + dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; + const request = { type: "start", prompt: "fixture", model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace: config }; + const previous = process.env; + process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_API_KEY: "fixture-secret", + HTTP_PROXY: "http://fixture-proxy", UNSELECTED_CANARY: "must-not-inherit", NODE_OPTIONS: "unsafe", + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "0", ANTHROPIC_AUTH_TOKEN: "not-selected" }; + t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); + return { root, dirs, config, request }; +} + +test("workspace is explicit, typed and rejects external MCP declarations", t => { + const { config, request } = fixture(t); + assert.deepEqual(parseStart(JSON.stringify(request)), request); + for (const fields of [{ mcp_http_servers: [] }, { functions: null }, { mcp_http_servers: null }]) { + assert.throws(() => parseStart(JSON.stringify({ ...request, ...fields })), /invalid_request/); + } + for (const workspace of [null, [], {}, { ...config, native: {} }, { ...config, env: {} }, + { ...config, env_names: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"] }, + { ...config, env_names: ["NODE_OPTIONS"] }, { ...config, env_names: ["HOME"] }]) { + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace })), /invalid_request/); + } + const { workspace, ...ordinary } = request; + assert.deepEqual(parseStart(JSON.stringify(ordinary)), ordinary); + assert.equal(parseWorkspace(undefined, ordinary.cwd), undefined); + assert.deepEqual(parseStart(JSON.stringify({ ...ordinary, functions: [], mcp_http_servers: [] })), + { ...ordinary, functions: [], mcp_http_servers: [] }); +}); + +test("workspace roots are existing canonical directories with no overlap or rule syntax", t => { + const { root, dirs, config } = fixture(t); + const alias = join(root, "alias"); + symlinkSync(dirs.home, alias); + const child = join(dirs.workspace, "child"); + mkdirSync(child); + const file = join(root, "file"); + writeFileSync(file, "fixture"); + for (const home of ["relative", "/", dirs.home + "/", dirs.home + "/../home", join(root, "missing"), + dirs.workspace, child, root, alias, file, dirs.home + "*", dirs.home + "\n"]) { + assert.throws(() => parseWorkspace({ ...config, home }, dirs.workspace), /invalid_request/); + } + assert.throws(() => parseWorkspace({ ...config, protected_dirs: [dirs.protected, dirs.protected] }, dirs.workspace), /invalid_request/); + for (const path of ["", ":" + dirs.deps, dirs.deps + ":", "relative", dirs.home, child, root]) { + assert.throws(() => parseWorkspace({ ...config, dependency_path: path }, dirs.workspace), /invalid_request/); + } + const depsAlias = join(root, "deps-alias"); + symlinkSync(dirs.deps, depsAlias); + assert.equal(parseWorkspace({ ...config, dependency_path: depsAlias }, dirs.workspace).dependency_path, dirs.deps); + assert.equal(new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: depsAlias }).options.env.PATH, dirs.deps); + for (const mutable of [dirs.workspace, dirs.scratch]) { + const unsafeAlias = join(mutable, "deps-alias"); + symlinkSync(dirs.deps, unsafeAlias); + assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: unsafeAlias }), /invalid_request/); + } + assert.throws(() => parseWorkspace({ ...config, dependency_path: alias }, dirs.workspace), /invalid_request/); +}); + +test("workspace environment copies only selected refs and fixed values", t => { + const { dirs, config } = fixture(t); + const options = new WorkspaceProfile(dirs.workspace, config).options; + assert.deepEqual(options.env, { + PATH: dirs.deps, HOME: dirs.home, TMPDIR: dirs.scratch, CLAUDE_CONFIG_DIR: dirs.state, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", + DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + ANTHROPIC_API_KEY: "fixture-secret", HTTP_PROXY: "http://fixture-proxy", + }); + assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "ANTHROPIC_AUTH_TOKEN" && entry.mode === "deny")); + assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "HTTP_PROXY" && entry.mode === "deny")); + delete process.env.ANTHROPIC_API_KEY; + assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); + process.env.ANTHROPIC_API_KEY = "fixture-secret"; + for (const key of ["HOME", "CLAUDE_CONFIG_DIR"]) { + const previous = process.env[key]; + process.env[key] = "/incorrect"; + assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); + process.env[key] = previous; + } + process.env.CLAUDE_CODE_PROJECT_DIR_NAME = "ambient-history-override"; + assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); +}); + +test("workspace native options keep the strict sandbox and exact native inventory", t => { + const { dirs, config } = fixture(t); + const profile = new WorkspaceProfile(dirs.workspace, config); + const options = profile.options; + assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); + assert.deepEqual(options.allowedTools, []); + assert.deepEqual(options.settingSources, []); + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.permissionMode, "default"); + assert.equal(options.persistSession, true); + assert.equal(options.sandbox.enabled, true); + assert.equal(options.sandbox.failIfUnavailable, true); + for (const key of ["autoAllowBashIfSandboxed", "allowUnsandboxedCommands", "enableWeakerNestedSandbox", "enableWeakerNetworkIsolation"]) { + assert.equal(options.sandbox[key], false); + } + assert.deepEqual(options.sandbox.excludedCommands, []); + assert.deepEqual(options.sandbox.filesystem, { disabled: false, allowWrite: [dirs.workspace, dirs.scratch], + denyRead: [dirs.home, dirs.state, dirs.protected], denyWrite: [dirs.home, dirs.state, dirs.protected], allowRead: [] }); + assert.deepEqual(options.sandbox.network, + { allowedDomains: [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); + assert.equal(options.settings.permissions.blockReadsOutsideWorkingDirectories, true); + assert.equal(options.settings.permissions.disableBypassPermissionsMode, "disable"); + for (const path of [dirs.home, dirs.state, dirs.protected, "/proc", "/sys"]) { + assert.ok(options.settings.permissions.deny.includes(`Read(/${path}/**)`)); + assert.ok(options.settings.permissions.deny.includes(`Edit(/${path}/**)`)); + } + profile.verify(["Read", "Edit", "Bash"], []); + for (const tools of [[], ["Bash", "Read", "Read"], ["Bash", "Read", "Write"], ["Bash", "Read", "Edit", "Agent"]]) { + assert.throws(() => profile.verify(tools, []), /unexpected native workspace inventory/); + } + assert.throws(() => profile.verify(options.tools, [{ name: "untrusted", status: "connected" }]), /unexpected native workspace inventory/); +}); + +test("workspace permissions and pre-tool hook reject outside paths and unsafe Bash flags", async t => { + const { dirs, config } = fixture(t); + writeFileSync(join(dirs.workspace, "file.txt"), "fixture"); + symlinkSync(dirs.protected, join(dirs.workspace, "escape")); + symlinkSync(join(dirs.protected, "missing"), join(dirs.workspace, "dangling")); + symlinkSync(join(dirs.workspace, "file.txt"), join(dirs.workspace, "inside")); + const profile = new WorkspaceProfile(dirs.workspace, config); + const options = { signal: new AbortController().signal, toolUseID: "tool", requestId: "request" }; + const hook = async (name, input) => profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "native", cwd: dirs.workspace, + transcript_path: join(dirs.state, "session"), tool_name: name, tool_input: input, tool_use_id: "tool" }, "tool", options); + for (const [name, input] of [["Bash", { command: "printf value" }], ["Read", { file_path: "file.txt" }], + ["Read", { file_path: "inside" }], ["Edit", { file_path: "new/file.txt", old_string: "", new_string: "value" }]]) { + const permission = await profile.canUseTool(name, input, options); + assert.equal(permission.behavior, "allow"); + if (name === "Bash") assert.deepEqual(await hook(name, input), {}); + else { + assert.equal(permission.updatedInput.file_path, join(dirs.workspace, input.file_path)); + assert.deepEqual((await hook(name, input)).hookSpecificOutput.updatedInput, permission.updatedInput); + } + } + for (const [name, input] of [["Bash", { command: "true", run_in_background: true }], + ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Bash", { command: "true", run_in_background: "false" }], + ["Write", { file_path: "file.txt" }], ["Read", { file_path: "../protected/value" }], + ["Read", { file_path: join(dirs.home, "credentials") }], ["Edit", { file_path: "escape/new" }], + ["Read", { file_path: "dangling" }], ["Edit", { file_path: "dangling/new" }]]) { + assert.equal((await profile.canUseTool(name, input, options)).behavior, "deny"); + assert.equal((await hook(name, input)).hookSpecificOutput.permissionDecision, "deny"); + } + assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, agentID: "child" })).behavior, "deny"); + assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, signal: AbortSignal.abort() })).behavior, "deny"); +}); + +test("dedicated Runtime carries an explicit native network policy", t => { + const { dirs, config, request } = fixture(t); + for (const network_access of ["enabled", "disabled"]) { + const workspace = { ...config, network_access }; + const parsed = parseStart(JSON.stringify({ ...request, workspace, require_history: true })); + assert.equal(parsed.require_history, true); + const options = new WorkspaceProfile(dirs.workspace, workspace).options; + assert.deepEqual(options.sandbox.network.allowedDomains, network_access === "enabled" ? ["*"] : []); + assert.equal(options.sandbox.enableWeakerNestedSandbox, false); + assert.equal(options.sandbox.allowUnsandboxedCommands, false); + } + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...config, network_access: "restricted" } })), /invalid_request/); + const { workspace, ...none } = request; + assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); +}); + +test("workspace functions retain native sandbox and exact tool authority", async t => { + const { dirs, config, request } = fixture(t); + const functions = [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }]; + assert.deepEqual(parseStart(JSON.stringify({ ...request, functions })).functions, functions); + const profile = new WorkspaceProfile(dirs.workspace, config, ["mcp__functions__lookup"]); + profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], [{ name: "functions", status: "connected" }]); + for (const servers of [[], [{ name: "functions", status: "failed" }], [{ name: "external", status: "connected" }]]) { + assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], servers)); + } + assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__unknown"], [{ name: "functions", status: "connected" }])); + const controller = new AbortController(); + const input = { id: "fixture" }; + const options = { signal: controller.signal }; + assert.deepEqual(await profile.canUseTool("mcp__functions__lookup", input, options), { behavior: "allow", updatedInput: input }); + assert.equal((await profile.canUseTool("mcp__functions__unknown", input, options)).behavior, "deny"); + assert.equal((await profile.canUseTool("mcp__functions__lookup", input, { ...options, agentID: "child" })).behavior, "deny"); + const event = { hook_event_name: "PreToolUse", tool_name: "mcp__functions__lookup", tool_input: input, tool_use_id: "call" }; + assert.deepEqual(await profile.beforeTool(event, "call", options), {}); + assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); + assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "deny"); + assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "deny"); + assert.equal(profile.options.sandbox.failIfUnavailable, true); + assert.equal(profile.options.sandbox.allowUnsandboxedCommands, false); + controller.abort(); + assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); +}); + +test("initialized user env is applied inside native Bash, never SDK spawn env", async t => { + const { dirs, config } = fixture(t); + const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true }); + assert.equal(profile.options.env.PYTHONPATH, undefined); + assert.equal(profile.options.env.PATH, dirs.deps); + assert.ok(profile.options.sandbox.filesystem.allowWrite.includes("/environment/packages")); + const input = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_use_id: "tool", + tool_input: { command: "printf '%s' 'quoted value'", timeout: 1000 } }; + const result = await profile.beforeTool(input, "tool", { signal: new AbortController().signal }); + assert.equal(result.hookSpecificOutput.updatedInput.timeout, 1000); + assert.equal(result.hookSpecificOutput.updatedInput.command, + ". /environment/initialization/tool-env.sh && eval -- 'printf '\\''%s'\\'' '\\''quoted value'\\'''" ); + const denied = await profile.beforeTool({ ...input, tool_input: { command: "id", dangerouslyDisableSandbox: true } }, "tool", { signal: new AbortController().signal }); + assert.equal(denied.hookSpecificOutput.permissionDecision, "deny"); +}); + +test("installed system tools use the full native shell prefix and existing scratch", t => { + const { dirs, config } = fixture(t); + assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, system_packages: true }), /invalid_request/); + const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true, system_packages: true }); + assert.equal(profile.options.env.CLAUDE_CODE_SHELL_PREFIX, "/usr/local/bin/agents-api-tool-root"); + assert.equal(profile.options.env.PARSAR_RUNTIME_TOOL_SCRATCH, dirs.scratch); + assert.equal(profile.options.env.TMPDIR, dirs.scratch); + assert.ok(profile.options.sandbox.filesystem.denyWrite.includes("/environment/packages/system")); + assert.equal(profile.options.env.PYTHONPATH, undefined); + assert.equal(profile.options.sandbox.failIfUnavailable, true); +}); diff --git a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs new file mode 100644 index 000000000..5e5065883 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, symlink, rm, rename, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { WorkspaceDirectories } from "../dist/workspace_directories.js"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "parsar-directories-")); + const workspace = join(root, "workspace"); + await mkdir(workspace); + const abort = new AbortController(); + let receipt; + const directories = new WorkspaceDirectories(event => { receipt?.(event); return Promise.resolve(); }, abort); + await directories.bind(workspace); + t.after(async () => { await directories.close(); await rm(root, { recursive: true, force: true }); }); + let sequence = 0; + return { root, workspace, directories, abort, list: async (directory = "", max_entries = 1000) => { + const result = new Promise(resolve => { receipt = resolve; }); + directories.submit({ type: "workspace_directory", id: `list-${++sequence}`, directory, max_entries }); + const value = await result; + await new Promise(resolve => setImmediate(resolve)); + return value; + } }; +} + +test("bounded literal metadata, missing directory, and traversal denial", { skip: process.platform !== "linux" }, async t => { + const f = await fixture(t); + await mkdir(join(f.workspace, "nested")); + await writeFile(join(f.workspace, "binary"), Buffer.from([0, 255, 0])); + await writeFile(join(f.workspace, "empty"), ""); + await writeFile(join(f.root, "protected"), "secret"); + await symlink(f.root, join(f.workspace, "outside")); + await symlink("nested", join(f.workspace, "inside")); + const value = await f.list(); + assert.equal(value.truncated, false); + assert.deepEqual(value.entries.sort((a, b) => a.name.localeCompare(b.name)), [ + { name: "binary", kind: "file", size_bytes: 3 }, { name: "empty", kind: "file", size_bytes: 0 }, + { name: "inside", kind: "symlink" }, { name: "nested", kind: "directory" }, { name: "outside", kind: "symlink" }, + ]); + assert.deepEqual((await f.list("nested")).entries, []); + assert.equal((await f.list("", 1)).truncated, true); + assert.equal((await f.list("absent")).error, "not_found"); + for (const path of ["outside", "outside/protected", "inside"]) assert.ok(["invalid", "permission"].includes((await f.list(path)).error)); + for (const path of ["/", "a//b", ".", "..", "a/../b", "a\\b", "a\n"]) assert.equal((await f.list(path)).error, "invalid"); + for (const limit of [0, -1, 1001, 1.1]) assert.equal((await f.list("", limit)).error, "invalid"); +}); + +test("frozen root survives replacement and closes descriptors", { skip: process.platform !== "linux" }, async t => { + const baseline = (await readdir("/proc/self/fd")).length; + const f = await fixture(t); + await mkdir(join(f.workspace, "nested")); + await writeFile(join(f.workspace, "nested", "owned"), "ok"); + await rename(f.workspace, join(f.root, "original")); + await mkdir(f.workspace); + await writeFile(join(f.workspace, "wrong"), "no"); + for (let i = 0; i < 20; i++) assert.deepEqual((await f.list("nested")).entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); + await f.directories.close(); + assert.equal((await f.list()).error, "unavailable"); + assert.equal((await readdir("/proc/self/fd")).length, baseline); +}); + +test("concurrent parent symlink swaps never enumerate the outside target", { skip: process.platform !== "linux" }, async t => { + const f = await fixture(t); + const nested = join(f.workspace, "nested"), parked = join(f.workspace, "parked"); + await mkdir(nested); + await writeFile(join(nested, "owned"), "ok"); + await mkdir(join(f.root, "protected")); + await writeFile(join(f.root, "protected", "secret"), "outside"); + let stop = false; + const swapping = (async () => { + while (!stop) { + await rename(nested, parked); + await symlink(join(f.root, "protected"), nested); + await rm(nested); + await rename(parked, nested); + } + })(); + try { + for (let i = 0; i < 100; i++) { + const result = await f.list("nested"); + if (result.error) assert.ok(["not_found", "permission", "invalid"].includes(result.error)); + else assert.deepEqual(result.entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); + } + } finally { stop = true; await swapping; } +}); + +test("literal Unicode names are preserved and undecodable names fail explicitly", { skip: process.platform !== "linux" }, async t => { + const f = await fixture(t); + await writeFile(join(f.workspace, "字\ufffd"), "ok"); + assert.deepEqual((await f.list()).entries, [{ name: "字\ufffd", kind: "file", size_bytes: 2 }]); + await writeFile(Buffer.concat([Buffer.from(f.workspace + "/"), Buffer.from([255])]), "invalid"); + assert.equal((await f.list()).error, "uncertain"); + assert.equal(f.abort.signal.aborted, true); +}); diff --git a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs new file mode 100644 index 000000000..329ed7102 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const fixture = ` +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const sdk = 'export function getSessionInfo(...args){return globalThis.historyFixture(...args);} export function query(options){return globalThis.queryFixture(options);} export function startup(){throw new Error("Unexpected preparation");}'; +registerHooks({ resolve(specifier, context, next) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}; + return next(specifier,context); +}}); +const { execute } = await import(${JSON.stringify(new URL("../dist/adapter.js", import.meta.url).href)}); +const root = realpathSync(mkdtempSync(join(tmpdir(), "parsar-workspace-execute-"))); +const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps"].map(name => { + const path = join(root, name); mkdirSync(path); return [name, path]; +})); +const mode = process.argv[1]; +const workspace = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, + protected_dirs: [], dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY"] }; +const request = { type: "start", prompt: "fixture", model: "fixture", system_prompt: "", cwd: dirs.workspace, + workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}) }; +process.env.HOME = dirs.home; +process.env.CLAUDE_CONFIG_DIR = dirs.state; +process.env.ANTHROPIC_API_KEY = "fixture-secret"; +process.env.UNSELECTED_CANARY = "must-not-inherit"; +delete process.env.CLAUDE_CODE_PROJECT_DIR_NAME; +const events = [], calls = []; +const abort = new AbortController(); +globalThis.historyFixture = async (id, options) => { + calls.push("history"); + assert.equal(id, "native"); + assert.deepEqual(options, { dir: dirs.workspace }); + assert.equal(process.env.HOME, dirs.home); + assert.equal(process.env.CLAUDE_CONFIG_DIR, dirs.state); + return mode === "resume-missing" ? undefined : { sessionId: "native" }; +}; +globalThis.queryFixture = ({prompt, options}) => { + calls.push("query"); + assert.equal(options.env.UNSELECTED_CANARY, undefined); + assert.equal(options.env.ANTHROPIC_API_KEY, "fixture-secret"); + assert.equal(options.env.HOME, dirs.home); + assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); + assert.equal(options.sandbox.failIfUnavailable, true); + assert.equal(options.resume, request.resume); + const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: abort.signal, + args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,undefined);assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); + return { close() { child.stdin.end(); }, async *[Symbol.asyncIterator]() { + const first = (await prompt[Symbol.asyncIterator]().next()).value; + yield { type: "system", subtype: "init", session_id: "native", mcp_servers: mode === "extra-mcp" ? [{name:"other",status:"connected"}] : [], + tools: mode === "extra-tool" ? ["Bash", "Read", "Edit", "Agent"] : ["Bash", "Read", "Edit"] }; + if (mode.startsWith("commands")) { + const call = id => ({type:"assistant",session_id:"native",parent_tool_use_id:null, + message:{content:[{type:"tool_use",name:"Bash",id,input:{command:"printf 'observed'"}}]}}); + yield call("observed"); + yield {type:"user",session_id:"native",parent_tool_use_id:null, + message:{content:[{type:"tool_result",tool_use_id:"observed",content:"observed",is_error:false}]}, + tool_use_result:{stdout:"observed",stderr:"",interrupted:false}}; + if (mode !== "commands-success") yield call("unfinished"); + if (mode === "commands-cancel") {abort.abort();return;} + } + yield { type: "result", uuid: "result", session_id: "native", user_message_uuids: [first.uuid], + subtype: "success", is_error: false, result: "fixture", usage: {input_tokens:1,output_tokens:1}, modelUsage:{} }; + } }; +}; +try { + if (mode === "resume-mismatch") { + process.env.CLAUDE_CONFIG_DIR = dirs.home; + await assert.rejects(execute(request, async e => events.push(e), abort), /invalid_request/); + assert.deepEqual(calls, []); + } else { + await execute(request, async e => events.push(e), abort); + if (mode === "resume-missing") { + assert.deepEqual(calls, ["history"]); + assert.deepEqual(events, [{type:"error",code:"history_unavailable"}]); + } else { + assert.deepEqual(calls, mode === "resume-existing" ? ["history", "query"] : ["query"]); + if (mode.startsWith("extra")) { + assert.deepEqual(events.at(-1), {type:"error",code:"execution_failed"}); + assert.equal(events.some(e => e.type === "input_ready"), false); + } else if (mode.startsWith("commands")) { + const observations=events.filter(e=>e.type==="command_observation"); + assert.deepEqual(observations.slice(0,2).map(e=>[e.id,e.stage,e.observation.output]), + [["observed","before",undefined],["observed","after","observed"]]); + assert.equal(events.some(e=>e.type==="command_output"),false); + if(mode === "commands-success") { + assert.equal(observations.length,2);assert.equal(events.at(-1).type,"result"); + } else { + assert.equal(observations.length,4); + assert.deepEqual(observations.at(-1).observation,{kind:"command",status:"incomplete",command:"printf 'observed'"}); + assert.deepEqual(events.at(-1),{type:"error",code:mode === "commands-cancel"?"cancelled":"execution_failed"}); + } + } else assert.equal(events.at(-1).type, "result"); + } + } +} finally { rmSync(root, {recursive:true,force:true}); } +`; + +for (const mode of ["fresh", "resume-existing", "resume-missing", "resume-mismatch", "extra-tool", "extra-mcp", "commands-success", "commands-cancel", "commands-missing"]) { + test(`workspace execution boundary: ${mode}`, { timeout: 15000 }, () => { + const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture, mode], { encoding: "utf8", timeout: 10000 }); + assert.equal(child.status, 0, child.stderr || child.error?.message); + }); +} diff --git a/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs b/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs new file mode 100644 index 000000000..5d8fe943c --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace_reads.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WorkspaceReads } from "../dist/workspace_reads.js"; + +const request = { type: "workspace_read", id: "read", path: "file", max_bytes: 4 }; +function fixture(readFile) { + const events=[],abort=new AbortController();let closed=false; + const reads=new WorkspaceReads(async event=>{events.push(event);},abort); + reads.bind({readFile,close(){closed=true;}},"/workspace"); + return {reads,events,abort,get closed(){return closed;}}; +} +for (const [name,contents,truncated] of [["binary","AP+AAQ==",false],["empty","",false],["prefix","AP+AAQ==",true]]) { + test(`native binary response: ${name}`,async()=>{ + const f=fixture(async(path,options)=>{assert.equal(path,"/workspace/file");assert.deepEqual(options,{maxBytes:4,encoding:"base64"});return {absPath:path,encoding:"base64",contents,...truncated&&{truncated}};}); + f.reads.submit(request);await f.reads.close(); + assert.deepEqual(f.events,[{type:"workspace_read",id:"read",data_base64:contents,truncated}]);assert.equal(f.abort.signal.aborted,false); + }); +} +test("bounds, admission and release retain the original native wait",async()=>{ + let settle;const f=fixture(()=>new Promise(resolve=>{settle=resolve;})); + for(const fields of [{path:"../escape"},{path:"/absolute"},{path:"a//b"},{path:"a\\b"},{max_bytes:0},{max_bytes:1048577},{path:"a".repeat(8192)}]) f.reads.submit({...request,...fields}); + assert.ok(f.events.every(event=>event.error === "invalid"));f.events.length=0; + f.reads.submit(request);f.reads.submit({...request,id:"busy"});assert.equal(f.events[0].error,"busy"); + let released=false;const release=f.reads.close().then(()=>{released=true;});await Promise.resolve();assert.equal(released,false); + f.reads.submit({...request,id:"closed"});assert.equal(f.events[1].error,"unavailable"); + settle({absPath:"/workspace/file",encoding:"base64",contents:""});await release;assert.equal(f.events[2].data_base64,""); +}); +for(const result of [null,{absPath:"/workspace/file",contents:"",encoding:"utf-8"},{absPath:"/other",contents:"",encoding:"base64"}, + {absPath:"/workspace/file",contents:"!",encoding:"base64"},{absPath:"/workspace/file",contents:"",encoding:"base64",truncated:true}]) { + test("ambiguous native responses fence the owner",async()=>{ + const f=fixture(async()=>result);f.reads.submit(request);await f.reads.close(); + assert.deepEqual(f.events,[{type:"workspace_read",id:"read",error:"uncertain"}]);assert.equal(f.abort.signal.aborted,true);assert.equal(f.closed,true); + }); +} diff --git a/packages/claude-sdk-adapter/tsconfig.json b/packages/claude-sdk-adapter/tsconfig.json new file mode 100644 index 000000000..e9b419872 --- /dev/null +++ b/packages/claude-sdk-adapter/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig/base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/packages/codex-executor/Cargo.lock b/packages/codex-executor/Cargo.lock new file mode 100644 index 000000000..d63b13874 --- /dev/null +++ b/packages/codex-executor/Cargo.lock @@ -0,0 +1,8719 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "agents-api-codex-executor" +version = "0.1.0" +dependencies = [ + "clap", + "codex-api", + "codex-exec-server", + "codex-http-client", + "http", + "rustix", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocative" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8cf9afc79c83d514444b55df3935d317da54b1ce3b17a133c646889cc260de8" +dependencies = [ + "allocative_derive", + "bumpalo", + "ctor", + "hashbrown 0.16.1", + "num-bigint", +] + +[[package]] +name = "allocative_derive" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614043c56c1173b800acb007b81fd0cbc0a0d7d717b71ba705fc2230d0760a23" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "appcontainer_common" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", + "getrandom 0.2.17", + "learning_mode_core", + "learning_mode_windows", + "process_security_environment_spec", + "sandbox_spec", + "serde", + "serde_json", + "thiserror 2.0.18", + "widestring", + "windows 0.62.2", + "windows-core 0.62.2", + "winreg 0.55.0", + "wxc_common", +] + +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "zeroize", +] + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "digest", + "rayon-core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "cfg_aliases 0.2.1", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "clatter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fed49fa357a85c377c0f920e86100f5326111b09ad69f6de684e324e3ad8097" +dependencies = [ + "aes-gcm", + "arrayvec", + "displaydoc", + "getrandom 0.3.4", + "ml-kem", + "rand_core 0.6.4", + "sha2", + "thiserror-no-std", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmp_any" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codex-api" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "async-channel", + "base64", + "bytes", + "chrono", + "codex-client", + "codex-http-client", + "codex-protocol", + "codex-utils-rustls-provider", + "codex-websocket-client", + "eventsource-stream", + "futures", + "http", + "regex-lite", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tungstenite", + "url", + "uuid", +] + +[[package]] +name = "codex-async-utils" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "tokio", + "tokio-util", +] + +[[package]] +name = "codex-client" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-http-client", + "eventsource-stream", + "futures", + "http", + "rand 0.9.3", + "tokio", + "tracing", +] + +[[package]] +name = "codex-config" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "base64", + "codex-execpolicy", + "codex-features", + "codex-file-system", + "codex-git-utils", + "codex-model-provider-info", + "codex-network-proxy", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-path", + "codex-utils-path-uri", + "codex-utils-redacted-string", + "core-foundation 0.9.4", + "dns-lookup", + "dunce", + "futures", + "gethostname", + "indexmap 2.14.0", + "libc", + "multimap", + "prost", + "regex-lite", + "schemars 0.8.22", + "serde", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 2.0.18", + "tokio", + "toml", + "toml_edit 0.24.0+spec-1.1.0", + "tonic", + "tonic-prost", + "tracing", + "wildmatch", + "winapi-util", + "windows-sys 0.52.0", +] + +[[package]] +name = "codex-exec-server" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "arc-swap", + "axum", + "base64", + "bytes", + "clatter", + "codex-api", + "codex-config", + "codex-exec-server-protocol", + "codex-file-system", + "codex-http-client", + "codex-network-proxy", + "codex-otel", + "codex-protocol", + "codex-sandboxing", + "codex-shell-command", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "codex-utils-path-uri", + "codex-utils-pty", + "codex-utils-rustls-provider", + "codex-websocket-client", + "dirs", + "futures", + "http", + "libc", + "prost", + "rustix", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "toml", + "tracing", + "url", + "uuid", + "windows-sys 0.52.0", +] + +[[package]] +name = "codex-exec-server-protocol" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "base64", + "codex-file-system", + "codex-network-proxy", + "codex-protocol", + "codex-shell-command", + "codex-utils-path-uri", + "serde", + "serde_json", +] + +[[package]] +name = "codex-execpolicy" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "clap", + "codex-utils-absolute-path", + "multimap", + "serde", + "serde_json", + "shlex", + "starlark", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-extension-items" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-utils-absolute-path", + "schemars 0.8.22", + "serde", + "serde_json", + "ts-rs", +] + +[[package]] +name = "codex-features" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-otel", + "codex-protocol", + "schemars 0.8.22", + "serde", + "toml", + "tracing", +] + +[[package]] +name = "codex-file-system" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "bytes", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-path-uri", + "futures", + "serde", +] + +[[package]] +name = "codex-git-utils" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "chrono", + "codex-file-system", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-path-uri", + "codex-utils-pty", + "futures", + "gix", + "once_cell", + "regex", + "schemars 0.8.22", + "serde", + "similar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "ts-rs", + "walkdir", +] + +[[package]] +name = "codex-http-client" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "bytes", + "codex-utils-rustls-provider", + "futures", + "http", + "native-tls", + "opentelemetry", + "reqwest", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "sha2", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-opentelemetry", + "windows-sys 0.52.0", + "zstd", +] + +[[package]] +name = "codex-model-provider-info" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-api", + "codex-protocol", + "codex-utils-redacted-string", + "http", + "schemars 0.8.22", + "serde", +] + +[[package]] +name = "codex-network-proxy" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "base64", + "chrono", + "clap", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "codex-utils-rustls-provider", + "globset", + "rama-core", + "rama-http", + "rama-http-backend", + "rama-net", + "rama-socks5", + "rama-tcp", + "rama-tls-rustls", + "rama-unix", + "rand 0.9.3", + "rustls-native-certs", + "schannel", + "security-framework 3.5.1", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", + "windows-sys 0.52.0", +] + +[[package]] +name = "codex-otel" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "chrono", + "codex-api", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-string", + "eventsource-stream", + "gethostname", + "http", + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "os_info", + "reqwest", + "serde", + "serde_json", + "strum_macros", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "codex-protocol" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "chardetng", + "chrono", + "codex-async-utils", + "codex-execpolicy", + "codex-extension-items", + "codex-http-client", + "codex-network-proxy", + "codex-utils-absolute-path", + "codex-utils-image", + "codex-utils-path-uri", + "codex-utils-redacted-string", + "codex-utils-string", + "encoding_rs", + "gix-url", + "globset", + "http", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "landlock", + "quick-xml", + "schemars 0.8.22", + "seccompiler", + "serde", + "serde_json", + "serde_with", + "strum", + "strum_macros", + "sys-locale", + "thiserror 2.0.18", + "tokio", + "tracing", + "ts-rs", + "uuid", + "wildmatch", +] + +[[package]] +name = "codex-sandboxing" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "appcontainer_common", + "codex-network-proxy", + "codex-otel", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "codex-utils-path-uri", + "codex-utils-pty", + "codex-windows-sandbox", + "dunce", + "libc", + "regex-lite", + "serde_json", + "tokio", + "tracelogging", + "tracing", + "url", + "which", +] + +[[package]] +name = "codex-shell-command" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "base64", + "codex-protocol", + "codex-utils-absolute-path", + "libc", + "once_cell", + "regex", + "serde", + "serde_json", + "shlex", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-powershell", + "url", + "which", +] + +[[package]] +name = "codex-utils-absolute-path" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "dirs", + "dunce", + "schemars 0.8.22", + "serde", + "ts-rs", +] + +[[package]] +name = "codex-utils-cache" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "lru", + "sha1", + "tokio", +] + +[[package]] +name = "codex-utils-home-dir" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-utils-absolute-path", + "dirs", +] + +[[package]] +name = "codex-utils-image" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "base64", + "codex-utils-cache", + "image", + "mime_guess", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-utils-path" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-utils-absolute-path", + "dunce", + "tempfile", +] + +[[package]] +name = "codex-utils-path-uri" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "base64", + "codex-utils-absolute-path", + "schemars 0.8.22", + "serde", + "thiserror 2.0.18", + "ts-rs", + "url", + "urlencoding", +] + +[[package]] +name = "codex-utils-pty" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "filedescriptor", + "lazy_static", + "libc", + "log", + "portable-pty", + "shared_library", + "tokio", + "winapi", +] + +[[package]] +name = "codex-utils-redacted-string" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "schemars 0.8.22", + "serde", +] + +[[package]] +name = "codex-utils-rustls-provider" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "rustls", +] + +[[package]] +name = "codex-utils-string" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "codex-websocket-client" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "codex-http-client", + "futures", + "rustls", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "url", +] + +[[package]] +name = "codex-windows-sandbox" +version = "0.153.4" +source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" +dependencies = [ + "anyhow", + "base64", + "chrono", + "codex-otel", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-pty", + "codex-utils-string", + "dirs-next", + "dunce", + "glob", + "rand 0.8.6", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing-appender", + "windows 0.58.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "debugserver-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" +dependencies = [ + "schemafy", + "serde", + "serde_json", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "display_container" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" +dependencies = [ + "either", + "indenter", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dns-lookup" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +dependencies = [ + "cfg-if", + "libc", + "socket2 0.6.3", + "windows-sys 0.60.2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dupe" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" +dependencies = [ + "dupe_derive", +] + +[[package]] +name = "dupe_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless 0.8.0", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.13.1", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs 0.5.5", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gix" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-blame", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-index", + "gix-lock", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-actor" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "winnow", +] + +[[package]] +name = "gix-archive" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-chunk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3196655fd1443f3c58a48c114aa480be3e4e87b393d7292daaa0d543862eb445" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08939b4c4ed7a663d0e64be9e1e9bdf23a1fb4fcee1febdf449f12229542e50d" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "memchr", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", + "winnow", +] + +[[package]] +name = "gix-config-value" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-date" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39acf819aa9fee65e4838a2eec5cb2506e47ebb89e02a5ab9918196e491571ea" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", + "smallvec", +] + +[[package]] +name = "gix-diff" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" +dependencies = [ + "bstr", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "imara-diff 0.1.8", + "imara-diff 0.2.0", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c65bd3330fe0cb9d40d875bf862fd5e8ad6fa4164ddbc4842fbeb889c3f0b2c6" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e86d01da904d4a9265def43bd42a18c5e6dc7000a73af512946ba14579c9fbd" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" +dependencies = [ + "bytes", + "crc32fast", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs 0.6.3", +] + +[[package]] +name = "gix-filter" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a964b4aec683eb0bacb87533defa80805bb4768056371a47ab38b00a2d377b72" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-glob" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hashtable" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" +dependencies = [ + "gix-hash", + "hashbrown 0.16.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f915dcf6911e3027537166d34e13f0fe101ed12225178d2ae29cd1272cff26" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "21.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054fbd0989700c69dc5aa80bc66944f05df1e15aa7391a9e42aca7366337905f" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-merge" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "imara-diff 0.1.8", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-negotiate" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-odb" +version = "0.78.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24833ae9323b4f7079575fb9f961cf9c414b0afbec428a536ab8e7dd93bc002b" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.68.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3484119cd19859d7d7639413c27e192478fa354d3f4ff5f7e3c041e8040f0f4" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-packetline" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be19313dcdb7dff75a3ce2f99be00878458295bcc3b6c7f0005591597573345c" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c31d4373bda7fab9eb01822927b55185a378d6e1bf737e0a54c743ad806658" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-protocol" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-quote" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-refspec" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revision" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-sec" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-status" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-submodule" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5c3929c5e6821f651d35e8420f72fea3cfafe9fc1e928a61e718b462c72a5" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-tempfile" +version = "21.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0" + +[[package]] +name = "gix-transport" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a521e39c6235ce63ed6c001e2dd79818c830b82c3b7b59247ee7b229c39ec9bb" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-traverse" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-url" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d28e8af3d42581190da884f013caf254d2fd4d6ab102408f08d21bfa11de6c8d" +dependencies = [ + "bstr", + "gix-path", + "percent-encoding", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" +dependencies = [ + "bstr", + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec1eff98d91941f47766367cba1be746bab662bad761d9891ae6f7882f7840b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-state" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.3", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.3", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale", + "icu_locale_core", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_plurals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imara-diff" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "landlock" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "learning_mode_core" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "same-file", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "learning_mode_windows" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "learning_mode_core", + "sha2", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", + "wxc_common", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.13.1", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lock_free_hashtable" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf3631712f5b790675292ff827af269f5d9f920c920b77dc41d0485e3719612" +dependencies = [ + "atomic", + "parking_lot", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "logos" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array", + "kem", + "rand_core 0.6.4", + "sha3", + "zeroize", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + +[[package]] +name = "mxc_config_contract" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "mxc_telemetry" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "tracelogging", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "base64", + "const-hex", + "opentelemetry", + "opentelemetry_sdk", + "prost", + "serde", + "serde_json", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.3", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "pagable" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3658968938a4d1eaa1987e69dcd84b01fb067c5b3416dccc8d71373b6ded6821" +dependencies = [ + "allocative", + "anyhow", + "async-trait", + "blake3", + "bytemuck", + "dashmap", + "dupe", + "either", + "erased-serde 0.4.10", + "fancy-regex", + "indexmap 2.14.0", + "inventory", + "num-bigint", + "once_cell", + "pagable_derive", + "parking_lot", + "postcard", + "regex", + "sequence_trie", + "serde", + "serde_json", + "smallvec", + "sorted_vector_map", + "static_assertions", + "static_interner", + "strong_hash", + "take_mut", + "triomphe", +] + +[[package]] +name = "pagable_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838d17166587914f4e99353766c29160462b681511f08679545a0d07a0dc9415" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "crc", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process_security_environment_spec" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bitflags 2.13.1", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "psl" +version = "2.1.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dc6a90669f481b41cae3005c68efa36bef275b95aa9123a7af7f1c68c6e5b2" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quickcheck" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" +dependencies = [ + "env_logger", + "log", + "rand 0.10.1", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.3", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type 0.1.2", + "nibble_vec", +] + +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type 0.2.0", + "nibble_vec", +] + +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash", + "asynk-strim", + "bytes", + "futures", + "parking_lot", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-dns" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" +dependencies = [ + "ahash", + "hickory-resolver", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash", + "base64", + "bitflags 2.13.1", + "chrono", + "const_format", + "csv", + "http", + "http-range-header", + "httpdate", + "iri-string", + "matchit 0.9.1", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "radix_trie 0.3.0", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.3", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-backend" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" +dependencies = [ + "h2", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-core", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-tcp", + "rama-unix", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-http-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" +dependencies = [ + "ahash", + "atomic-waker", + "futures-channel", + "httparse", + "httpdate", + "indexmap 2.14.0", + "itoa", + "parking_lot", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-types", + "rama-utils", + "slab", + "tokio", + "tokio-test", + "want", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash", + "base64", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.3", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash", + "bytes", + "const_format", + "fnv", + "http", + "http-body", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.3", + "serde", + "serde_json", + "sync_wrapper", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash", + "const_format", + "flume", + "hex", + "ipnet", + "itertools", + "md5", + "nom 8.0.0", + "parking_lot", + "pin-project-lite", + "psl", + "radix_trie 0.3.0", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.3", + "tokio", +] + +[[package]] +name = "rama-socks5" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5468b263516daaf258de32542c1974b7cbe962363ad913dcb669f5d46db0ef3e" +dependencies = [ + "byteorder", + "rama-core", + "rama-net", + "rama-tcp", + "rama-udp", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-tcp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-dns", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.3", + "tokio", +] + +[[package]] +name = "rama-tls-rustls" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536d47f6b269fb20dffd45e4c04aa8b340698b3509326e3c36e444b4f33ce0d6" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-http-types", + "rama-net", + "rama-utils", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "webpki-roots", + "x509-parser", +] + +[[package]] +name = "rama-udp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ed05e0ecac73e084e92a3a8b1fbf16fdae8958c506f0f0eada180a2d99eef4" +dependencies = [ + "rama-core", + "rama-net", + "tokio", + "tokio-util", +] + +[[package]] +name = "rama-unix" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-net", + "tokio", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie 0.2.1", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sandbox_spec" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemafy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aea5ba40287dae331f2c48b64dbc8138541f5e97ee8793caa7948c1f31d86d5" +dependencies = [ + "Inflector", + "schemafy_core", + "schemafy_lib", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "syn 1.0.109", +] + +[[package]] +name = "schemafy_core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41781ae092f4fd52c9287efb74456aea0d3b90032d2ecad272bd14dbbcb0511b" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "schemafy_lib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e953db32579999ca98c451d80801b6f6a7ecba6127196c5387ec0774c528befa" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "schemafy_core", + "serde", + "serde_derive", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sequence_trie" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee22067b7ccd072eeb64454b9c6e1b33b61cd0d49e895fd48676a184580e0c3" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serial2" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted_vector_map" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bf565ee1681b4473aa5a9d71d807347c28021bd1d8947cb626b02f42a0141f" +dependencies = [ + "itertools", + "quickcheck", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "starlark" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9062e866918dc4c9701c98ac99f7f4fa9e4b3b4edce306e147393bc75458c4fc" +dependencies = [ + "allocative", + "anyhow", + "blake3", + "bumpalo", + "cmp_any", + "dashmap", + "debugserver-types", + "derivative", + "derive_more", + "display_container", + "dupe", + "either", + "erased-serde 0.3.31", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "inventory", + "itertools", + "maplit", + "memoffset", + "num-bigint", + "num-traits", + "once_cell", + "pagable", + "paste", + "ref-cast", + "regex", + "rustyline", + "serde", + "serde_json", + "starlark_derive", + "starlark_map", + "starlark_syntax", + "static_assertions", + "strong_hash", + "strsim 0.10.0", + "textwrap", + "thiserror 2.0.18", +] + +[[package]] +name = "starlark_derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797e235eb70936bfa14fabf490bf7453e6f0caaf6b9c56fe4c9aff02aee7e66d" +dependencies = [ + "dupe", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "starlark_map" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234877898fd216af93b2f5798b08cbbdc1a2e8f16a622a258b1db23a61a1c4ba" +dependencies = [ + "allocative", + "dupe", + "equivalent", + "fxhash", + "hashbrown 0.16.1", + "pagable", + "serde", + "strong_hash", +] + +[[package]] +name = "starlark_syntax" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7492c571c531e68099c911cfd909d32659f1cc0910cf3adee9fce66e39d21f14" +dependencies = [ + "allocative", + "annotate-snippets", + "anyhow", + "derivative", + "derive_more", + "dupe", + "logos", + "lsp-types", + "memchr", + "num-bigint", + "num-traits", + "once_cell", + "pagable", + "starlark_map", + "thiserror 2.0.18", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "static_interner" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab44341fbf4deae6e8d5ab450f24e1b34e0b2439d39ac0e9b5215a4e5493263" +dependencies = [ + "equivalent", + "lock_free_hashtable", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strong_hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0831334aea34390b6b6ec7af0a27f9ee6324ad3a69463e6b240d83d6b7bce9c9" +dependencies = [ + "ref-cast", + "strong_hash_derive", +] + +[[package]] +name = "strong_hash_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace6b48b7c4383a39bd3b966cca41bc999003aab9f690a2f355525c924296928" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl-no-std" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e6318948b519ba6dc2b442a6d0b904ebfb8d411a3ad3e07843615a72249758" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "thiserror-no-std" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ad459d94dd517257cc96add8a43190ee620011bb6e6cdc82dafd97dfafafea" +dependencies = [ + "thiserror-impl-no-std", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.24.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tonic" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracelogging" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4314470f3f54b29d582ff6776fceb7c819b023141828d559f19c790cee40e94" +dependencies = [ + "tracelogging_macros", +] + +[[package]] +name = "tracelogging_macros" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95e2d891464ff33bc1814c4cbbb251bae7800458b1efdb6ac8b7c01ee6382563" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-powershell" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" +dependencies = [ + "serde_json", + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" +dependencies = [ + "bytes", + "data-encoding", + "flate2", + "headers", + "http", + "httparse", + "log", + "rand 0.9.3", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wxc_common" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "base64", + "cidr", + "getrandom 0.2.17", + "libc", + "mxc_config_contract", + "mxc_telemetry", + "semver", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.18", + "unicode-general-category", + "url", + "widestring", + "windows 0.62.2", + "windows-core 0.62.2", + "winreg 0.55.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core", +] diff --git a/packages/codex-executor/Cargo.toml b/packages/codex-executor/Cargo.toml new file mode 100644 index 000000000..7fb9ebc4b --- /dev/null +++ b/packages/codex-executor/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "agents-api-codex-executor" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "agents-api-codex-directory" +path = "src/bin/directory.rs" + +[[bin]] +name = "agents-api-codex-write" +path = "src/bin/write.rs" + +[[bin]] +name = "agents-api-workspace-export" +path = "src/bin/export.rs" + +[dependencies] +codex-api = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } +codex-exec-server = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } +codex-http-client = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } +clap = { version = "4", features = ["derive"] } +http = "1.3.1" +rustix = { version = "=1.1.4", features = ["fs"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "=0.10.9" +tar = { version = "=0.4.46", default-features = false } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +url = "2" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "=3.27.0" + +[patch.crates-io] +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } + +[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } + +[profile.dev] +debug = 0 + +[profile.release] +strip = "symbols" diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md new file mode 100644 index 000000000..8f5c3e437 --- /dev/null +++ b/packages/codex-executor/README.md @@ -0,0 +1,199 @@ +# Native Codex executor for Agents API + +`agents-api-codex-executor` is a separately named launcher for a third-party Agents +API registry. It embeds the unmodified Codex 0.153.4 executor libraries from commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`. Upstream owns registration, Noise, +reconnection, files, process execution and graceful shutdown. This package owns +explicit connection configuration and its service-issued credential. + +This is an optional Linux x86_64 component. The Agents API service and other daemon +adapters build independently. It does not enable public `self_hosted` admission or +establish full Environment compatibility. Stock `codex exec-server` retains its +API-key domain restriction; this launcher does not change that command. + +## Build and installation + +Install Rust 1.95.0 with rustfmt and Clippy, a C toolchain, pkg-config and OpenSSL +development headers. Build with the committed Cargo lock: + +```sh +make check-agents-executor +make build-agents-executor +``` + +Build state stays under `~/.parsar/`. `CARGO_HOME`, `CARGO_TARGET_DIR` and +`AGENTS_EXECUTOR_BUILD_DIR` can select existing absolute cache/output locations. +The build copies only this package into its build context; no product or API +service code is needed. The resulting GNU binary requires compatible glibc and +OpenSSL runtime libraries. This is not a portable musl artifact. + +Install the exact official native Codex 0.153.4 package separately. Keep its +platform resource directory intact, including any bundled sandbox helper. +`--codex-bin` must point to its actual native executable, not the npm JavaScript +entry point. The launcher checks its version and creates a stable +`codex-linux-sandbox` alias under its private state directory. Native filesystem, +argv0 and sandbox helper modes execute that official binary; none are copied into +the launcher. System/container sandbox permissions must support the requested +native policy. Do not interpret an unsandboxed command test as sandbox validation. + +## Scoped directory helper + +The build also emits `agents-api-codex-directory` for the current directory-listing +adapter gap. Install it at an operator-controlled absolute path on the executor, +outside the writable workspace. Its three argv values are the authorized absolute +workspace root, a relative directory (empty for the root), and a limit of 1–4096. +Invoke it directly through the existing authenticated native process API with a +read-only filesystem policy and restricted network. No shell or model is involved. +The helper itself is a local program, not an authorization service: the caller +must bind the root to the exact authorized owner and validate the installation. + +It opens every directory component without following symlinks, retains directory +descriptors for enumeration and metadata, and stops after the limit plus one +entry. It returns one version-1 JSON response: `directory.entries` contains +`name`, `kind` (`file`, `directory`, `symlink`, `other`) and nullable `size_bytes`; +`directory.truncated` reports lookahead. Only regular files have sizes. Errors +use `error` with no partial entries. Invalid paths/names and oversized responses +are rejected. Descriptor cleanup precedes output; exit zero alone is not success, +since a settled error also returns JSON. Require valid complete JSON, a successful +exit and native output-close receipt before accepting an observation. Unknown +start/termination/transport results remain uncertain and must not be retried as +settled reads. + +This one-level observation has no order, paging or snapshot guarantee. Renames may +leave an operation reading the directory it already opened; workspace replacement +and cross-tenant placement remain the caller's responsibility. The helper does +not change stock native filesystem methods, create a daemon connection, or enable +public Files. The [native fixture](../../services/agents-api/tests/native/directory/README.md) +qualifies the standalone helper independently of later adapter/public wiring. + +## Scoped output exporter + +`agents-api-workspace-export` takes one authorized absolute workspace root and +streams regular files beneath its `outputs` directory as a standard tar archive +on stdout. It reuses the directory helper's descriptor-relative path protection; +it does not invoke a shell, model or provider command. The caller must supply the +exact Environment's frozen root and independently authorize the operation. + +Require both a complete validated archive and successful process exit before +publishing anything. On failure the stdout prefix may still look like a valid +archive. Never extract it into Core's filesystem. Consumers must bound and stream +individual entries into private storage, then publish only after the entire +capture succeeds. A missing `outputs` directory produces an empty archive. + +The exporter rejects symlinks, multiply linked files, special files, device +changes and detected concurrent modifications. It limits each file to 200 MiB +and aggregate bytes to 500 MiB, following the current official Files guide. +Traversal is additionally bounded at 4096 entries, 64 directory levels and +4096-byte relative paths; those are implementation limits, not upstream promises. +This is not a filesystem-wide point-in-time snapshot. Immutable publication, +tenant isolation, Turn ordering and storage cleanup remain Core/Runtime duties; +the helper alone does not enable public Artifacts. + +## Scoped file installer + +The optional `agents-api-codex-write` helper addresses two pinned native write +limitations: hard-link targets are modified in place, and base64 encoding a +50 MiB file exceeds the native 64 MiB message bound. Install this helper outside +the writable workspace and invoke it directly through the native process API, +with restricted network and the required helper/runtime reads. The qualified +installer policy grants write access to one dedicated per-Environment parent +containing only workspace and staging; ordinary native tools can write only the +workspace. Keep credentials, native history and other Environments outside that +parent. Separate writable mount entries can make rename fail with EXDEV even +when their backing filesystem matches; the helper must reject that layout. +It does not authorize callers or enable Files.create. + +Arguments are the authorized absolute root, a nonempty relative file path, +its declared byte count (0–50 MiB), and an existing absolute staging directory. +The staging directory must be outside the workspace, not its ancestor, and on +the destination filesystem. Symlink traversal and cross-filesystem replacement +are rejected; there is no copy fallback. The former three-argument private CLI +is no longer accepted. Stream those bytes in bounded native stdin +chunks, followed by their 32-byte binary SHA-256 digest. This is one private frame; +there is no second request on that process. The native process protocol has no +stdin-close method, so the digest terminates the frame without waiting for EOF. +Extra bytes after the frame are not consumed. A process/write accepted receipt +means queued input, not committed file contents. + +The helper reuses held-directory no-follow traversal, rejects existing nonregular +targets and requires an existing parent. It writes a fresh mode-0600 temporary +file in the held staging directory with existing rustix openat/renameat operations, +checks the declared byte count and digest, syncs the file, then replaces the +destination directory entry and syncs both directories. Existing hard links retain +their original inode and contents. This +private replacement policy does not preserve destination mode/ownership metadata +or establish official overwrite semantics. The operator must protect staging and +its ancestors from native tools and background processes. A dedicated staging +directory per Environment, with native tool write access limited to the workspace +and separate installer access, is the qualified mechanism. Directory naming or +mode 0700 alone does not isolate processes running as the same user. The helper +cannot verify other processes' policies; public admission must bind and validate +this condition. Broad read permission may still expose staging bytes; confidentiality +requires its own placement policy. Concurrent workspace changes do not gain access +to protected staging, but later writers can change the installed file. No snapshot +or exactly-once guarantee is implied. + +One version-1 JSON response reports `outcome: completed` with `size_bytes`, +`failed` before replacement, or `unknown` if either directory sync fails after replacement. +Errors contain only a fixed safe code. Require a complete response plus observed +native exit/output close; exit zero alone is insufficient. Input errors preserve +the old destination provided staging remains protected; independent workspace +writers can still change that destination themselves. Temporary-file cleanup +is best effort: permission or I/O errors, as well as forced termination, can leave +a `.parsar-upload-*` file in the private staging directory. Never interpret it as a +completed upload. +A missing receipt remains unknown and must not trigger automatic replay. This +helper does not fence a replacement owner after remote transport or service loss; +public admission still needs operation ownership and recovery handling. + +## Connect an executor + +An operator creates an executor principal key with +[`agents-api-environment-key`](../../services/agents-api/README.md#native-executor-transport-prerequisite). +The key may be issued before a Session exists, or optionally restricted to one +existing Environment. Redirect its JSON output to a mode-0600 regular file under +`~/.parsar/` and transfer that credential to its executor. Keep caller, database, +daemon and model-provider credentials outside this compute. + +```sh +~/.parsar/build/agents-executor/agents-api-codex-executor \ + --remote https://agents.example.com \ + --environment-id "$ENVIRONMENT_ID" \ + --credentials "$HOME/.parsar/executor.json" \ + --codex-bin /opt/codex/bin/codex +``` + +The URL is an explicitly trusted service endpoint. HTTPS uses native certificate +and hostname validation; HTTP is accepted only for loopback development. +Userinfo, query strings and fragments are rejected. Native custom CA support uses +`CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE`; no certificate verification bypass is +provided. Native HTTP(S) proxy behavior is retained. + +The JSON requires a canonical nonzero UUID `key_id` and the issued 43-character +base64url `executor_token`. The optional `environment_id` may be omitted or null +for a principal key. If present, it must be a canonical UUID equal to the requested +Environment. The server authorizes the key's stored principal and restrictions; +file metadata supplies local validation only. + +The credential is read once at startup, without ambient OpenAI login/API-key +fallback or raw secret command-line arguments. At the cutover, update the launcher +and replace old credential files together; files without `key_id` are rejected. +Rotate the key through the operator command, replace the private file, and restart +this launcher. Revocation closes the authorized connection through registry +checks; it is not immediate process quiescence. + +Executor state and helper aliases live under +`~/.parsar/codex-executor//` (or the absolute `PARSAR_HOME`). +The native executor's `CODEX_HOME` is scoped there independently of a user's Codex +login. Run the executable under an ordinary service supervisor. SIGINT and SIGTERM +ask the native library to shut down its sessions/processes before returning. +Generated code shares this executor's process user and filesystem visibility; +a private credential file or separate directory is not filesystem isolation. + +## Verification boundaries + +`make check-agents-executor` checks configuration, scoped credential handling, +formatting and Clippy. Native transport, TLS, sandbox/helper behavior and actual +model calls require the opt-in [Environment fixtures](../../services/agents-api/tests/native/README.md). +Record their prerequisites and results separately; unit tests and successful +linking alone do not establish a usable executor deployment. diff --git a/packages/codex-executor/rust-toolchain.toml b/packages/codex-executor/rust-toolchain.toml new file mode 100644 index 000000000..38ab2c6bd --- /dev/null +++ b/packages/codex-executor/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +components = ["clippy", "rustfmt"] diff --git a/packages/codex-executor/src/bin/directory.rs b/packages/codex-executor/src/bin/directory.rs new file mode 100644 index 000000000..983df1942 --- /dev/null +++ b/packages/codex-executor/src/bin/directory.rs @@ -0,0 +1,60 @@ +use rustix::fs::FileType; +#[path = "../directory.rs"] +mod directory; +use directory::observe; +#[path = "../workspace_path.rs"] +mod workspace_path; +use serde_json::json; +use std::{io, path::Path}; +use workspace_path::{anchor, directory}; + +fn run() -> io::Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 3 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let limit = args[2] + .parse() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + if !(1..=4096).contains(&limit) { + return Err(io::ErrorKind::InvalidInput.into()); + } + let root = anchor(Path::new(&args[0]))?; + let selected = directory(&root, &args[1])?; + let result = observe(&selected, limit)?; + let entries: Vec<_> = result + .entries + .into_iter() + .map(|entry| { + let kind = match entry.kind { + FileType::RegularFile => "file", + FileType::Directory => "directory", + FileType::Symlink => "symlink", + _ => "other", + }; + json!({"name": entry.name, "kind": kind, "size_bytes": entry.size}) + }) + .collect(); + Ok(json!({"version": 1, "directory": {"entries": entries, "truncated": result.truncated}})) +} + +fn main() -> std::process::ExitCode { + let response = match run() { + Ok(value) => value, + Err(error) => json!({"version": 1, "error": match error.kind() { + io::ErrorKind::NotFound => "not_found", + io::ErrorKind::PermissionDenied => "permission_denied", + io::ErrorKind::InvalidInput | io::ErrorKind::NotADirectory => "invalid_path", + _ => "native_error", + }}), + }; + let bytes = match serde_json::to_vec(&response) { + Ok(bytes) if bytes.len() <= 4 * 1024 * 1024 => bytes, + _ => b"{\"version\":1,\"error\":\"too_large\"}".to_vec(), + }; + use io::Write; + match io::stdout().lock().write_all(&bytes) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(_) => std::process::ExitCode::FAILURE, + } +} diff --git a/packages/codex-executor/src/bin/export.rs b/packages/codex-executor/src/bin/export.rs new file mode 100644 index 000000000..d1ca3672e --- /dev/null +++ b/packages/codex-executor/src/bin/export.rs @@ -0,0 +1,20 @@ +#[path = "../directory.rs"] +mod directory; +#[path = "../export.rs"] +mod export; +#[path = "../workspace_path.rs"] +mod workspace_path; + +use std::{io, path::Path, process::ExitCode}; + +fn main() -> ExitCode { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.len() != 1 { + return ExitCode::FAILURE; + } + // A valid archive prefix alone is not success: callers must also observe exit 0. + match export::outputs(Path::new(&args[0]), io::stdout().lock()) { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} diff --git a/packages/codex-executor/src/bin/write.rs b/packages/codex-executor/src/bin/write.rs new file mode 100644 index 000000000..bd3f0c92c --- /dev/null +++ b/packages/codex-executor/src/bin/write.rs @@ -0,0 +1,39 @@ +#[path = "../workspace_path.rs"] +mod workspace_path; +#[path = "../write_file.rs"] +mod write_file; + +use serde_json::json; +use std::io::{self, Write}; +use std::path::Path; + +fn run() -> Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 4 { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let size = args[2] + .parse::() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + write_file::install( + Path::new(&args[0]), + &args[1], + size, + io::stdin().lock(), + Path::new(&args[3]), + )?; + Ok(size) +} + +fn main() -> std::process::ExitCode { + let response = match run() { + Ok(size) => json!({"version": 1, "outcome": "completed", "size_bytes": size}), + Err(failure) => { + json!({"version": 1, "outcome": if failure.committed { "unknown" } else { "failed" }, "error": if failure.error.kind() == io::ErrorKind::InvalidInput { "invalid_input" } else { "write_failed" }}) + } + }; + if writeln!(io::stdout().lock(), "{response}").is_err() { + return std::process::ExitCode::FAILURE; + } + std::process::ExitCode::SUCCESS +} diff --git a/packages/codex-executor/src/directory.rs b/packages/codex-executor/src/directory.rs new file mode 100644 index 000000000..0d77f898e --- /dev/null +++ b/packages/codex-executor/src/directory.rs @@ -0,0 +1,68 @@ +use rustix::fs::{AtFlags, Dir, FileType, statat}; +use std::io; +use std::os::fd::OwnedFd; + +#[derive(Debug, PartialEq)] +pub(crate) struct Entry { + pub name: String, + pub kind: FileType, + pub size: Option, +} + +#[derive(Debug)] +pub(crate) struct Observation { + pub entries: Vec, + pub truncated: bool, + pub visited: usize, +} + +fn invalid() -> io::Error { + io::ErrorKind::InvalidInput.into() +} + +pub(crate) fn observe(fd: &OwnedFd, limit: usize) -> io::Result { + if !(1..=4096).contains(&limit) { + return Err(invalid()); + } + let mut result = Observation { + entries: Vec::with_capacity(limit), + truncated: false, + visited: 0, + }; + for entry in Dir::read_from(fd)? { + let entry = entry?; + let name = entry.file_name().to_str().map_err(|_| invalid())?; + if name == "." || name == ".." { + continue; + } + result.visited += 1; + if result.entries.len() == limit { + result.truncated = true; + break; + } + if name.len() > 255 || name.contains(['\\', '\0', '\r', '\n']) { + return Err(invalid()); + } + let metadata = statat(fd, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW)?; + let kind = FileType::from_raw_mode(metadata.st_mode); + let size = if kind == FileType::RegularFile { + Some(u64::try_from(metadata.st_size).map_err(|_| invalid())?) + } else { + None + }; + result.entries.push(Entry { + name: name.into(), + kind, + size, + }); + } + Ok(result) +} + +#[cfg(test)] +#[path = "directory_tests.rs"] +mod tests; + +// Tests counting process-wide descriptors must exclude concurrent fixture I/O. +#[cfg(test)] +pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/packages/codex-executor/src/directory_tests.rs b/packages/codex-executor/src/directory_tests.rs new file mode 100644 index 000000000..39f923dc9 --- /dev/null +++ b/packages/codex-executor/src/directory_tests.rs @@ -0,0 +1,154 @@ +use super::*; +use crate::workspace_path::{anchor, directory}; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct Fixture { + path: PathBuf, + _guard: std::sync::MutexGuard<'static, ()>, +} +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let guard = TEST_LOCK.lock().unwrap(); + let root = PathBuf::from(std::env::var_os("HOME").expect("HOME required")) + .join(".parsar/tests/scoped-directory"); + fs::create_dir_all(&root).unwrap(); + let path = root.join(format!( + "fixture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self { + path, + _guard: guard, + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.path).unwrap(); + } +} + +#[test] +fn ancestor_replacement_after_open_stays_on_authorized_directory() { + let f = Fixture::new(); + fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); + fs::create_dir_all(f.path.join("outside/sub")).unwrap(); + fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); + fs::write(f.path.join("workspace/a/sub/outside-secret"), b"decoy").unwrap(); + fs::write(f.path.join("outside/sub/outside-secret"), b"outside-secret").unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let selected = directory(&root, "a/sub").unwrap(); + fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); + let path_names: Vec<_> = fs::read_dir(f.path.join("workspace/a/sub")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(path_names, ["outside-secret"]); + let mut safe = observe(&selected, 16).unwrap(); + safe.entries.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!( + safe.entries, + [ + Entry { + name: "inside".into(), + kind: FileType::RegularFile, + size: Some(6) + }, + Entry { + name: "outside-secret".into(), + kind: FileType::RegularFile, + size: Some(5) + }, + ] + ); + assert!(!safe.truncated); + assert!(directory(&root, "a/sub").is_err()); + fs::remove_file(f.path.join("workspace/a")).unwrap(); + fs::rename(f.path.join("workspace/held"), f.path.join("workspace/a")).unwrap(); + for name in path_names { + assert!( + fs::symlink_metadata(f.path.join("workspace/a/sub").join(name)) + .unwrap() + .is_file() + ); + } +} + +#[test] +fn ancestor_replacement_between_component_opens_stays_on_original_fd() { + let f = Fixture::new(); + fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); + fs::create_dir_all(f.path.join("outside/sub")).unwrap(); + fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); + fs::write(f.path.join("outside/sub/outside"), b"outside").unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let parent = directory(&root, "a").unwrap(); + fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); + let selected = directory(&parent, "sub").unwrap(); + assert_eq!(observe(&selected, 1).unwrap().entries[0].name, "inside"); +} + +#[test] +fn bound_applies_before_collecting_directory_names() { + let f = Fixture::new(); + for i in 0..5000 { + fs::write(f.path.join(format!("entry-{i:05}")), b"x").unwrap(); + } + let root = anchor(&f.path).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 3).unwrap(); + assert_eq!(result.entries.len(), 3); + assert_eq!(result.visited, 4); + assert!(result.truncated); +} + +#[test] +fn symlink_metadata_does_not_follow_the_target_and_invalid_paths_fail() { + let f = Fixture::new(); + fs::create_dir(f.path.join("workspace")).unwrap(); + fs::write( + f.path.join("outside"), + b"must not become the returned file size", + ) + .unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/link")).unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 1).unwrap(); + assert_eq!( + result.entries, + [Entry { + name: "link".into(), + kind: FileType::Symlink, + size: None + }] + ); + assert!(!result.truncated); + for path in ["/outside", "..", "a/../b", ".", "a//b", "a/", "a\\b", "a\0"] { + assert!(directory(&root, path).is_err(), "{path:?}"); + } + assert!(observe(&selected, 0).is_err()); + assert!(observe(&selected, 4097).is_err()); +} + +#[test] +fn repeated_reads_release_directory_descriptors() { + let f = Fixture::new(); + let count = || fs::read_dir("/proc/self/fd").unwrap().count(); + let before = count(); + for _ in 0..200 { + let root = anchor(&f.path).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 1).unwrap(); + assert!(result.entries.is_empty() && !result.truncated); + } + assert_eq!(count(), before); +} diff --git a/packages/codex-executor/src/export.rs b/packages/codex-executor/src/export.rs new file mode 100644 index 000000000..5ba7df839 --- /dev/null +++ b/packages/codex-executor/src/export.rs @@ -0,0 +1,142 @@ +use crate::{directory::observe, workspace_path}; +use rustix::fs::{FileType, Mode, OFlags, Stat, fstat, openat}; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::OwnedFd; +use std::path::Path; + +const MAX_FILE_BYTES: u64 = 200 * 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 500 * 1024 * 1024; +// Defensive traversal bounds, not upstream protocol limits. +const MAX_ENTRIES: usize = 4096; +const MAX_DEPTH: usize = 64; + +#[derive(Default)] +struct Budget { + entries: usize, + bytes: u64, +} + +fn invalid() -> io::Error { + io::ErrorKind::InvalidData.into() +} + +pub(crate) fn outputs(root: &Path, output: impl Write) -> io::Result<()> { + let root = workspace_path::anchor(root)?; + let mut archive = tar::Builder::new(output); + let selected = match workspace_path::directory(&root, "outputs") { + Ok(fd) => fd, + Err(error) if error.kind() == io::ErrorKind::NotFound => return archive.finish(), + Err(error) => return Err(error), + }; + let device = fstat(&root)?.st_dev; + walk( + &selected, + "outputs", + device, + 0, + &mut Budget::default(), + &mut archive, + )?; + archive.finish() +} + +fn walk( + parent: &OwnedFd, + path: &str, + device: u64, + depth: usize, + budget: &mut Budget, + archive: &mut tar::Builder, +) -> io::Result<()> { + if depth > MAX_DEPTH || path.len() > 4096 { + return Err(invalid()); + } + let before = fstat(parent)?; + if before.st_dev != device { + return Err(invalid()); + } + let mut listing = observe(parent, MAX_ENTRIES)?; + budget.entries += listing.visited; + if listing.truncated || budget.entries > MAX_ENTRIES { + return Err(invalid()); + } + listing.entries.sort_by(|a, b| a.name.cmp(&b.name)); + for entry in &listing.entries { + let path = format!("{path}/{}", entry.name); + if path.len() > 4096 { + return Err(invalid()); + } + match entry.kind { + FileType::Directory => { + let child = workspace_path::directory(parent, &entry.name)?; + walk(&child, &path, device, depth + 1, budget, archive)?; + } + FileType::RegularFile => { + let file = openat( + parent, + entry.name.as_str(), + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + )?; + append(File::from(file), &path, device, budget, archive)?; + } + _ => return Err(invalid()), + } + } + // Directory timestamps may have coarser resolution than successive mutations. + let mut after = observe(parent, MAX_ENTRIES)?; + after.entries.sort_by(|a, b| a.name.cmp(&b.name)); + if after.truncated || after.entries != listing.entries || !unchanged(&before, &fstat(parent)?) { + return Err(invalid()); + } + Ok(()) +} + +fn append( + mut file: File, + path: &str, + device: u64, + budget: &mut Budget, + archive: &mut tar::Builder, +) -> io::Result<()> { + let before = fstat(&file)?; + if FileType::from_raw_mode(before.st_mode) != FileType::RegularFile + || before.st_dev != device + || before.st_nlink != 1 + { + return Err(invalid()); + } + let size = u64::try_from(before.st_size).map_err(|_| invalid())?; + if size > MAX_FILE_BYTES || size > MAX_TOTAL_BYTES - budget.bytes { + return Err(invalid()); + } + budget.bytes += size; + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Regular); + header.set_mode(0o600); + header.set_size(size); + // Read through the held descriptor, never reopen an attacker-controlled path. + let mut body = (&mut file).take(size); + archive.append_data(&mut header, path, &mut body)?; + if body.limit() != 0 || !unchanged(&before, &fstat(&file)?) { + return Err(invalid()); + } + Ok(()) +} + +fn unchanged(a: &Stat, b: &Stat) -> bool { + a.st_dev == b.st_dev + && a.st_ino == b.st_ino + && a.st_mode == b.st_mode + && a.st_nlink == b.st_nlink + && a.st_size == b.st_size + && a.st_mtime == b.st_mtime + && a.st_mtime_nsec == b.st_mtime_nsec + && a.st_ctime == b.st_ctime + && a.st_ctime_nsec == b.st_ctime_nsec +} + +#[cfg(test)] +#[path = "export_tests.rs"] +mod tests; diff --git a/packages/codex-executor/src/export_tests.rs b/packages/codex-executor/src/export_tests.rs new file mode 100644 index 000000000..9fd81bfa4 --- /dev/null +++ b/packages/codex-executor/src/export_tests.rs @@ -0,0 +1,166 @@ +use super::*; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::{fs::symlink, net::UnixListener}; + +#[test] +fn captures_nested_binary_empty_and_long_names_without_other_workspace_files() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("outputs/nested")).unwrap(); + fs::write(root.path().join("private.txt"), "not an output").unwrap(); + fs::write(root.path().join("outputs/empty"), []).unwrap(); + let body: Vec<_> = (0..=255).cycle().take(131_079).collect(); + let name = format!("outputs/nested/{}", "x".repeat(200)); + fs::write(root.path().join(&name), &body).unwrap(); + let mut bytes = Vec::new(); + outputs(root.path(), &mut bytes).unwrap(); + let mut archive = tar::Archive::new(bytes.as_slice()); + let mut actual = BTreeMap::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + assert!(entry.header().entry_type().is_file()); + let name = entry.path().unwrap().to_str().unwrap().to_owned(); + let mut data = Vec::new(); + entry.read_to_end(&mut data).unwrap(); + actual.insert(name, data); + } + assert_eq!( + actual, + BTreeMap::from([("outputs/empty".into(), vec![]), (name, body)]) + ); +} + +#[test] +fn absent_outputs_is_an_empty_archive() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + let root = tempfile::tempdir().unwrap(); + let mut bytes = Vec::new(); + outputs(root.path(), &mut bytes).unwrap(); + assert_eq!( + tar::Archive::new(bytes.as_slice()) + .entries() + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn rejects_links_and_special_files_without_exposing_their_contents() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + for kind in ["root-symlink", "file-symlink", "hardlink", "socket"] { + let root = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let secret = b"private daemon credential marker"; + fs::write(other.path().join("secret"), secret).unwrap(); + if kind == "root-symlink" { + symlink(other.path(), root.path().join("outputs")).unwrap(); + } else { + fs::create_dir(root.path().join("outputs")).unwrap(); + } + let target = root.path().join("outputs/file"); + let _socket = match kind { + "file-symlink" => { + symlink(other.path().join("secret"), target).unwrap(); + None + } + "hardlink" => { + fs::hard_link(other.path().join("secret"), target).unwrap(); + None + } + "socket" => Some(UnixListener::bind(target).unwrap()), + _ => None, + }; + let mut bytes = Vec::new(); + assert!(outputs(root.path(), &mut bytes).is_err(), "{kind}"); + assert!( + !bytes.windows(secret.len()).any(|part| part == secret), + "{kind}" + ); + } +} + +#[test] +fn enforces_file_and_total_bytes_without_loading_bodies() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + for sizes in [ + vec![MAX_FILE_BYTES + 1], + vec![MAX_FILE_BYTES, MAX_FILE_BYTES, 101 << 20], + ] { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("outputs")).unwrap(); + for (index, size) in sizes.iter().enumerate() { + File::create(root.path().join(format!("outputs/{index}"))) + .unwrap() + .set_len(*size) + .unwrap(); + } + assert!(outputs(root.path(), io::sink()).is_err()); + } +} + +#[test] +fn rejects_a_changed_file_or_directory_during_export() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + struct Mutate<'a> { + root: &'a Path, + directory: bool, + done: bool, + } + impl Write for Mutate<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if !self.done { + self.done = true; + if self.directory { + fs::write(self.root.join("outputs/new"), "late")?; + } else { + fs::write(self.root.join("outputs/file"), "changed-length")?; + } + } + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + for directory in [false, true] { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("outputs")).unwrap(); + fs::write(root.path().join("outputs/file"), "initial").unwrap(); + assert!( + outputs( + root.path(), + Mutate { + root: root.path(), + directory, + done: false + } + ) + .is_err(), + "directory={directory}" + ); + } +} + +#[test] +fn rejects_truncated_traversal_and_broken_destination() { + let _guard = crate::directory::TEST_LOCK.lock().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("outputs")).unwrap(); + for index in 0..=MAX_ENTRIES { + fs::write(root.path().join(format!("outputs/{index}")), []).unwrap(); + } + assert!(outputs(root.path(), io::sink()).is_err()); + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let empty = tempfile::tempdir().unwrap(); + assert!(outputs(empty.path(), Broken).is_err()); +} diff --git a/packages/codex-executor/src/main.rs b/packages/codex-executor/src/main.rs new file mode 100644 index 000000000..99e1ee13f --- /dev/null +++ b/packages/codex-executor/src/main.rs @@ -0,0 +1,80 @@ +mod options; +mod runtime; + +use clap::Parser; +use codex_api::AuthProvider; +use codex_exec_server::{ + ExecServerError, RemoteEnvironmentConfig, run_remote_environment_until_shutdown, +}; +use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; +use http::{HeaderMap, HeaderValue}; +use std::sync::Arc; + +struct ExecutorAuth(HeaderValue); + +impl AuthProvider for ExecutorAuth { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert(http::header::AUTHORIZATION, self.0.clone()); + } +} + +fn main() { + if let Err(message) = run() { + eprintln!("{message}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), &'static str> { + let options = options::Options::parse(); + options.validate()?; + let paths = runtime::prepare(&options)?; + + // No threads exist yet; native helpers must use this executor's private state. + unsafe { std::env::set_var("CODEX_HOME", &paths.codex_home) }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .map_err(|_| "could not start executor runtime")?; + runtime.block_on(async { + let authorization = options.authorization().await?; + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|_| "could not listen for executor shutdown")?; + let config = RemoteEnvironmentConfig::new( + options.remote, + options.environment_id, + Arc::new(ExecutorAuth(authorization)), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .map_err(|_| "invalid executor connection configuration")?; + eprintln!("Starting native executor; press Ctrl-C to stop."); + run_remote_environment_until_shutdown(config, paths.native, async { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + }) + .await + .map_err(|error| match error { + ExecServerError::EnvironmentRegistryAuth(_) => { + "registry rejected the executor credential" + } + ExecServerError::EnvironmentRegistryConfig(_) => { + "native registry configuration is invalid" + } + ExecServerError::EnvironmentRegistryHttp { status, .. } => { + eprintln!("Registry HTTP status: {}", status.as_u16()); + "registry rejected the native registration request" + } + ExecServerError::EnvironmentRegistryRequest(_) => { + "registry connection failed; check network, TLS and proxy configuration" + } + ExecServerError::WebSocketConnect { .. } + | ExecServerError::WebSocketConnectTimeout { .. } + | ExecServerError::WebSocketConfiguration(_) => "executor WebSocket connection failed", + _ => "native executor stopped with a protocol or execution error", + }) + }) +} diff --git a/packages/codex-executor/src/options.rs b/packages/codex-executor/src/options.rs new file mode 100644 index 000000000..3e9c6d494 --- /dev/null +++ b/packages/codex-executor/src/options.rs @@ -0,0 +1,128 @@ +use clap::Parser; +use codex_exec_server::read_sensitive_file_to_string; +use http::HeaderValue; +use serde::Deserialize; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use url::{Host, Url}; +use uuid::Uuid; + +#[derive(Parser)] +#[command( + version, + about = "Agents API executor using Codex 0.153.4 native libraries. Requires the matching native Codex installation." +)] +pub struct Options { + /// Third-party registry HTTPS URL; HTTP is allowed only on loopback for development. + #[arg(long)] + pub remote: String, + /// Canonical Environment UUID issued by Agents API. + #[arg(long)] + pub environment_id: String, + /// Absolute path to mode-0600 JSON from agents-api-environment-key. + #[arg(long)] + pub credentials: PathBuf, + /// Absolute path to the native Codex 0.153.4 executable, with its installation resources intact. + #[arg(long)] + pub codex_bin: PathBuf, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Credential { + key_id: String, + environment_id: Option, + executor_token: String, +} + +impl Options { + pub fn validate(&self) -> Result<(), &'static str> { + validate_remote(&self.remote)?; + let id = Uuid::parse_str(&self.environment_id) + .map_err(|_| "environment ID must be a canonical UUID")?; + if id.to_string() != self.environment_id { + return Err("environment ID must be a canonical UUID"); + } + if !self.credentials.is_absolute() || !self.codex_bin.is_absolute() { + return Err("credentials and native Codex paths must be absolute"); + } + Ok(()) + } + + pub async fn authorization(&self) -> Result { + let metadata = tokio::fs::symlink_metadata(&self.credentials) + .await + .map_err(|_| "could not read executor credential file")?; + if !metadata.is_file() + || metadata.len() > 65536 + || metadata.permissions().mode() & 0o077 != 0 + { + return Err( + "executor credentials require a private regular file (mode 0600, at most 64 KiB)", + ); + } + let json = read_sensitive_file_to_string(&self.credentials) + .await + .map_err(|_| "could not read executor credential file")?; + credential_header(&json, &self.environment_id) + } +} + +fn validate_remote(remote: &str) -> Result<(), &'static str> { + let url = Url::parse(remote).map_err(|_| "invalid executor registry URL")?; + let loopback = match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + }; + if url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !(url.scheme() == "https" || (url.scheme() == "http" && loopback)) + { + return Err( + "registry URL requires HTTPS (HTTP only on loopback), without userinfo, query or fragment", + ); + } + Ok(()) +} + +fn credential_header(json: &str, environment: &str) -> Result { + let credential: Credential = serde_json::from_str(json).map_err(|_| { + "invalid executor credential JSON; expected key_id, executor_token and optional environment_id" + })?; + let key_id = Uuid::parse_str(&credential.key_id) + .map_err(|_| "executor credential key ID must be a canonical nonzero UUID")?; + if key_id.is_nil() || key_id.to_string() != credential.key_id { + return Err("executor credential key ID must be a canonical nonzero UUID"); + } + if let Some(environment_id) = credential.environment_id { + let id = Uuid::parse_str(&environment_id) + .map_err(|_| "executor credential Environment ID must be a canonical UUID")?; + if id.to_string() != environment_id { + return Err("executor credential Environment ID must be a canonical UUID"); + } + if environment_id != environment { + return Err("executor credential belongs to a different Environment"); + } + } + if credential.executor_token.len() != 43 + || !credential + .executor_token + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err("invalid issued executor credential"); + } + let mut header = HeaderValue::from_str(&format!("Bearer {}", credential.executor_token)) + .map_err(|_| "invalid issued executor credential")?; + header.set_sensitive(true); + Ok(header) +} + +#[cfg(test)] +#[path = "options_tests.rs"] +mod tests; diff --git a/packages/codex-executor/src/options_tests.rs b/packages/codex-executor/src/options_tests.rs new file mode 100644 index 000000000..af8125fae --- /dev/null +++ b/packages/codex-executor/src/options_tests.rs @@ -0,0 +1,171 @@ +use super::*; + +const ENVIRONMENT: &str = "20cc9e86-39a0-42ca-a2cd-218c7cd2eced"; +const OTHER_ENVIRONMENT: &str = "1eb47f85-842d-43f2-bbca-42b54cf127cc"; +const KEY_ID: &str = "b626f2e2-4678-434c-a40b-f7be962bc4ea"; +const TOKEN: &str = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; + +fn credential() -> serde_json::Value { + serde_json::json!({"key_id": KEY_ID, "executor_token": TOKEN}) +} + +fn options() -> Options { + Options { + remote: "https://registry.example.test/prefix".into(), + environment_id: ENVIRONMENT.into(), + credentials: "/private/executor.json".into(), + codex_bin: "/opt/codex/bin/codex".into(), + } +} + +#[test] +fn explicit_third_party_https_and_loopback_are_supported() { + for remote in [ + "https://registry.example.test/prefix", + "https://10.0.0.10:8443", + "http://127.0.0.1:8000", + "http://[::1]:8000", + ] { + assert!(validate_remote(remote).is_ok()); + } + for remote in [ + "http://registry.example.test", + "http://10.0.0.10", + "https://user:secret@registry.example.test", + "https://registry.example.test?token=secret", + "https://registry.example.test#secret", + "file:///private/config", + ] { + assert!(validate_remote(remote).is_err()); + } +} + +#[test] +fn ambiguous_environment_and_relative_paths_are_rejected() { + let mut opts = options(); + opts.environment_id = ENVIRONMENT.to_uppercase(); + assert!(opts.validate().is_err()); + opts = options(); + opts.credentials = "executor.json".into(); + assert!(opts.validate().is_err()); +} + +#[test] +fn principal_credential_accepts_omitted_or_null_environment_without_exposing_secret_in_debug() { + let unrestricted = credential(); + let mut null_restriction = credential(); + null_restriction["environment_id"] = serde_json::Value::Null; + for json in [unrestricted, null_restriction] { + for environment in [ENVIRONMENT, OTHER_ENVIRONMENT] { + let header = credential_header(&json.to_string(), environment).unwrap(); + assert_eq!(header.to_str().unwrap(), format!("Bearer {TOKEN}")); + assert!(header.is_sensitive()); + assert!(!format!("{header:?}").contains(TOKEN)); + } + } +} + +#[test] +fn exact_credential_requires_its_canonical_environment() { + let mut json = credential(); + json["environment_id"] = ENVIRONMENT.into(); + let json = json.to_string(); + let header = credential_header(&json, ENVIRONMENT).unwrap(); + assert_eq!(header.to_str().unwrap(), format!("Bearer {TOKEN}")); + assert!(header.is_sensitive()); + assert!(!format!("{header:?}").contains(TOKEN)); + let error = credential_header(&json, OTHER_ENVIRONMENT).unwrap_err(); + assert_eq!( + error, + "executor credential belongs to a different Environment" + ); + assert!(!error.contains(TOKEN)); + for environment in ["invalid".to_owned(), ENVIRONMENT.to_uppercase()] { + let mut json = credential(); + json["environment_id"] = environment.clone().into(); + let error = credential_header(&json.to_string(), &environment).unwrap_err(); + assert!(!error.contains(TOKEN)); + } +} + +#[test] +fn old_credential_without_key_id_fails_clearly() { + let json = serde_json::json!({"environment_id": ENVIRONMENT, "executor_token": TOKEN}); + let error = credential_header(&json.to_string(), ENVIRONMENT).unwrap_err(); + assert!(error.contains("key_id")); + assert!(!error.contains(TOKEN)); +} + +#[test] +fn key_id_requires_a_canonical_nonzero_uuid_without_exposing_invalid_values() { + for key_id in [ + serde_json::Value::Null, + serde_json::json!(123), + "".into(), + "00000000-0000-0000-0000-000000000000".into(), + KEY_ID.to_uppercase().into(), + KEY_ID.replace('-', "").into(), + TOKEN.into(), + ] { + let mut json = credential(); + json["key_id"] = key_id; + let error = credential_header(&json.to_string(), ENVIRONMENT).unwrap_err(); + assert!(!error.contains(TOKEN)); + } +} + +#[test] +fn malformed_json_unknown_fields_and_invalid_tokens_do_not_expose_secrets() { + let mut unknown_field = credential(); + unknown_field[TOKEN] = TOKEN.into(); + let json = credential().to_string(); + for invalid in [ + format!("{{ not json {TOKEN}"), + unknown_field.to_string(), + json.replace(TOKEN, "sk-not-an-issued-executor-key"), + json.replace(TOKEN, &"a".repeat(42)), + json.replace(TOKEN, &"a".repeat(44)), + json.replace(TOKEN, &format!("{}+", "a".repeat(42))), + json.replace("executor_token", "api_key"), + ] { + let error = credential_header(&invalid, ENVIRONMENT).unwrap_err(); + assert!(!error.contains(TOKEN)); + } +} + +#[tokio::test] +async fn only_private_regular_credential_files_are_accepted() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let base = PathBuf::from(std::env::var_os("HOME").unwrap()) + .join(".parsar") + .join("executor-credential-tests") + .join(Uuid::new_v4().to_string()); + std::fs::create_dir_all(&base).unwrap(); + let mut opts = options(); + opts.credentials = base.join("credential.json"); + assert!(opts.authorization().await.is_err()); + let json = credential().to_string(); + std::fs::write(&opts.credentials, &json).unwrap(); + std::fs::set_permissions(&opts.credentials, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(opts.authorization().await.is_err()); + std::fs::set_permissions(&opts.credentials, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(opts.authorization().await.is_ok()); + let link = base.join("alias.json"); + symlink(&opts.credentials, &link).unwrap(); + opts.credentials = link; + assert!(opts.authorization().await.is_err()); + opts.credentials = base.clone(); + assert!(opts.authorization().await.is_err()); + opts.credentials = base.join("credential.json"); + let mut padded_json = json; + padded_json.push_str(&" ".repeat(65536 - padded_json.len())); + std::fs::write(&opts.credentials, &padded_json).unwrap(); + assert!(opts.authorization().await.is_ok()); + padded_json.push(' '); + std::fs::write(&opts.credentials, &padded_json).unwrap(); + let error = opts.authorization().await.unwrap_err(); + assert!(!error.contains(TOKEN)); + assert!(error.contains("64 KiB")); + std::fs::remove_dir_all(base).unwrap(); +} diff --git a/packages/codex-executor/src/runtime.rs b/packages/codex-executor/src/runtime.rs new file mode 100644 index 000000000..f9a78d75a --- /dev/null +++ b/packages/codex-executor/src/runtime.rs @@ -0,0 +1,68 @@ +use crate::options::Options; +use codex_exec_server::ExecServerRuntimePaths; +use std::io::Read; +use std::os::unix::fs::{DirBuilderExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub struct RuntimePaths { + pub codex_home: PathBuf, + pub native: ExecServerRuntimePaths, +} + +pub fn prepare(options: &Options) -> Result { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { + return Err("this executor currently supports Linux x86_64 only"); + } + let base = match std::env::var_os("PARSAR_HOME") { + Some(path) => PathBuf::from(path), + None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is required")?).join(".parsar"), + }; + if !base.is_absolute() { + return Err("executor state directory must be absolute"); + } + let state = base.join("codex-executor").join(&options.environment_id); + let codex_home = state.join("codex"); + private_directory(&codex_home)?; + let binary = options + .codex_bin + .canonicalize() + .map_err(|_| "native Codex executable is unavailable")?; + let mut magic = [0u8; 4]; + std::fs::File::open(&binary) + .and_then(|mut file| file.read_exact(&mut magic)) + .map_err(|_| "could not read native Codex executable")?; + if magic != *b"\x7fELF" { + return Err("--codex-bin requires the native ELF executable, not a wrapper"); + } + let version = Command::new(&binary) + .arg("--version") + .env("CODEX_HOME", &codex_home) + .output() + .map_err(|_| "could not verify native Codex executable")?; + if !version.status.success() + || String::from_utf8_lossy(&version.stdout).trim() != "codex-cli 0.153.4" + { + return Err("native Codex 0.153.4 is required"); + } + let sandbox = state.join("codex-linux-sandbox"); + match std::fs::read_link(&sandbox) { + Ok(target) if target == binary => {} + Ok(_) => return Err("executor sandbox helper points to another installation"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + symlink(&binary, &sandbox).map_err(|_| "could not create executor sandbox helper")?; + } + Err(_) => return Err("executor sandbox helper is unavailable"), + } + let native = ExecServerRuntimePaths::new(binary, Some(sandbox)) + .map_err(|_| "invalid native helper paths")?; + Ok(RuntimePaths { codex_home, native }) +} + +fn private_directory(path: &Path) -> Result<(), &'static str> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path) + .map_err(|_| "could not create private executor state") +} diff --git a/packages/codex-executor/src/workspace_path.rs b/packages/codex-executor/src/workspace_path.rs new file mode 100644 index 000000000..c6096989b --- /dev/null +++ b/packages/codex-executor/src/workspace_path.rs @@ -0,0 +1,69 @@ +use rustix::fs::{Mode, OFlags, open, openat}; +use std::io; +use std::os::fd::OwnedFd; +use std::path::{Component, Path}; + +fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "invalid directory request") +} + +pub(crate) fn anchor(root: &Path) -> io::Result { + if !root.is_absolute() || root.as_os_str().len() > 4096 { + return Err(invalid()); + } + let mut fd = open( + "/", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + for part in root.components() { + match part { + Component::RootDir => (), + Component::Normal(name) => { + fd = openat( + &fd, + name, + OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + _ => return Err(invalid()), + } + } + Ok(fd) +} + +pub(crate) fn directory(root: &OwnedFd, relative: &str) -> io::Result { + if relative.len() > 4096 { + return Err(invalid()); + } + let mut fd = openat( + root, + ".", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + if !relative.is_empty() { + for part in relative.split('/') { + if part.is_empty() + || part == "." + || part == ".." + || part.contains(['\\', '\0', '\r', '\n']) + { + return Err(invalid()); + } + fd = openat( + &fd, + part, + OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + } + Ok(openat( + &fd, + ".", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?) +} diff --git a/packages/codex-executor/src/write_file.rs b/packages/codex-executor/src/write_file.rs new file mode 100644 index 000000000..f650b57a9 --- /dev/null +++ b/packages/codex-executor/src/write_file.rs @@ -0,0 +1,133 @@ +use crate::workspace_path::{anchor, directory}; +use rustix::fs::{AtFlags, FileType, Mode, OFlags, fstat, openat, renameat, statat, unlinkat}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::OwnedFd; +use std::path::Path; + +pub(crate) const MAX_BYTES: u64 = 50 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) struct Failure { + pub committed: bool, + pub error: io::Error, +} + +impl From for Failure { + fn from(error: io::Error) -> Self { + Self { + committed: false, + error, + } + } +} + +pub(crate) fn install( + root: &Path, + relative: &str, + size: u64, + mut input: impl Read, + staging_root: &Path, +) -> Result<(), Failure> { + if size > MAX_BYTES + || staging_root.starts_with(root) + || root.starts_with(staging_root) + || relative.is_empty() + || relative.len() > 4096 + || relative + .split('/') + .any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['\\', '\0', '\r', '\n'])) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let (parent, leaf) = relative.rsplit_once('/').unwrap_or(("", relative)); + let root = anchor(root)?; + let parent = directory(&root, parent)?; + let staging_parent = directory(&anchor(staging_root)?, "")?; + if fstat(&parent).map_err(io::Error::from)?.st_dev + != fstat(&staging_parent).map_err(io::Error::from)?.st_dev + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + match statat(&parent, leaf, AtFlags::SYMLINK_NOFOLLOW) { + Ok(metadata) if FileType::from_raw_mode(metadata.st_mode) == FileType::RegularFile => (), + Ok(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + Err(rustix::io::Errno::NOENT) => (), + Err(error) => return Err(io::Error::from(error).into()), + } + let staging = Staging::new(&staging_parent)?; + let mut file = &staging.file; + let mut digest = Sha256::new(); + let mut remaining = size; + let mut buffer = [0u8; 64 * 1024]; + while remaining != 0 { + let count = remaining.min(buffer.len() as u64) as usize; + input.read_exact(&mut buffer[..count])?; + file.write_all(&buffer[..count])?; + digest.update(&buffer[..count]); + remaining -= count as u64; + } + // Native process/write has no stdin-close operation; the digest ends one frame. + let mut trailer = [0u8; 32]; + input.read_exact(&mut trailer)?; + if digest.finalize().as_slice() != trailer { + return Err(io::Error::from(io::ErrorKind::InvalidData).into()); + } + staging.file.sync_all()?; + staging.persist(&parent, leaf)?; + File::from(parent).sync_all().map_err(|error| Failure { + committed: true, + error, + })?; + File::from(staging_parent) + .sync_all() + .map_err(|error| Failure { + committed: true, + error, + })?; + Ok(()) +} + +struct Staging<'a> { + parent: &'a OwnedFd, + name: String, + file: File, + committed: bool, +} + +impl<'a> Staging<'a> { + fn new(parent: &'a OwnedFd) -> io::Result { + let name = format!(".parsar-upload-{}", uuid::Uuid::new_v4()); + let file = openat( + parent, + name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + )?; + Ok(Self { + parent, + name, + file: File::from(file), + committed: false, + }) + } + + fn persist(mut self, destination: &OwnedFd, leaf: &str) -> io::Result<()> { + renameat(self.parent, self.name.as_str(), destination, leaf)?; + self.committed = true; + Ok(()) + } +} + +impl Drop for Staging<'_> { + fn drop(&mut self) { + if !self.committed { + let _ = unlinkat(self.parent, self.name.as_str(), AtFlags::empty()); + } + } +} + +#[cfg(test)] +#[path = "write_file_tests.rs"] +mod tests; diff --git a/packages/codex-executor/src/write_file_tests.rs b/packages/codex-executor/src/write_file_tests.rs new file mode 100644 index 000000000..c14fab048 --- /dev/null +++ b/packages/codex-executor/src/write_file_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use std::fs; +use std::io::Cursor; +use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; + +fn fixture() -> tempfile::TempDir { + let root = std::path::PathBuf::from(std::env::var_os("HOME").expect("HOME required")) + .join(".parsar/tests/scoped-write"); + fs::create_dir_all(&root).unwrap(); + tempfile::tempdir_in(root).unwrap() +} + +fn frame(data: &[u8]) -> impl Read + '_ { + Cursor::new(data).chain(Cursor::new(Sha256::digest(data).to_vec())) +} + +fn install(root: &Path, relative: &str, size: u64, input: impl Read) -> Result<(), Failure> { + let staging = fixture(); + let result = super::install(root, relative, size, input, staging.path()); + assert_eq!(fs::read_dir(staging.path()).unwrap().count(), 0); + result +} + +#[test] +fn requires_existing_disjoint_nofollow_staging_before_consuming_input() { + let f = fixture(); + let root = f.path().join("workspace"); + let staging = f.path().join("private"); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::create_dir(&staging).unwrap(); + fs::write(root.join("file"), b"old").unwrap(); + symlink(&staging, f.path().join("link")).unwrap(); + struct Unread; + impl Read for Unread { + fn read(&mut self, _: &mut [u8]) -> io::Result { + panic!("invalid staging must be rejected before reading input") + } + } + for invalid in [ + root.clone(), + root.join("nested"), + f.path().to_path_buf(), + f.path().join("missing"), + f.path().join("link"), + staging.join("../private"), + Path::new("relative").to_path_buf(), + ] { + let failure = super::install(&root, "file", 3, Unread, &invalid).unwrap_err(); + assert!(!failure.committed); + assert_eq!(fs::read(root.join("file")).unwrap(), b"old"); + } + assert_eq!(fs::read_dir(&staging).unwrap().count(), 0); +} + +#[test] +fn replaces_only_destination_hard_link_and_keeps_exact_binary_bytes() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir(&root).unwrap(); + let outside = f.path().join("outside"); + fs::write(&outside, b"outside").unwrap(); + fs::hard_link(&outside, root.join("file")).unwrap(); + let input: Vec<_> = (0..=255).cycle().take(256 * 1024).collect(); + install(&root, "file", input.len() as u64, frame(&input)).unwrap(); + assert_eq!(fs::read(&outside).unwrap(), b"outside"); + assert_eq!(fs::read(root.join("file")).unwrap(), input); + assert_ne!( + fs::metadata(&outside).unwrap().ino(), + fs::metadata(root.join("file")).unwrap().ino() + ); + assert_eq!( + fs::metadata(root.join("file")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + install(&root, "file", 0, frame(b"")).unwrap(); + assert!(fs::read(root.join("file")).unwrap().is_empty()); +} + +#[test] +fn incomplete_excess_and_failed_input_preserve_old_file_and_remove_staging() { + let f = fixture(); + fs::write(f.path().join("file"), b"old").unwrap(); + for (size, data) in [(4, b"new".as_slice()), (2, b"new".as_slice())] { + assert!( + !install(f.path(), "file", size, Cursor::new(data)) + .unwrap_err() + .committed + ); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); + } + struct Failing; + impl Read for Failing { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + } + assert!(!install(f.path(), "file", 3, Failing).unwrap_err().committed); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); +} + +#[test] +fn rejects_symlinks_traversal_nonregular_targets_and_oversized_inputs() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir(&root).unwrap(); + fs::create_dir(f.path().join("outside")).unwrap(); + fs::write(f.path().join("outside/file"), b"canary").unwrap(); + symlink(f.path().join("outside/file"), root.join("link")).unwrap(); + symlink(f.path().join("outside"), root.join("parent")).unwrap(); + for name in [ + "link", + "parent/file", + "../outside/file", + "/file", + "a/../file", + "a//file", + "", + ".", + "a\\b", + "x\n", + "missing/file", + ] { + assert!( + install(&root, name, 3, Cursor::new(b"new")).is_err(), + "{name:?}" + ); + } + assert!(install(&root, "parent", 3, Cursor::new(b"new")).is_err()); + fs::create_dir(root.join("directory")).unwrap(); + assert!(install(&root, "directory", 0, io::empty()).is_err()); + assert!(install(&root, "large", MAX_BYTES + 1, io::empty()).is_err()); + assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"canary"); +} + +#[test] +fn supports_large_stream_without_whole_input_allocation() { + let f = fixture(); + let mut digest = Sha256::new(); + for _ in 0..(MAX_BYTES / 8192) { + digest.update([0x91; 8192]); + } + let input = io::repeat(0x91) + .take(MAX_BYTES) + .chain(Cursor::new(digest.finalize().to_vec())); + install(f.path(), "large", MAX_BYTES, input).unwrap(); + let file = fs::File::open(f.path().join("large")).unwrap(); + assert_eq!(file.metadata().unwrap().len(), MAX_BYTES); + let mut content = io::BufReader::new(file); + let mut chunk = [0u8; 8192]; + loop { + let n = content.read(&mut chunk).unwrap(); + if n == 0 { + break; + } + assert!(chunk[..n].iter().all(|x| *x == 0x91)); + } +} + +#[test] +fn ancestor_replacement_during_input_cannot_redirect_commit() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir_all(root.join("a")).unwrap(); + fs::create_dir(f.path().join("outside")).unwrap(); + fs::write(f.path().join("outside/file"), b"outside").unwrap(); + let mut changed = false; + let mut bytes = Cursor::new(b"inside"); + let input = std::io::Read::by_ref(&mut bytes); + struct Replace<'a> { + read: &'a mut Cursor<&'static [u8; 6]>, + change: Box, + } + impl Read for Replace<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + (self.change)(); + self.read.read(buf) + } + } + let input = Replace { + read: input, + change: Box::new(|| { + if !changed { + fs::rename(root.join("a"), root.join("held")).unwrap(); + symlink(f.path().join("outside"), root.join("a")).unwrap(); + changed = true; + } + }), + }; + install( + &root, + "a/file", + 6, + input.chain(Cursor::new(Sha256::digest(b"inside").to_vec())), + ) + .unwrap(); + assert_eq!(fs::read(root.join("held/file")).unwrap(), b"inside"); + assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"outside"); +} + +#[test] +fn requires_matching_commit_trailer_and_does_not_wait_for_eof() { + let f = fixture(); + fs::write(f.path().join("file"), b"old").unwrap(); + for data in [b"new".to_vec(), [b"new".as_slice(), &[0u8; 32]].concat()] { + assert!(install(f.path(), "file", 3, Cursor::new(data)).is_err()); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + } + struct NoMore; + impl Read for NoMore { + fn read(&mut self, _: &mut [u8]) -> io::Result { + panic!("must stop after commit trailer") + } + } + install(f.path(), "file", 3, frame(b"new").chain(NoMore)).unwrap(); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"new"); +} diff --git a/packages/codex-harness/README.md b/packages/codex-harness/README.md new file mode 100644 index 000000000..ded5a24ae --- /dev/null +++ b/packages/codex-harness/README.md @@ -0,0 +1,221 @@ +# Private Codex harness artifact + +`parsar-codex-harness` is an opt-in Linux amd64 executable. It embeds the pinned +Codex raw stdio runner and exposes private remote metadata and bounded reads through +the runner's own `EnvironmentManager`. The existing Go `JSONRPCClient` owns the +child and native execution transport. The file socket is local control IPC, +not another executor/Noise connection. Default daemon installation and public +feature admission are unchanged. + +## Source and patch ownership + +Parsar maintains this integration artifact. It is not the stock upstream binary. +`source.json` pins Codex 0.153.4 at +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, Rust 1.95.0, the manager hook and a +separate named-binary manifest overlay. A separately hashed bounded-read patch +exposes one native operation without exposing the general RPC client. The hook's canonical copy is +`patches/manager-exposure.patch`; the older native qualifications reference the +same file. Maintain its qualification and hash with every deliberate change. +Replace the hook when an equivalent maintained upstream entrypoint is selected +and independently accepted; never silently change the native pin. + +Preparation exports that exact Git commit, ignoring checkout modifications. It +checks the original lock, normalizes only the 149 upstream workspace package +versions, checks the resulting lock, applies the hashed patches and injects +`src/` into `codex-rs/app-server/parsar-harness/`. The named binary uses existing +app-server dependencies. No dependency resolution, client dependency or +third-party version change is part of the overlay. Mismatched identities fail. + +## Build and checks + +Install Rust 1.95.0 with rustfmt and Clippy, Python 3.10+, Git, tar, a C toolchain, +pkg-config and OpenSSL development headers on Linux amd64. Supply an existing +official Codex Git checkout containing the pinned commit: + +```sh +export AGENTS_HARNESS_NATIVE_SOURCE="$HOME/.parsar/references/codex-native" +make check-agents-harness +make check-agents-harness-native +make build-agents-harness +``` + +`make check` includes the lightweight packaging checks. The explicit native check +prepares a fresh export and runs the binary's locked unit tests, formatting and +Clippy. Native checking and a release build are required for artifact changes; +they are intentionally separate from the ordinary local gate. CI runs both on +affected paths. Real executor/provider acceptance is separate from all build checks. + +Builds and caches stay below `~/.parsar`. `CARGO_HOME`, `CARGO_TARGET_DIR` and +`AGENTS_HARNESS_BUILD_DIR` may override their defaults only within that root. +`RUSTUP_TOOLCHAIN` may select an installed alias; the build verifies that its +compiler reports exactly Rust 1.95.0. +The default output is `~/.parsar/build/agents-harness/parsar-codex-harness` beside +`provenance.json`. Provenance records the native commit, manifest, patches, +injected sources, prepared lock, toolchain and artifact hash. Acceptance must also +record the exact stock helper hash and check execution without the prepared source +tree present. Build provenance alone does not establish runtime compatibility. + +## Private startup contract + +The operator supplies these environment variables to the child: + +| Variable | Meaning | +| --- | --- | +| `PARSAR_CODEX_HARNESS_NATIVE` | Absolute path to the stock native 0.153.4 helper | +| `PARSAR_CODEX_HARNESS_ENVIRONMENT` | One canonical remote Environment UUID | +| `PARSAR_CODEX_HARNESS_WORKSPACE` | Absolute workspace path on that executor | +| `PARSAR_CODEX_HARNESS_IPC_ROOT` | New private directory below the caller's `~/.parsar` | + +The wrapper accepts the existing `-c` overrides and `app-server --stdio` with +`--enable`/`--disable` features. Unsupported options fail explicitly. Native +configuration and `CODEX_HOME` remain native concerns; provider credentials must +not be added to wrapper arguments. Public requests cannot select local process, +helper or socket targets. + +The private `--workspace-read-only` preparation mode uses a temporary `CODEX_HOME` +and native loader overrides to exclude system/managed execution configuration, +user/project configuration and plugin startup. Native security requirements stay +enabled. The legacy mixed `/etc/codex/managed_config.toml` profile is explicitly +rejected rather than silently dropping its enforced constraints. The daemon +supplies only process/transport environment variables and the +exact remote binding; model/MCP credentials are excluded. This mode is for unused +preparation and directory reads; daemon and adapter reject Start. It does not grant +public Files access or prove remote mutation retirement. Ordinary native execution +keeps its existing configuration loading. + +The endpoint is `files.sock` within the new `0700` IPC directory, with mode `0600` +and a same-UID peer check. Existing directories or socket paths are not overwritten. +Each bounded connection carries one JSON line with `environment_id` and a relative +`path`. The identity must match startup configuration, and the manager entry must +be remote and ready. Startup also matches the operator UUID to the native registry +Environment variable. The native manager uses its fixed `remote` key, independently +of that UUID. A response reports native metadata or a safe error. There is +no local filesystem fallback. Omitting `operation` selects metadata. An explicit +`operation: "read"` requires an integer `max_bytes` from 1 through 8 MiB; this is a +private adapter bound, not a public protocol limit. The response has `read` with +base64 `data_base64`, `truncated` and `close_acknowledged: true`. Reads use native +blocks of at most 1 MiB and one extra byte to distinguish an exact-bound file from +a truncated prefix. No snapshot consistency is promised for a changing file. +An explicit `operation: "list_directory"` requires `max_entries` from 1 through +4096 and allows an empty path for the bound workspace root. The native process +backend invokes the executor's qualified `agents-api-codex-directory` helper with +explicit argv, a read-only Linux sandbox and restricted network. Set the daemon's +operator-only `PARSAR_CODEX_DIRECTORY_HELPER` to its clean absolute executor path; +the daemon freezes it as `PARSAR_CODEX_HARNESS_DIRECTORY_HELPER`, overriding any +caller environment value. Missing/invalid selection rejects directory operations +without changing preparation, byte reads or model execution. The installation must +remain trusted and outside the workspace's writable tree, including aliases. +`directory` contains `entries` (`name`, `kind`, nullable `size_bytes`) and explicit +`truncated`, without ordering, pagination or snapshot guarantees. The helper uses +bounded descriptor-scoped enumeration, including symlinks without following them. +Only successful exit/output close with a complete version-1 frame yields a result. +The native retained output window is 1 MiB; event-sequence gaps reject lost output +rather than accepting a parseable tail. Aggregate output is capped at 4 MiB. +Rejection before closure terminates and drains the process; unknown cleanup fails +the existing owner without replay. These are private adapter semantics and do not +qualify a public Files endpoint. + +### Private file writes + +`operation: "write"` requires integer `size_bytes` from 0 through 50 MiB, a +nonempty clean relative path, and exactly that many raw bytes after the JSON +header. `max_bytes` and `max_entries` must be absent or null. One connection carries +one request; no input EOF is needed, and extra bytes do not create another request. +The complete bounded body is held in memory before native dispatch. Incomplete +input closes the connection without starting a remote operation. + +This endpoint requires both `PARSAR_CODEX_HARNESS_WRITE_HELPER` and +`PARSAR_CODEX_HARNESS_STAGING`, frozen before native dotenv loading. Their values +are clean absolute executor paths. Workspace and staging must be different +siblings below one non-root Environment parent; the helper must be outside that +parent. `--workspace-read-only` removes write admission even when these variables +are configured. No daemon capability or public request enables this profile. + +The operator must qualify the [installer placement requirements](../codex-executor/README.md#scoped-file-installer): +staging and its ancestors cannot be writable through native tools or aliases; +credentials, native history and other Environments stay outside the shared parent. +These path checks do not attest remote mounts or isolation. The native installer +gets one writable parent covering workspace and staging, minimal runtime/helper +reads, restricted network and a required Linux sandbox. Do not split workspace +and staging into separate sandbox bind mounts: cross-mount rename must fail +without a copying fallback. + +The same captured native process receives at most 64 KiB per stdin write followed +by the raw SHA-256 trailer. Native input retries retain the native request identity; +this wrapper does not replay or start a replacement. A complete version-1 commit +receipt, exact size, exit zero and output closure without sequence gaps returns +`{"write":{"size_bytes":N,"committed":true}}`. A confirmed pre-commit helper +failure returns `native_error`. Missing, invalid or unknown receipts and native +transport failures stop the owner as unresolved, even if the process exited or +termination was requested. Detached callers retain the same bounded native wait. +This does not establish successor safety, durable recovery, snapshot stability or +public Files.create. The private [native fixture](../../services/agents-api/tests/native/write/README.md) +qualifies the transport separately from real-model execution regression. + +The bounded-read hook captures one native RPC connection before opening a handle. +Open, ordered block reads and cleanup use that exact connection without recovery +or replay. A successful result requires a successful close response after all +reads. A server rejection settles that particular request; it does not establish +successful close. Ambiguous transport/protocol outcomes and unconfirmed close +stop the owner without delivering partial bytes or admitting another request. +Closing a replacement connection cannot settle an old handle. The native stock +stream remains unchanged; its asynchronous Drop cleanup is not used as a receipt. + +Startup freezes the operator binding before calling native `arg0_dispatch`. This +preserves native `CODEX_HOME/.env` credential loading and helper dispatch before +threads start, without letting dotenv replace private selectors. The native alias +guard lives until runtime teardown; explicit child re-execution uses the pinned +stock helper. + +Metadata, reads and directory requests share one ten-second deadline. Write +headers have the same limit; a valid write has sixty seconds total from connection +acceptance for its bounded body, native transfer and receipt. A stalled frame or response writer +closes its connection. Caller disconnect does not cancel an admitted native wait. +When the raw runner ends, stop new admission and pending frames, then drain the +admitted operation within its original deadline. A stopped owner need not deliver +the result to the caller. Preserve runner failures after a successful drain, and +report an unresolved drain as failure even if the runner exited normally. This +only accounts for the native future; it does not prove remote effect retirement. +The existing daemon RPC `Close` can force-kill this child after its 250 ms grace, +interrupting the drain. A daemon/Core file consumer must reconcile that boundary +and retain uncertainty before treating release as operation settlement; this +artifact change does not alter the RPC's existing local-reap contract. +If the native operation has not settled by the deadline, +the artifact exits with an error and closes admission; dropping the native wait +does not cancel remote work. Recovery must retain that uncertainty and must not +infer remote retirement from this local failure. Runtime shutdown waits at most +one second for blocking tasks, including native stdin, so a caller keeping its +input pipe open still observes local process exit. This is not a remote cleanup +guarantee. + +## Acceptance limits + +Qualification must use this final binary through the existing Go RPC caller and +an actual authenticated remote executor. Native execution creates a file; metadata +and bounded bytes are observed while idle, during execution, after cancellation +and following fresh process history continuation. Synthetic binary and empty files +supplement native-created files to check byte fidelity, exact bounds and truncation. +Each successful read must include its ordered close acknowledgment. Controlled tests cover startup/EOF, identity errors, +socket collision, oversized frames, stalled peers and early runner exit. Preserve +failed evidence and distinguish local child exit from remote mutation retirement. + +Raw stdio avoids a typed-notification parser and preserves the native transport. +This does not mean the Go adapter stores unknown notifications or that every +existing RPC queue/write path is production-qualified. IPC frame, concurrency and +deadline bounds do not establish general native filesystem resource limits. +Private metadata and byte reads do not prove path isolation. Directory access needs +separate actual native permission/isolation acceptance. None of these observations +establishes public Files semantics, a reusable idle +owner, Core authority, successor safety or complete output fidelity. These remain +separate admission and acceptance work. + +## Opt-in adapter launch + +Set `PARSAR_CODEX_HARNESS_BIN` to the absolute integrated artifact path and keep +`PARSAR_CODEX_BIN` pointing to the stock helper. The daemon uses the artifact only +for validated remote Codex preparations. It supplies the frozen binding and a new +private socket directory, retains the existing Prepared/Session/RPC lifecycle, +and cleans the directory after the child is reaped. Nonremote Codex and Claude +keep their current launch paths. No wrapper or new public capability is required. +The private file socket remains operator infrastructure; public Files and +Core ownership/retirement gates are separate work. diff --git a/packages/codex-harness/patches/artifact-target.patch b/packages/codex-harness/patches/artifact-target.patch new file mode 100644 index 000000000..721ebc2de --- /dev/null +++ b/packages/codex-harness/patches/artifact-target.patch @@ -0,0 +1,13 @@ +--- a/codex-rs/app-server/Cargo.toml ++++ b/codex-rs/app-server/Cargo.toml +@@ -15,6 +15,10 @@ + [[bin]] + name = "exec-server" + path = "src/bin/exec_server.rs" ++ ++[[bin]] ++name = "parsar-codex-harness" ++path = "parsar-harness/main.rs" + + [lib] + name = "codex_app_server" diff --git a/packages/codex-harness/patches/bounded-read.patch b/packages/codex-harness/patches/bounded-read.patch new file mode 100644 index 000000000..8953d9ea6 --- /dev/null +++ b/packages/codex-harness/patches/bounded-read.patch @@ -0,0 +1,551 @@ +diff --git a/codex-rs/exec-server/src/bounded_file_read.rs b/codex-rs/exec-server/src/bounded_file_read.rs +new file mode 100644 +index 0000000..c3e8535 +--- /dev/null ++++ b/codex-rs/exec-server/src/bounded_file_read.rs +@@ -0,0 +1,183 @@ ++use std::io; ++use std::sync::Arc; ++ ++use codex_utils_path_uri::PathUri; ++use uuid::Uuid; ++ ++use super::ExecServerClient; ++use super::ExecServerError; ++use super::LazyRemoteExecServerClient; ++use crate::FILE_READ_CHUNK_SIZE; ++use crate::protocol::FS_CLOSE_METHOD; ++use crate::protocol::FS_OPEN_METHOD; ++use crate::protocol::FS_READ_BLOCK_METHOD; ++use crate::protocol::FsCloseParams; ++use crate::protocol::FsCloseResponse; ++use crate::protocol::FsOpenParams; ++use crate::protocol::FsOpenResponse; ++use crate::protocol::FsReadBlockParams; ++use crate::protocol::FsReadBlockResponse; ++use crate::remote_file_system::map_remote_error; ++use crate::rpc::RpcClient; ++ ++pub const MAX_BOUNDED_FILE_READ_BYTES: usize = 8 * 1024 * 1024; ++ ++#[derive(Debug)] ++pub struct BoundedFileRead { ++ pub bytes: Vec, ++ pub truncated: bool, ++} ++ ++#[derive(Debug, thiserror::Error)] ++#[error("{error}")] ++pub struct BoundedFileReadError { ++ #[source] ++ pub error: io::Error, ++ pub unsettled: bool, ++} ++ ++impl BoundedFileReadError { ++ fn local(message: &str) -> Self { ++ Self { ++ error: io::Error::new(io::ErrorKind::InvalidInput, message), ++ unsettled: false, ++ } ++ } ++ ++ fn rpc(error: ExecServerError) -> Self { ++ let unsettled = !matches!(error, ExecServerError::Server { .. }); ++ Self { ++ error: map_remote_error(error), ++ unsettled, ++ } ++ } ++} ++ ++impl LazyRemoteExecServerClient { ++ pub(crate) async fn read_file_bounded( ++ &self, ++ path: &PathUri, ++ max_bytes: usize, ++ ) -> Result { ++ validate_bound(max_bytes)?; ++ let client = self ++ .fail_fast() ++ .get() ++ .await ++ .map_err(|error| BoundedFileReadError { ++ error: map_remote_error(error), ++ unsettled: false, ++ })?; ++ client.read_file_bounded(path, max_bytes).await ++ } ++} ++ ++fn validate_bound(max_bytes: usize) -> Result<(), BoundedFileReadError> { ++ if !(1..=MAX_BOUNDED_FILE_READ_BYTES).contains(&max_bytes) { ++ return Err(BoundedFileReadError::local( ++ "file read bound must be between 1 and 8388608 bytes", ++ )); ++ } ++ Ok(()) ++} ++ ++impl ExecServerClient { ++ async fn read_file_bounded( ++ &self, ++ path: &PathUri, ++ max_bytes: usize, ++ ) -> Result { ++ validate_bound(max_bytes)?; ++ let rpc = self ++ .rpc_client_without_recovery() ++ .map_err(|error| BoundedFileReadError { ++ error: map_remote_error(error), ++ unsettled: false, ++ })?; ++ let handle_id = Uuid::new_v4().simple().to_string(); ++ let opened: Result = self ++ .call_rpc( ++ &rpc, ++ FS_OPEN_METHOD, ++ &FsOpenParams { ++ handle_id: handle_id.clone(), ++ path: path.clone(), ++ sandbox: None, ++ }, ++ ) ++ .await; ++ let result = match opened { ++ Ok(response) if response.handle_id == handle_id => { ++ self.read_bounded_blocks(&rpc, &handle_id, max_bytes).await ++ } ++ Ok(_) => Err(BoundedFileReadError { ++ error: io::Error::new( ++ io::ErrorKind::InvalidData, ++ "file open returned an unexpected handle", ++ ), ++ unsettled: true, ++ }), ++ Err(error @ ExecServerError::Server { .. }) => { ++ return Err(BoundedFileReadError::rpc(error)); ++ } ++ Err(error) => Err(BoundedFileReadError::rpc(error)), ++ }; ++ // Keep cleanup on the original connection, even if the Environment has recovered. ++ let closed: Result = self.map_rpc_call_result( ++ rpc.call_for_cleanup(FS_CLOSE_METHOD, &FsCloseParams { handle_id }) ++ .await, ++ ); ++ match closed { ++ Ok(_) => result, ++ Err(error) => Err(BoundedFileReadError { ++ error: map_remote_error(error), ++ unsettled: true, ++ }), ++ } ++ } ++ ++ async fn read_bounded_blocks( ++ &self, ++ rpc: &Arc, ++ handle_id: &str, ++ max_bytes: usize, ++ ) -> Result { ++ let limit = max_bytes + 1; ++ let mut bytes = Vec::with_capacity(limit); ++ loop { ++ let len = FILE_READ_CHUNK_SIZE.min(limit - bytes.len()); ++ let response: FsReadBlockResponse = self ++ .call_rpc( ++ rpc, ++ FS_READ_BLOCK_METHOD, ++ &FsReadBlockParams { ++ handle_id: handle_id.to_owned(), ++ offset: bytes.len() as u64, ++ len, ++ }, ++ ) ++ .await ++ .map_err(BoundedFileReadError::rpc)?; ++ let chunk = response.chunk.into_inner(); ++ if chunk.len() > len || (chunk.is_empty() && !response.eof) { ++ return Err(BoundedFileReadError { ++ error: io::Error::new( ++ io::ErrorKind::InvalidData, ++ "file read returned an invalid block", ++ ), ++ unsettled: true, ++ }); ++ } ++ bytes.extend_from_slice(&chunk); ++ if response.eof || bytes.len() == limit { ++ let truncated = bytes.len() > max_bytes; ++ bytes.truncate(max_bytes); ++ return Ok(BoundedFileRead { bytes, truncated }); ++ } ++ } ++ } ++} ++ ++#[cfg(test)] ++#[path = "bounded_file_read_tests.rs"] ++mod tests; +diff --git a/codex-rs/exec-server/src/bounded_file_read_tests.rs b/codex-rs/exec-server/src/bounded_file_read_tests.rs +new file mode 100644 +index 0000000..2d5f568 +--- /dev/null ++++ b/codex-rs/exec-server/src/bounded_file_read_tests.rs +@@ -0,0 +1,282 @@ ++use codex_exec_server_protocol::JSONRPCError; ++use codex_exec_server_protocol::JSONRPCMessage; ++use codex_exec_server_protocol::JSONRPCRequest; ++use codex_exec_server_protocol::JSONRPCResponse; ++use codex_exec_server_protocol::RequestId; ++use serde_json::Value; ++use serde_json::json; ++use tokio::sync::mpsc; ++use tokio::sync::watch; ++use tokio::time::Duration; ++use tokio::time::timeout; ++ ++use super::*; ++use crate::ExecServerClientConnectOptions; ++use crate::client::ConnectionStatus; ++use crate::connection::JsonRpcConnection; ++use crate::connection::JsonRpcConnectionEvent; ++use crate::connection::JsonRpcTransport; ++use crate::rpc::not_found; ++ ++struct Peer { ++ requests: mpsc::Receiver, ++ responses: mpsc::Sender, ++ _disconnected: watch::Sender, ++} ++ ++fn connection() -> (JsonRpcConnection, Peer) { ++ let (outgoing_tx, requests) = mpsc::channel(8); ++ let (responses, incoming_rx) = mpsc::channel(8); ++ let (disconnected, disconnected_rx) = watch::channel(false); ++ ( ++ JsonRpcConnection { ++ outgoing_tx, ++ incoming_rx, ++ disconnected_rx, ++ task_handles: Vec::new(), ++ transport: JsonRpcTransport::Plain, ++ }, ++ Peer { ++ requests, ++ responses, ++ _disconnected: disconnected, ++ }, ++ ) ++} ++ ++impl Peer { ++ async fn request(&mut self, method: &str) -> JSONRPCRequest { ++ let message = timeout(Duration::from_secs(2), self.requests.recv()) ++ .await ++ .expect("request deadline") ++ .expect("request channel"); ++ let JSONRPCMessage::Request(request) = message else { ++ panic!("expected request: {message:?}") ++ }; ++ assert_eq!(request.method, method); ++ request ++ } ++ ++ async fn respond(&self, id: RequestId, result: Value) { ++ self.responses ++ .send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Response( ++ JSONRPCResponse { id, result }, ++ ))) ++ .await ++ .expect("send response"); ++ } ++ ++ async fn reject(&self, id: RequestId) { ++ self.responses ++ .send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Error( ++ JSONRPCError { ++ id, ++ error: not_found("missing".to_owned()), ++ }, ++ ))) ++ .await ++ .expect("send rejection"); ++ } ++} ++ ++async fn connected() -> (ExecServerClient, Peer) { ++ let (connection, mut peer) = connection(); ++ let bootstrap = tokio::spawn(async move { ++ let request = peer.request("initialize").await; ++ peer.respond( ++ request.id, ++ json!({"sessionId":"bounded-reader","environmentInfo":null}), ++ ) ++ .await; ++ assert!(matches!( ++ peer.requests.recv().await, ++ Some(JSONRPCMessage::Notification(_)) ++ )); ++ peer ++ }); ++ let client = ExecServerClient::connect(connection, ExecServerClientConnectOptions::default()) ++ .await ++ .expect("connect native client"); ++ (client, bootstrap.await.expect("bootstrap")) ++} ++ ++fn path() -> PathUri { ++ PathUri::parse("file:///workspace/artifact.bin").expect("file URI") ++} ++ ++#[tokio::test] ++async fn bounded_read_binary_limits_and_pinned_connection() { ++ for (size, bound, replace) in [ ++ (0, 10, false), ++ (10, 10, false), ++ (11, 10, false), ++ (FILE_READ_CHUNK_SIZE, FILE_READ_CHUNK_SIZE, false), ++ (FILE_READ_CHUNK_SIZE + 37, FILE_READ_CHUNK_SIZE + 100, true), ++ (FILE_READ_CHUNK_SIZE + 37, FILE_READ_CHUNK_SIZE + 36, false), ++ ] { ++ let data: Vec = (0..size).map(|index| (index % 256) as u8).collect(); ++ let (client, mut peer) = connected().await; ++ let owner = client.clone(); ++ let read = tokio::spawn(async move { owner.read_file_bounded(&path(), bound).await }); ++ let opened = peer.request(FS_OPEN_METHOD).await; ++ let params: FsOpenParams = ++ serde_json::from_value(opened.params.expect("open params")).expect("open type"); ++ assert_eq!(params.path, path()); ++ assert!(params.sandbox.is_none()); ++ let (replacement, mut replacement_peer) = connection(); ++ let (replacement, _events) = RpcClient::new(replacement); ++ if replace { ++ client ++ .inner ++ .connection ++ .lock() ++ .expect("connection lock") ++ .status = ConnectionStatus::Connected(Arc::new(replacement)); ++ } ++ peer.respond(opened.id, json!({"handleId":params.handle_id})) ++ .await; ++ let mut offset = 0; ++ loop { ++ let request = peer.request(FS_READ_BLOCK_METHOD).await; ++ let block: FsReadBlockParams = ++ serde_json::from_value(request.params.expect("block params")).expect("block type"); ++ assert_eq!(block.handle_id, params.handle_id); ++ assert_eq!(block.offset, offset as u64); ++ assert_eq!(block.len, FILE_READ_CHUNK_SIZE.min(bound + 1 - offset)); ++ let end = size.min(offset + block.len); ++ let eof = end - offset < block.len; ++ peer.respond( ++ request.id, ++ serde_json::to_value(FsReadBlockResponse { ++ chunk: data[offset..end].to_vec().into(), ++ eof, ++ }) ++ .expect("block response"), ++ ) ++ .await; ++ offset = end; ++ if eof || offset == bound + 1 { ++ break; ++ } ++ } ++ let closed = peer.request(FS_CLOSE_METHOD).await; ++ assert_eq!(closed.params, Some(json!({"handleId":params.handle_id}))); ++ assert!( ++ !read.is_finished(), ++ "read must retain ownership until close acknowledgement" ++ ); ++ peer.respond(closed.id, json!({})).await; ++ let result = read.await.expect("read task").expect("bounded bytes"); ++ assert_eq!(result.bytes, data[..size.min(bound)]); ++ assert_eq!(result.truncated, size > bound); ++ assert!( ++ replacement_peer.requests.try_recv().is_err(), ++ "read or close moved to replacement" ++ ); ++ assert!(peer.requests.try_recv().is_err()); ++ } ++} ++ ++#[tokio::test] ++async fn bounded_read_errors_require_ordered_close_and_preserve_uncertainty() { ++ for fault in [ ++ "open_server", ++ "open_json", ++ "open_handle", ++ "read_server", ++ "read_json", ++ "read_oversize", ++ "read_empty", ++ "close_server", ++ "close_json", ++ "close_disconnect", ++ "read_disconnect", ++ ] { ++ let (client, mut peer) = connected().await; ++ let read = tokio::spawn(async move { client.read_file_bounded(&path(), 8).await }); ++ let opened = peer.request(FS_OPEN_METHOD).await; ++ let params: FsOpenParams = ++ serde_json::from_value(opened.params.expect("open params")).expect("open type"); ++ if fault == "open_server" { ++ peer.reject(opened.id).await; ++ } else if fault == "open_json" { ++ peer.respond(opened.id, json!({})).await; ++ } else if fault == "open_handle" { ++ peer.respond(opened.id, json!({"handleId":"wrong"})).await; ++ } else { ++ peer.respond(opened.id, json!({"handleId":params.handle_id})) ++ .await; ++ let block = peer.request(FS_READ_BLOCK_METHOD).await; ++ match fault { ++ "read_server" => peer.reject(block.id).await, ++ "read_json" => peer.respond(block.id, json!({})).await, ++ "read_disconnect" => { ++ peer.responses ++ .send(JsonRpcConnectionEvent::Disconnected { reason: None }) ++ .await ++ .expect("disconnect"); ++ } ++ _ => { ++ let (bytes, eof) = match fault { ++ "read_oversize" => (vec![0; 10], true), ++ "read_empty" => (vec![], false), ++ _ => (vec![0, 255, 128], true), ++ }; ++ peer.respond( ++ block.id, ++ serde_json::to_value(FsReadBlockResponse { ++ chunk: bytes.into(), ++ eof, ++ }) ++ .expect("block response"), ++ ) ++ .await; ++ } ++ } ++ } ++ if fault != "open_server" && fault != "read_disconnect" { ++ let closed = peer.request(FS_CLOSE_METHOD).await; ++ assert_eq!(closed.params, Some(json!({"handleId":params.handle_id}))); ++ assert!(!read.is_finished()); ++ match fault { ++ "close_server" => peer.reject(closed.id).await, ++ "close_json" => peer.respond(closed.id, json!(null)).await, ++ "close_disconnect" => { ++ peer.responses ++ .send(JsonRpcConnectionEvent::Disconnected { reason: None }) ++ .await ++ .expect("disconnect"); ++ } ++ _ => peer.respond(closed.id, json!({})).await, ++ } ++ } ++ let failure = timeout(Duration::from_secs(2), read) ++ .await ++ .expect("read deadline") ++ .expect("read task") ++ .expect_err("read fails"); ++ let settled = matches!(fault, "open_server" | "read_server"); ++ assert_eq!(failure.unsettled, !settled, "{fault}"); ++ if matches!(fault, "open_server" | "read_server") { ++ assert_eq!(failure.error.kind(), io::ErrorKind::NotFound); ++ } ++ assert!( ++ peer.requests.try_recv().is_err(), ++ "extra request after {fault}" ++ ); ++ } ++} ++ ++#[tokio::test] ++async fn bounded_read_invalid_bound_sends_no_requests() { ++ let (client, mut peer) = connected().await; ++ for bound in [0, MAX_BOUNDED_FILE_READ_BYTES + 1, usize::MAX] { ++ let error = client ++ .read_file_bounded(&path(), bound) ++ .await ++ .expect_err("invalid bound"); ++ assert_eq!(error.error.kind(), io::ErrorKind::InvalidInput); ++ assert!(!error.unsettled); ++ assert!(peer.requests.try_recv().is_err()); ++ } ++} +diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs +index bd758cd..a87df97 100644 +--- a/codex-rs/exec-server/src/client.rs ++++ b/codex-rs/exec-server/src/client.rs +@@ -1,3 +1,10 @@ ++#[path = "bounded_file_read.rs"] ++mod bounded_file_read; ++ ++pub use bounded_file_read::BoundedFileRead; ++pub use bounded_file_read::BoundedFileReadError; ++pub use bounded_file_read::MAX_BOUNDED_FILE_READ_BYTES; ++ + use std::collections::BTreeMap; + use std::collections::HashMap; + use std::sync::Arc; +diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs +index 953f8d4..9362a0a 100644 +--- a/codex-rs/exec-server/src/environment.rs ++++ b/codex-rs/exec-server/src/environment.rs +@@ -796,6 +796,25 @@ impl Environment { + } + } + ++ /// Reads bounded remote bytes on one current connection and awaits its close acknowledgement. ++ pub async fn read_file_bounded( ++ &self, ++ path: &codex_utils_path_uri::PathUri, ++ max_bytes: usize, ++ ) -> Result { ++ let client = self ++ .remote_client ++ .as_ref() ++ .ok_or_else(|| crate::BoundedFileReadError { ++ error: std::io::Error::new( ++ std::io::ErrorKind::Unsupported, ++ "bounded reads require a remote environment", ++ ), ++ unsettled: false, ++ })?; ++ client.read_file_bounded(path, max_bytes).await ++ } ++ + pub fn is_remote(&self) -> bool { + self.remote_client.is_some() + } +diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs +index f0e2303..c0b20ba 100644 +--- a/codex-rs/exec-server/src/lib.rs ++++ b/codex-rs/exec-server/src/lib.rs +@@ -54,8 +54,11 @@ pub use arg0_exec_helper::main as run_arg0_exec_helper_main; + pub use capability_discovery::CapabilityDiscoveryError; + pub use capability_discovery::discover_capability_roots; + pub use capability_discovery_cache::ExecutorCapabilityDiscoveryCache; ++pub use client::BoundedFileRead; ++pub use client::BoundedFileReadError; + pub use client::ExecServerClient; + pub use client::ExecServerError; ++pub use client::MAX_BOUNDED_FILE_READ_BYTES; + pub use client::http_client::HttpResponseBodyStream; + pub use client::http_client::RouteAwareHttpClient; + pub use client_api::ExecServerClientConnectOptions; +diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs +index d574cec..28ad6ba 100644 +--- a/codex-rs/exec-server/src/remote_file_system.rs ++++ b/codex-rs/exec-server/src/remote_file_system.rs +@@ -415,7 +415,7 @@ fn remote_sandbox_context( + .map(FileSystemSandboxContext::drop_cwd_if_unused) + } + +-fn map_remote_error(error: ExecServerError) -> io::Error { ++pub(crate) fn map_remote_error(error: ExecServerError) -> io::Error { + match error { + ExecServerError::Server { code, message } if code == NOT_FOUND_ERROR_CODE => { + io::Error::new(io::ErrorKind::NotFound, message) diff --git a/packages/codex-harness/patches/manager-exposure.patch b/packages/codex-harness/patches/manager-exposure.patch new file mode 100644 index 000000000..8f1dcb765 --- /dev/null +++ b/packages/codex-harness/patches/manager-exposure.patch @@ -0,0 +1,91 @@ +diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs +index 0b4fe17..cdf2280 100644 +--- a/codex-rs/app-server/src/lib.rs ++++ b/codex-rs/app-server/src/lib.rs +@@ -467,6 +467,68 @@ pub async fn run_main_with_transport_options( + session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, + runtime_options: AppServerRuntimeOptions, ++) -> IoResult<()> { ++ run_main_with_transport_options_inner( ++ arg0_paths, ++ cli_config_overrides, ++ loader_overrides, ++ strict_config, ++ default_analytics_enabled, ++ transport, ++ session_source, ++ auth, ++ runtime_options, ++ None, ++ ) ++ .await ++} ++ ++/// Runs the stock raw transport and publishes its shared environment manager. ++/// ++/// The handle is published after manager construction, before transport startup. ++/// It does not signal successful startup, initialization, or environment readiness. ++/// The caller must supervise this future and release its handle when the runner ++/// stops. A receiver dropped before publication fails startup with `BrokenPipe`. ++#[allow(clippy::too_many_arguments)] ++pub async fn run_main_with_transport_options_and_environment_manager( ++ arg0_paths: Arg0DispatchPaths, ++ cli_config_overrides: CliConfigOverrides, ++ loader_overrides: LoaderOverrides, ++ strict_config: bool, ++ default_analytics_enabled: bool, ++ transport: AppServerTransport, ++ session_source: SessionSource, ++ auth: AppServerWebsocketAuthSettings, ++ runtime_options: AppServerRuntimeOptions, ++ environment_manager_tx: tokio::sync::oneshot::Sender>, ++) -> IoResult<()> { ++ run_main_with_transport_options_inner( ++ arg0_paths, ++ cli_config_overrides, ++ loader_overrides, ++ strict_config, ++ default_analytics_enabled, ++ transport, ++ session_source, ++ auth, ++ runtime_options, ++ Some(environment_manager_tx), ++ ) ++ .await ++} ++ ++#[allow(clippy::too_many_arguments)] ++async fn run_main_with_transport_options_inner( ++ arg0_paths: Arg0DispatchPaths, ++ cli_config_overrides: CliConfigOverrides, ++ loader_overrides: LoaderOverrides, ++ strict_config: bool, ++ default_analytics_enabled: bool, ++ transport: AppServerTransport, ++ session_source: SessionSource, ++ auth: AppServerWebsocketAuthSettings, ++ runtime_options: AppServerRuntimeOptions, ++ environment_manager_tx: Option>>, + ) -> IoResult<()> { + let loader_overrides = loader_overrides_with_test_user_config_file( + loader_overrides, +@@ -583,6 +645,17 @@ pub async fn run_main_with_transport_options( + .map(Arc::new) + .map_err(std::io::Error::other)?; + ++ if let Some(environment_manager_tx) = environment_manager_tx { ++ environment_manager_tx ++ .send(Arc::clone(&environment_manager)) ++ .map_err(|_| { ++ std::io::Error::new( ++ ErrorKind::BrokenPipe, ++ "environment manager receiver dropped before publication", ++ ) ++ })?; ++ } ++ + let otel = codex_core::otel_init::build_provider( + &config, + env!("CARGO_PKG_VERSION"), diff --git a/packages/codex-harness/prepare.py b/packages/codex-harness/prepare.py new file mode 100644 index 000000000..aeb5bd8a9 --- /dev/null +++ b/packages/codex-harness/prepare.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Export and prepare the pinned private harness build without resolving dependencies.""" + +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def private_path(value): + path = Path(value).expanduser() + if not path.is_absolute(): + raise ValueError("harness paths must be absolute") + path = path.resolve() + root = (Path.home() / ".parsar").resolve() + if path == root or not path.is_relative_to(root): + raise ValueError("harness state must be below ~/.parsar") + return path + + +def checked_bytes(path, expected): + data = path.read_bytes() + if sha(data) != expected: + raise ValueError("source identity differs: " + str(path)) + return data + + +def load_manifest(): + package = Path(__file__).resolve().parent + raw = (package / "source.json").read_bytes() + manifest = json.loads(raw) + for key in ("patch", "bounded_read_patch", "build_overlay"): + checked_bytes(package / manifest[key]["file"], manifest[key]["sha256"]) + sources = sorted((package / manifest["source_directory"]).rglob("*.rs")) + if not sources or not (package / manifest["source_directory"] / "main.rs").is_file(): + raise ValueError("harness Rust sources are missing") + return package, raw, manifest, sources + + +def normalize_lock(original, overlay, version): + if sha(original) != overlay["original_sha256"]: + raise ValueError("unexpected upstream Cargo.lock") + parts = original.split(b"[[package]]") + changed = 0 + for index, part in enumerate(parts[1:], 1): + if b"\nsource = " not in part and b'\nversion = "0.0.0"\n' in part: + parts[index] = part.replace( + b'\nversion = "0.0.0"\n', ('\nversion = "' + version + '"\n').encode(), 1 + ) + changed += 1 + normalized = b"[[package]]".join(parts) + if changed != overlay["workspace_packages"] or sha(normalized) != overlay["normalized_sha256"]: + raise ValueError("workspace-only lock normalization differs") + return normalized + + +def prepare(source, output): + package, raw, manifest, sources = load_manifest() + source = Path(source).expanduser() + if not source.is_absolute(): + raise ValueError("native Git source must be absolute") + output = private_path(output) + revision = manifest["revision"] + resolved = subprocess.check_output( + ["git", "-C", str(source), "rev-parse", revision + "^{commit}"], text=True + ).strip() + if resolved != revision: + raise ValueError("native source revision differs") + output.mkdir(parents=True, exist_ok=False) + with subprocess.Popen( + ["git", "-C", str(source), "archive", "--format=tar", revision], stdout=subprocess.PIPE + ) as archive: + try: + subprocess.run(["tar", "-xf", "-", "-C", str(output)], stdin=archive.stdout, check=True) + finally: + archive.stdout.close() + if archive.wait() != 0: + raise RuntimeError("native source export failed") + lock = output / "codex-rs/Cargo.lock" + lock.write_bytes(normalize_lock(lock.read_bytes(), manifest["cargo_lock"], manifest["native_version"])) + cargo_manifest = output / "codex-rs/app-server/Cargo.toml" + checked_bytes(cargo_manifest, manifest["build_overlay"]["original_manifest_sha256"]) + for key in ("patch", "bounded_read_patch", "build_overlay"): + patch = package / manifest[key]["file"] + subprocess.run(["git", "apply", "--check", str(patch)], cwd=output, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=output, check=True) + checked_bytes(cargo_manifest, manifest["build_overlay"]["prepared_manifest_sha256"]) + source_hashes = {} + for source_file in sources: + relative = source_file.relative_to(package / manifest["source_directory"]) + data = source_file.read_bytes() + target = output / manifest["target_directory"] / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + source_hashes[str(relative)] = sha(data) + record = { + "revision": revision, + "manifest_sha256": sha(raw), + "patch_sha256": manifest["patch"]["sha256"], + "bounded_read_patch_sha256": manifest["bounded_read_patch"]["sha256"], + "build_overlay_sha256": manifest["build_overlay"]["sha256"], + "prepared_manifest_sha256": sha(cargo_manifest.read_bytes()), + "prepared_lock_sha256": sha(lock.read_bytes()), + "rust_toolchain": manifest["rust_toolchain"], + "sources": source_hashes, + } + (output / "preparation.json").write_text(json.dumps(record, indent=2) + "\n") + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, help="existing upstream Git checkout") + parser.add_argument("--output", type=Path, help="new export below ~/.parsar") + parser.add_argument("--check", action="store_true", help="verify local manifest and patch identities") + parser.add_argument("--check-path", action="append", default=[], help="verify an isolated build path") + args = parser.parse_args() + for value in args.check_path: + private_path(value) + if args.check: + load_manifest() + elif args.source is not None and args.output is not None: + print(prepare(args.source, args.output)) + elif not args.check_path: + parser.error("provide --source and --output, or --check") + + +if __name__ == "__main__": + main() diff --git a/packages/codex-harness/prepare_test.py b/packages/codex-harness/prepare_test.py new file mode 100644 index 000000000..9931df4b1 --- /dev/null +++ b/packages/codex-harness/prepare_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import json +import tempfile +import unittest +from pathlib import Path + +from prepare import checked_bytes, normalize_lock, private_path, sha + + +class PreparationTests(unittest.TestCase): + def test_only_workspace_versions_change(self): + original = ( + b'[[package]]\nname = "native"\nversion = "0.0.0"\n' + b'[[package]]\nname = "external"\nversion = "0.0.0"\nsource = "registry+example"\n' + ) + expected = original.replace(b'version = "0.0.0"', b'version = "0.153.4"', 1) + overlay = { + "original_sha256": sha(original), + "normalized_sha256": sha(expected), + "workspace_packages": 1, + } + self.assertEqual(normalize_lock(original, overlay, "0.153.4"), expected) + for field, value in (("original_sha256", "wrong"), ("normalized_sha256", "wrong"), ("workspace_packages", 2)): + with self.subTest(field=field), self.assertRaises(ValueError): + normalize_lock(original, {**overlay, field: value}, "0.153.4") + + def test_private_build_paths_reject_escape(self): + root = Path.home() / ".parsar" + root.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="harness-path-test-", dir=root) as directory: + base = Path(directory) + self.assertEqual(private_path(base / "output"), base.resolve() / "output") + (base / "escape").symlink_to(root.parent, target_is_directory=True) + for value in ("relative", root, root / ".." / "outside", base / "escape" / "outside"): + with self.subTest(value=value), self.assertRaises(ValueError): + private_path(value) + + def test_bounded_read_patch_has_separate_identity_and_native_scope(self): + package = Path(__file__).resolve().parent + manifest = json.loads((package / "source.json").read_text()) + patch = manifest["bounded_read_patch"] + data = checked_bytes(package / patch["file"], patch["sha256"]) + with self.assertRaises(ValueError): + checked_bytes(package / patch["file"], manifest["patch"]["sha256"]) + paths = [line.split()[2][2:] for line in data.decode().splitlines() if line.startswith("diff --git ")] + self.assertEqual(set(paths), { + "codex-rs/exec-server/src/bounded_file_read.rs", + "codex-rs/exec-server/src/bounded_file_read_tests.rs", + "codex-rs/exec-server/src/client.rs", + "codex-rs/exec-server/src/environment.rs", + "codex-rs/exec-server/src/lib.rs", + "codex-rs/exec-server/src/remote_file_system.rs", + }) + + def test_shared_patch_identity_and_fixture_references(self): + package = Path(__file__).resolve().parent + root = package.parents[1] + manifest = json.loads((package / "source.json").read_text()) + canonical = package / manifest["patch"]["file"] + checked_bytes(canonical, manifest["patch"]["sha256"]) + with self.assertRaises(ValueError): + checked_bytes(canonical, "wrong") + for name in ("raw_manager", "raw_files", "retirement"): + fixture = root / "services/agents-api/tests/native" / name / "source.json" + reference = json.loads(fixture.read_text())["patch"] + self.assertEqual((fixture.parent / reference["file"]).resolve(), canonical.resolve()) + self.assertEqual(reference["sha256"], manifest["patch"]["sha256"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/codex-harness/source.json b/packages/codex-harness/source.json new file mode 100644 index 000000000..52d8bc6ce --- /dev/null +++ b/packages/codex-harness/source.json @@ -0,0 +1,29 @@ +{ + "repository": "https://github.com/openai/codex", + "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", + "native_version": "0.153.4", + "rust_toolchain": "1.95.0", + "scope": "opt-in private Linux amd64 harness artifact with remote metadata and bounded reads; no public admission", + "patch": { + "file": "patches/manager-exposure.patch", + "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" + }, + "cargo_lock": { + "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", + "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", + "workspace_packages": 149 + }, + "build_overlay": { + "file": "patches/artifact-target.patch", + "sha256": "18606942555a060f4b626fd1ab5c0e7e6324f118dd13a65f28f546d03c622f1a", + "original_manifest_sha256": "687db2b91d42c568dddb09adc5958eff6998008c733af5186e8a6a340ccfa301", + "prepared_manifest_sha256": "63113ae56325bea3f42ab2f8b59983ec2a533f6914248ece9238459d97462b53" + }, + "binary": "parsar-codex-harness", + "source_directory": "src", + "target_directory": "codex-rs/app-server/parsar-harness", + "bounded_read_patch": { + "file": "patches/bounded-read.patch", + "sha256": "5a6a49ac0b9b9398b772bc27c6256eb11af64487427bc57715d7800178c2e5dc" + } +} diff --git a/packages/codex-harness/src/files.rs b/packages/codex-harness/src/files.rs new file mode 100644 index 000000000..538884921 --- /dev/null +++ b/packages/codex-harness/src/files.rs @@ -0,0 +1,426 @@ +use anyhow::{Context, Result, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use codex_exec_server::{ + Environment, EnvironmentManager, EnvironmentObservedStatus, GetMetadataOptions, + MAX_BOUNDED_FILE_READ_BYTES, +}; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::future::Future; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::oneshot; +use tokio::time::{Instant, timeout_at}; +use tokio_util::sync::CancellationToken; + +use crate::options::Binding; + +const MAX_FRAME: usize = 8192; +const REQUEST_DEADLINE: Duration = Duration::from_secs(10); + +#[path = "files_directory.rs"] +mod directory; +#[path = "files_process_output.rs"] +mod process_output; +#[path = "files_write.rs"] +mod write; + +pub struct PrivateSocket { + listener: UnixListener, + root: PathBuf, + uid: u32, +} + +impl PrivateSocket { + pub fn bind(root: &Path) -> Result { + let uid = std::fs::metadata("/proc/self")?.uid(); + let state = PathBuf::from(std::env::var_os("HOME").context("HOME is required")?) + .join(".parsar") + .canonicalize()?; + let parent = root.parent().context("IPC parent is missing")?; + let canonical = parent.canonicalize()?; + ensure!( + canonical == parent && parent.starts_with(&state), + "IPC root must be below canonical ~/.parsar" + ); + for ancestor in parent.ancestors() { + let metadata = std::fs::symlink_metadata(ancestor)?; + ensure!( + metadata.is_dir() + && (metadata.uid() == uid || metadata.uid() == 0) + && metadata.mode() & 0o022 == 0, + "IPC ancestors must be trusted directories" + ); + } + ensure!( + root.join("files.sock").as_os_str().len() < 104, + "IPC socket path is too long" + ); + std::fs::DirBuilder::new() + .mode(0o700) + .create(root) + .context("IPC root must be new")?; + let listener = match UnixListener::bind(root.join("files.sock")) { + Ok(listener) => listener, + Err(error) => { + let _ = std::fs::remove_dir(root); + return Err(error).context("bind private metadata socket"); + } + }; + let socket = Self { + listener, + root: root.to_owned(), + uid, + }; + std::fs::set_permissions( + socket.root.join("files.sock"), + std::fs::Permissions::from_mode(0o600), + )?; + Ok(socket) + } + + pub async fn serve( + &self, + published: oneshot::Receiver>, + binding: &Binding, + stopping: &CancellationToken, + ) -> Result<()> { + let manager = tokio::select! { + biased; + _ = stopping.cancelled() => return Ok(()), + result = published => result.context("native manager was not published")?, + }; + loop { + // A native deadline ends this owner: dropping a response future + // does not settle the remote operation or authorize another one. + let (stream, _) = tokio::select! { + biased; + _ = stopping.cancelled() => return Ok(()), + result = self.listener.accept() => result?, + }; + if stream.peer_cred()?.uid() != self.uid { + continue; + } + if let Ok(outcome) = serve_connection(stream, &manager, binding, stopping).await { + outcome.require_settled()?; + } + } + } +} + +impl Drop for PrivateSocket { + fn drop(&mut self) { + // Remove only this instance's known socket and empty private directory. + let _ = std::fs::remove_file(self.root.join("files.sock")); + let _ = std::fs::remove_dir(&self.root); + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + environment_id: String, + path: String, + #[serde(default)] + operation: Operation, + max_bytes: Option, + max_entries: Option, + size_bytes: Option, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Operation { + #[default] + Metadata, + Read, + ListDirectory, + Write, +} + +struct Command { + write: Option, + path: PathUri, + read_limit: Option, + directory: Option<(PathUri, usize, Option)>, +} + +fn request_command(frame: &[u8], binding: &Binding) -> Result { + if frame.len() > MAX_FRAME || !frame.ends_with(b"\n") { + return Err("invalid_request"); + } + let request: Request = serde_json::from_slice(frame).map_err(|_| "invalid_request")?; + if request.environment_id != binding.environment { + return Err("wrong_environment"); + } + let (read_limit, directory_limit, write_size) = match ( + request.operation, + request.max_bytes, + request.max_entries, + request.size_bytes, + ) { + (Operation::Metadata, None, None, None) => (None, None, None), + (Operation::Read, Some(limit), None, None) + if (1..=MAX_BOUNDED_FILE_READ_BYTES).contains(&limit) => + { + (Some(limit), None, None) + } + (Operation::ListDirectory, None, Some(limit), None) + if (1..=directory::MAX_ENTRIES).contains(&limit) => + { + (None, Some(limit), None) + } + (Operation::Write, None, None, Some(size)) if size <= write::MAX_BYTES => { + (None, None, Some(size)) + } + _ => return Err("invalid_request"), + }; + let path = Path::new(&request.path); + if (request.path.is_empty() && directory_limit.is_none()) + || request.path.contains(['\0', '\\']) + || ((directory_limit.is_some() || write_size.is_some()) + && (request.path.contains(['\r', '\n']) + || (!request.path.is_empty() + && request + .path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..")))) + || !path + .components() + .all(|part| matches!(part, Component::Normal(_))) + { + return Err("invalid_path"); + } + Ok(Command { + write: write_size + .map(|size| { + binding + .write + .clone() + .map(|write_binding| write::Upload { + binding: write_binding, + workspace: binding.workspace.clone(), + relative: request.path.clone(), + size, + bytes: Vec::new(), + }) + .ok_or("unsupported") + }) + .transpose()?, + path: PathUri::from_host_native_path(binding.workspace.join(path)) + .map_err(|_| "invalid_path")?, + read_limit, + directory: directory_limit + .map(|limit| { + PathUri::from_host_native_path(binding.workspace.clone()) + .map(|workspace| (workspace, limit, binding.directory_helper.clone())) + .map_err(|_| "invalid_path") + }) + .transpose()?, + }) +} + +async fn ready_environment(manager: &EnvironmentManager) -> Result, &'static str> { + // The native manager key is distinct from the registry's Environment UUID; + // startup validates that UUID against the frozen operator binding. + let environment = manager + .get_environment("remote") + .ok_or("environment_unavailable")?; + if manager.try_local_environment().is_some() + || !environment.is_remote() + || !matches!(environment.status().await, EnvironmentObservedStatus::Ready) + { + return Err("environment_unavailable"); + } + Ok(environment) +} + +fn file_error(error: std::io::Error) -> &'static str { + match error.kind() { + std::io::ErrorKind::NotFound => "not_found", + std::io::ErrorKind::PermissionDenied => "permission_denied", + _ => "native_error", + } +} + +async fn execute(manager: &EnvironmentManager, command: Command) -> Result { + let environment = ready_environment(manager) + .await + .map_err(OperationError::Rejected)?; + if let Some(upload) = command.write { + return write::install(&environment, upload).await; + } + if let Some((workspace, limit, helper)) = command.directory { + return directory::list( + &environment, + &workspace, + &command.path, + limit, + helper.as_deref(), + ) + .await; + } + if let Some(limit) = command.read_limit { + let read = environment + .read_file_bounded(&command.path, limit) + .await + .map_err(|failure| { + if failure.unsettled { + OperationError::Unsettled + } else { + OperationError::Rejected(file_error(failure.error)) + } + })?; + return Ok(json!({"read": { + "data_base64": STANDARD.encode(read.bytes), + "truncated": read.truncated, + "close_acknowledged": true, + }})); + } + // This private operator endpoint does not establish public path isolation. + // Native parent-component traversal remains subject to deployment policy. + let metadata = environment + .get_filesystem() + .get_metadata( + &command.path, + GetMetadataOptions { + follow_symlinks: false, + }, + None, + ) + .await + .map_err(|error| OperationError::Rejected(file_error(error)))?; + Ok( + json!({"metadata":{"size":metadata.size,"is_file":metadata.is_file,"is_directory":metadata.is_directory,"is_symlink":metadata.is_symlink,"created_at_ms":metadata.created_at_ms,"modified_at_ms":metadata.modified_at_ms}}), + ) +} + +#[derive(Debug)] +enum OperationError { + Rejected(&'static str), + Unsettled, +} + +#[derive(Debug, PartialEq)] +enum ConnectionOutcome { + Settled, + UnsettledNativeOperation, +} + +impl ConnectionOutcome { + fn require_settled(self) -> Result<()> { + ensure!( + self == Self::Settled, + "native file deadline expired or settlement unconfirmed; owner stopped with remote operation unresolved" + ); + Ok(()) + } +} + +async fn serve_connection( + stream: UnixStream, + manager: &EnvironmentManager, + binding: &Binding, + stopping: &CancellationToken, +) -> Result { + exchange(stream, binding, REQUEST_DEADLINE, stopping, |command| { + execute(manager, command) + }) + .await +} + +async fn exchange( + stream: UnixStream, + binding: &Binding, + duration: Duration, + stopping: &CancellationToken, + operation: F, +) -> Result +where + F: FnOnce(Command) -> R, + R: Future>, +{ + let mut deadline = Instant::now() + duration; + let (read, mut write) = stream.into_split(); + let mut reader = BufReader::new(read); + let mut frame = Vec::new(); + let frame_result = { + let mut header = (&mut reader).take((MAX_FRAME + 1) as u64); + tokio::select! { + biased; + _ = stopping.cancelled() => return Ok(ConnectionOutcome::Settled), + result = timeout_at(deadline, header.read_until(b'\n', &mut frame)) => result, + } + }; + match frame_result { + Ok(result) => { + result?; + } + Err(_) => return Ok(ConnectionOutcome::Settled), + } + // From admission until the native response/deadline, only this owner holds + // the operation. Caller disconnect and runner shutdown do not drop its wait. + let result = match request_command(&frame, binding) { + Ok(mut command) => { + if let Some(upload) = &mut command.write { + // Writes include bounded transfer; reads keep their original deadline. + deadline += duration * 5; + upload.bytes.resize(upload.size, 0); + let received = tokio::select! { + biased; + _ = stopping.cancelled() => return Ok(ConnectionOutcome::Settled), + result = timeout_at(deadline, reader.read_exact(&mut upload.bytes)) => result, + }; + if !matches!(received, Ok(Ok(_))) { + // No native process exists before the complete bounded body. + return Ok(ConnectionOutcome::Settled); + } + } + match timeout_at(deadline, operation(command)).await { + Ok(result) => result, + Err(_) => return Ok(ConnectionOutcome::UnsettledNativeOperation), + } + } + Err(error) => Err(OperationError::Rejected(error)), + }; + let response = match result { + Ok(value) => value, + Err(OperationError::Rejected(error)) => json!({"error":error}), + Err(OperationError::Unsettled) => return Ok(ConnectionOutcome::UnsettledNativeOperation), + }; + let mut bytes = serde_json::to_vec(&response)?; + bytes.push(b'\n'); + // The native future returned. Response delivery no longer owns that wait; + // a transport error still cannot establish remote operation retirement. + tokio::select! { + biased; + _ = stopping.cancelled() => {}, + _ = timeout_at(deadline, async { + write.write_all(&bytes).await?; + write.shutdown().await + }) => {}, + } + Ok(ConnectionOutcome::Settled) +} + +#[cfg(test)] +#[path = "files_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "files_read_tests.rs"] +mod read_tests; + +#[cfg(test)] +#[path = "files_directory_tests.rs"] +mod directory_tests; + +#[cfg(test)] +#[path = "files_write_tests.rs"] +mod write_tests; diff --git a/packages/codex-harness/src/files_directory.rs b/packages/codex-harness/src/files_directory.rs new file mode 100644 index 000000000..b8f0ff33e --- /dev/null +++ b/packages/codex-harness/src/files_directory.rs @@ -0,0 +1,156 @@ +use super::{OperationError, process_output::Output}; +use codex_exec_server::{ + Environment, ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, +}; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::{ + FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, + FileSystemSpecialPath, NetworkSandboxPolicy, +}; +use codex_sandboxing::SandboxType; +use codex_utils_path_uri::PathUri; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Component, Path}; +use uuid::Uuid; + +#[path = "files_directory_output.rs"] +mod output; + +pub(super) const MAX_ENTRIES: usize = 4096; + +pub(super) async fn list( + environment: &Environment, + workspace: &PathUri, + path: &PathUri, + limit: usize, + helper: Option<&Path>, +) -> Result { + let invalid = || OperationError::Rejected("unsupported"); + let root = workspace.to_abs_path().map_err(|_| invalid())?; + let target = path.to_abs_path().map_err(|_| invalid())?; + let helper = helper + .filter(|helper| qualified_path(helper, root.as_path())) + .ok_or_else(invalid)?; + let relative = target + .as_path() + .strip_prefix(root.as_path()) + .ok() + .and_then(Path::to_str) + .ok_or_else(invalid)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: workspace.clone(), + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(helper).map_err(|_| invalid())?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + FileSystemAccessMode::Read, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + workspace.clone(), + ); + let started = environment + .get_exec_backend() + .start(ExecParams { + process_id: ProcessId::from(format!("directory-{}", Uuid::new_v4())), + argv: vec![ + helper.to_string_lossy().into_owned(), + root.to_string_lossy().into_owned(), + relative.into(), + limit.to_string(), + ], + cwd: workspace.clone(), + shell_snapshot: None, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }) + .await + .map_err(|_| OperationError::Unsettled)?; + let process = started.process.as_ref(); + if !matches!(started.sandbox_type, Some(SandboxType::LinuxSeccomp)) { + return stop_and_reject(process).await; + } + let mut output = Output::default(); + loop { + // Uncapped snapshots expose the native global cursor. Capped reads can + // report closed while omitting chunks, and retained history can evict data. + let response = process + .read(Some(output.after()), None, Some(1000)) + .await + .map_err(|_| OperationError::Unsettled)?; + if response.failure.is_some() { + return Err(OperationError::Unsettled); + } + let settled = response.closed && response.exited; + if output.append(&response).is_err() { + return if settled { + Err(OperationError::Rejected("native_error")) + } else { + stop_and_reject(process).await + }; + } + if settled { + if response.exit_code != Some(0) || response.sandbox_denied { + return Err(OperationError::Rejected("native_error")); + } + return output::decode(&output.bytes, limit); + } + } +} + +fn qualified_path(helper: &Path, workspace: &Path) -> bool { + let Some(value) = helper.to_str() else { + return false; + }; + helper.is_absolute() + && !helper.starts_with(workspace) + && value + .split('/') + .skip(1) + .all(|part| !part.is_empty() && part != "." && part != "..") + && !value.contains(['\\', '\0', '\r', '\n']) + && helper + .components() + .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) +} + +async fn stop_and_reject(process: &dyn ExecProcess) -> Result { + process + .terminate() + .await + .map_err(|_| OperationError::Unsettled)?; + loop { + let response = process + .read(None, None, Some(1000)) + .await + .map_err(|_| OperationError::Unsettled)?; + if response.failure.is_some() { + return Err(OperationError::Unsettled); + } + // A terminate reply alone is not cleanup. The enclosing retained wait + // bounds this drain and fails the owner if exit/output close stay unknown. + if response.exited && response.closed { + return Err(OperationError::Rejected("native_error")); + } + } +} diff --git a/packages/codex-harness/src/files_directory_output.rs b/packages/codex-harness/src/files_directory_output.rs new file mode 100644 index 000000000..8faa76608 --- /dev/null +++ b/packages/codex-harness/src/files_directory_output.rs @@ -0,0 +1,70 @@ +use super::OperationError; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + version: u32, + directory: Option, + error: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Directory { + entries: Vec, + truncated: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + name: String, + kind: String, + size_bytes: Option, +} + +pub(super) fn decode(bytes: &[u8], limit: usize) -> Result { + let invalid = || OperationError::Rejected("native_error"); + let envelope: Envelope = serde_json::from_slice(bytes).map_err(|_| invalid())?; + if envelope.version != 1 { + return Err(invalid()); + } + match (envelope.directory, envelope.error) { + (None, Some(error)) => Err(OperationError::Rejected(match error.as_str() { + "not_found" => "not_found", + "permission_denied" => "permission_denied", + "invalid_path" => "invalid_path", + "too_large" => "too_large", + _ => "native_error", + })), + (Some(directory), None) if directory.entries.len() <= limit => { + let mut seen = std::collections::HashSet::new(); + let mut entries = Vec::with_capacity(directory.entries.len()); + for entry in directory.entries { + if entry.name.is_empty() + || entry.name.len() > 255 + || entry.name == "." + || entry.name == ".." + || entry.name.contains(['/', '\\', '\0', '\r', '\n']) + || !seen.insert(entry.name.clone()) + { + return Err(invalid()); + } + match (entry.kind.as_str(), entry.size_bytes) { + ("file", Some(size)) if size >= 0 => {} + ("directory" | "symlink" | "other", None) => {} + _ => return Err(invalid()), + } + entries.push( + json!({"name":entry.name,"kind":entry.kind,"size_bytes":entry.size_bytes}), + ); + } + Ok(json!({"directory":{"entries":entries,"truncated":directory.truncated}})) + } + _ => Err(invalid()), + } +} + +#[cfg(test)] +#[path = "files_directory_output_tests.rs"] +mod tests; diff --git a/packages/codex-harness/src/files_directory_output_tests.rs b/packages/codex-harness/src/files_directory_output_tests.rs new file mode 100644 index 000000000..fd7b02867 --- /dev/null +++ b/packages/codex-harness/src/files_directory_output_tests.rs @@ -0,0 +1,119 @@ +use super::super::super::process_output::Output; +use super::*; +use codex_exec_server::{ExecOutputStream, ProcessOutputChunk, ReadResponse}; + +fn response(next_seq: u64, chunks: &[(u64, &[u8])], exited: bool, closed: bool) -> ReadResponse { + ReadResponse { + chunks: chunks + .iter() + .map(|(seq, bytes)| ProcessOutputChunk { + seq: *seq, + stream: ExecOutputStream::Stdout, + chunk: bytes.to_vec().into(), + }) + .collect(), + next_seq, + exited, + exit_code: exited.then_some(0), + closed, + failure: None, + sandbox_denied: false, + } +} + +#[test] +fn complete_snapshots_account_for_exit_before_late_output() { + let mut output = Output::default(); + output + .append(&response(2, &[(1, b"first")], false, false)) + .expect("first snapshot"); + // Exit consumes sequence 2; output can still arrive as sequence 3. + output + .append(&response(4, &[(3, b"last")], true, false)) + .expect("late output"); + output + .append(&response(5, &[], true, true)) + .expect("closed"); + assert_eq!(output.bytes, b"firstlast"); + assert_eq!(output.after(), 4); +} + +#[test] +fn missing_retained_output_is_rejected_even_if_tail_is_valid_json() { + let valid = br#"{"version":1,"directory":{"entries":[],"truncated":false}}"#; + assert!( + Output::default() + .append(&response(6, &[(3, valid)], true, true)) + .is_err() + ); + // Closed on a capped snapshot cannot hide missing output chunks. + assert!( + Output::default() + .append(&response(5, &[(1, valid)], true, true)) + .is_err() + ); + assert!( + Output::default() + .append(&response(5, &[(1, b"a"), (1, b"b")], true, true)) + .is_err() + ); +} + +#[test] +fn output_is_bounded_and_rejects_mixed_streams_and_regression() { + let mut output = Output::default(); + let bytes = vec![b'x'; 1024 * 1024]; + for sequence in 1..=4 { + output + .append(&response(sequence + 1, &[(sequence, &bytes)], false, false)) + .expect("bounded"); + } + assert!( + output + .append(&response(6, &[(5, b"x")], false, false)) + .is_err() + ); + let mut mixed = response(4, &[(1, b"x")], true, true); + mixed.chunks[0].stream = ExecOutputStream::Stderr; + assert!(Output::default().append(&mixed).is_err()); + let mut output = Output::default(); + output.append(&response(2, &[], true, false)).expect("exit"); + assert!(output.append(&response(2, &[], false, false)).is_err()); +} + +#[test] +fn only_versioned_complete_bounded_directory_envelopes_are_accepted() { + let valid = br#"{"version":1,"directory":{"entries":[{"name":"a","kind":"file","size_bytes":0}],"truncated":false}}"#; + assert!(decode(valid, 1).is_ok()); + assert!(decode(valid, 0).is_err()); + for bytes in [ + br#"{"version":2,"directory":{"entries":[],"truncated":false}}"#.as_slice(), + br#"{"version":1,"directory":{"entries":[]}}"#, + br#"{"version":1,"directory":{"entries":[],"truncated":false},"error":"not_found"}"#, + br#"{"version":1,"directory":{"entries":[{"name":"../a","kind":"file","size_bytes":0}],"truncated":false}}"#, + br#"{"version":1,"directory":{"entries":[{"name":"a","kind":"symlink","size_bytes":1}],"truncated":false}}"#, + br#"{"version":1,"directory":{"entries":[],"truncated":false}}{}"#, + ] { assert!(decode(bytes, 1).is_err(), "{bytes:?}"); } +} + +#[test] +fn helper_selector_cannot_be_relative_or_inside_the_workspace() { + use std::path::Path; + for helper in [ + "relative", + "/workspace/helper", + "/workspace/nested/helper", + "/trusted/../helper", + "/trusted//helper", + "/trusted/helper/", + ] { + assert!(!super::super::qualified_path( + Path::new(helper), + Path::new("/workspace") + )); + } + assert!(super::super::qualified_path( + Path::new("/usr/local/bin/helper"), + Path::new("/workspace") + )); +} diff --git a/packages/codex-harness/src/files_directory_tests.rs b/packages/codex-harness/src/files_directory_tests.rs new file mode 100644 index 000000000..249106a03 --- /dev/null +++ b/packages/codex-harness/src/files_directory_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn directory_root_and_limits_are_operation_specific() { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + for path in ["", "nested/path"] { + let request = json!({"environment_id":"expected", "operation":"list_directory", "path":path, "max_entries":2}); + let command = request_command(format!("{request}\n").as_bytes(), &binding) + .expect("directory request"); + let (root, limit, _) = command.directory.expect("directory operation"); + assert_eq!( + root.to_abs_path().expect("absolute root").as_path(), + Path::new("/workspace") + ); + assert_eq!(limit, 2); + assert!(command.read_limit.is_none()); + } + for path in ["/outside", ".", "a/../b", "a//b", "a/", "a\r", "a\n"] { + let request = json!({"environment_id":"expected", "operation":"list_directory", "path":path, "max_entries":2}); + assert!(request_command(format!("{request}\n").as_bytes(), &binding).is_err()); + } + for fields in [ + json!({"max_entries":null}), + json!({"max_entries":0}), + json!({"max_entries":directory::MAX_ENTRIES+1}), + json!({"max_entries":2,"max_bytes":1}), + ] { + let mut request = + json!({"environment_id":"expected", "operation":"list_directory", "path":""}); + request + .as_object_mut() + .expect("object") + .extend(fields.as_object().expect("fields").clone()); + assert!(request_command(format!("{request}\n").as_bytes(), &binding).is_err()); + } + assert!( + request_command( + b"{\"environment_id\":\"expected\",\"path\":\"\"}\n", + &binding + ) + .is_err() + ); +} + +#[tokio::test] +async fn directory_transport_loss_fences_the_owner_after_dispatch() -> Result<()> { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + for kind in [ + std::io::ErrorKind::BrokenPipe, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::TimedOut, + std::io::ErrorKind::Other, + ] { + let (server, mut client) = UnixStream::pair()?; + client.write_all(b"{\"environment_id\":\"expected\",\"operation\":\"list_directory\",\"path\":\"\",\"max_entries\":1}\n").await?; + let stopping = CancellationToken::new(); + let outcome = exchange( + server, + &binding, + REQUEST_DEADLINE, + &stopping, + |command| async move { + assert!(command.directory.is_some()); + let _ = kind; + Err(OperationError::Unsettled) + }, + ) + .await?; + assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); + assert!(outcome.require_settled().is_err()); + assert_eq!(client.read(&mut [0; 1]).await?, 0); + } + Ok(()) +} diff --git a/packages/codex-harness/src/files_process_output.rs b/packages/codex-harness/src/files_process_output.rs new file mode 100644 index 000000000..1ebe88249 --- /dev/null +++ b/packages/codex-harness/src/files_process_output.rs @@ -0,0 +1,51 @@ +use codex_exec_server::{ExecOutputStream, ReadResponse}; + +const MAX_OUTPUT: usize = 4 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct Output { + pub bytes: Vec, + cursor: u64, + exited: bool, + closed: bool, +} + +impl Output { + pub fn after(&self) -> u64 { + self.cursor + } + + pub fn append(&mut self, response: &ReadResponse) -> Result<(), ()> { + let next = response.next_seq.checked_sub(1).ok_or(())?; + if next < self.cursor + || (self.exited && !response.exited) + || (self.closed && !response.closed) + || response.exited != response.exit_code.is_some() + || (response.closed && !response.exited) + { + return Err(()); + } + let events = response.chunks.len() as u64 + + u64::from(response.exited && !self.exited) + + u64::from(response.closed && !self.closed); + if next - self.cursor != events { + return Err(()); + } + let mut previous = self.cursor; + for chunk in &response.chunks { + if chunk.seq <= previous + || chunk.seq > next + || chunk.stream != ExecOutputStream::Stdout + || chunk.chunk.0.len() > MAX_OUTPUT.saturating_sub(self.bytes.len()) + { + return Err(()); + } + previous = chunk.seq; + self.bytes.extend_from_slice(&chunk.chunk.0); + } + self.cursor = next; + self.exited = response.exited; + self.closed = response.closed; + Ok(()) + } +} diff --git a/packages/codex-harness/src/files_read_tests.rs b/packages/codex-harness/src/files_read_tests.rs new file mode 100644 index 000000000..7285f710e --- /dev/null +++ b/packages/codex-harness/src/files_read_tests.rs @@ -0,0 +1,101 @@ +use super::*; + +fn binding() -> Binding { + Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + } +} + +#[test] +fn read_requires_an_explicit_bounded_limit() { + let binding = binding(); + for fields in [ + json!({"operation":"read"}), + json!({"operation":"read","max_bytes":null}), + json!({"operation":"read","max_bytes":0}), + json!({"operation":"read","max_bytes":-1}), + json!({"operation":"read","max_bytes":MAX_BOUNDED_FILE_READ_BYTES + 1}), + json!({"operation":"read","max_bytes":1.5}), + json!({"operation":"write","max_bytes":1}), + json!({"max_bytes":1}), + ] { + let mut frame = json!({"environment_id":"expected","path":"file"}); + frame + .as_object_mut() + .expect("object") + .extend(fields.as_object().expect("fields").clone()); + assert!(request_command(format!("{frame}\n").as_bytes(), &binding).is_err()); + } + for limit in [1, MAX_BOUNDED_FILE_READ_BYTES] { + let frame = + json!({"environment_id":"expected","path":"file","operation":"read","max_bytes":limit}); + let command = + request_command(format!("{frame}\n").as_bytes(), &binding).expect("valid read"); + assert_eq!(command.read_limit, Some(limit)); + } +} + +#[tokio::test] +async fn read_wait_includes_close_after_caller_detaches() -> Result<()> { + let binding = binding(); + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + client.write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\",\"operation\":\"read\",\"max_bytes\":4}\n").await?; + let (admitted, admission) = oneshot::channel(); + let (read_done, read_result) = oneshot::channel(); + let (closing, close_started) = oneshot::channel(); + let (close_done, close_result) = oneshot::channel(); + let files = exchange( + server, + &binding, + REQUEST_DEADLINE, + &stopping, + |command| async move { + assert_eq!(command.read_limit, Some(4)); + admitted.send(()).expect("admission observed"); + read_result.await.expect("read wait retained"); + closing.send(()).expect("close observed"); + close_result.await.expect("close wait retained") + }, + ); + tokio::pin!(files); + tokio::select! { + result = &mut files => panic!("read ended before admission: {result:?}"), + result = admission => result?, + } + drop(client); + stopping.cancel(); + read_done.send(()).expect("read wait survives detach"); + tokio::select! { + result = &mut files => panic!("read ended before close: {result:?}"), + result = close_started => result?, + } + assert!(futures::poll!(&mut files).is_pending()); + close_done + .send(Err(OperationError::Unsettled)) + .expect("close wait survives stop"); + let outcome = files.await?; + assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); + assert!(outcome.require_settled().is_err()); + Ok(()) +} + +#[tokio::test] +async fn unconfirmed_read_or_close_never_delivers_a_success_response() -> Result<()> { + let binding = binding(); + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + client.write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\",\"operation\":\"read\",\"max_bytes\":1}\n").await?; + let outcome = exchange(server, &binding, REQUEST_DEADLINE, &stopping, |_| async { + Err(OperationError::Unsettled) + }) + .await?; + assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); + assert_eq!(client.read(&mut [0; 1]).await?, 0); + Ok(()) +} diff --git a/packages/codex-harness/src/files_tests.rs b/packages/codex-harness/src/files_tests.rs new file mode 100644 index 000000000..c4cdd8319 --- /dev/null +++ b/packages/codex-harness/src/files_tests.rs @@ -0,0 +1,298 @@ +use super::*; + +#[tokio::test] +async fn runner_completion_keeps_the_original_admitted_deadline() -> Result<()> { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + client + .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") + .await?; + // Establish actual socket readiness before using the controlled clock. + server.readable().await?; + tokio::time::pause(); + let (admitted, mut admission) = oneshot::channel(); + let (complete, completion) = oneshot::channel(); + let (finish_runner, runner_done) = oneshot::channel(); + let files = async { + exchange( + server, + &binding, + REQUEST_DEADLINE, + &stopping, + |_path| async { + admitted.send(()).expect("observe admission"); + completion.await.expect("retain native response") + }, + ) + .await? + .require_settled() + }; + let operation = crate::owner::supervise( + async { runner_done.await.map_err(Into::into) }, + files, + &stopping, + ); + tokio::pin!(operation); + let started = Instant::now(); + assert!(futures::poll!(&mut operation).is_pending()); + admission.try_recv()?; + tokio::time::advance(Duration::from_secs(6)).await; + finish_runner.send(()).expect("runner still owned"); + assert!(futures::poll!(&mut operation).is_pending()); + assert!(stopping.is_cancelled()); + assert!(!complete.is_closed()); + tokio::time::advance(Duration::from_millis(3999)).await; + assert!(futures::poll!(&mut operation).is_pending()); + tokio::time::advance(Duration::from_millis(1)).await; + let error = operation + .await + .expect_err("original deadline must fail the owner"); + // Tokio's timer wheel may round the original deadline up by one millisecond. + assert!( + (REQUEST_DEADLINE..=REQUEST_DEADLINE + Duration::from_millis(1)) + .contains(&(Instant::now() - started)) + ); + assert_eq!(error.to_string(), "private file operation did not drain"); + assert!(error.root_cause().to_string().contains("deadline expired")); + assert!(complete.send(Ok(json!({"size": 42}))).is_err()); + Ok(()) +} + +#[tokio::test] +async fn admitted_operation_survives_caller_detach_and_owner_stop() -> Result<()> { + for stop_owner in [false, true] { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + client + .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") + .await?; + let (admitted, admission) = oneshot::channel(); + let (complete, completion) = oneshot::channel(); + let operation = exchange( + server, + &binding, + Duration::from_secs(1), + &stopping, + |_path| async { + admitted.send(()).expect("observe admission"); + completion.await.expect("owned native response") + }, + ); + tokio::pin!(operation); + // Poll actual admission before dropping the caller, without a sleep. + tokio::select! { + result = &mut operation => panic!("operation ended before native reply: {result:?}"), + result = admission => result?, + } + drop(client); + if stop_owner { + stopping.cancel(); + } + assert!(futures::poll!(&mut operation).is_pending()); + complete + .send(Ok(json!({"size": 42}))) + .expect("caller detach must retain native wait"); + operation.await?.require_settled()?; + } + Ok(()) +} + +#[tokio::test] +async fn stopped_owner_does_not_admit_even_a_complete_frame() -> Result<()> { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + let stopping = CancellationToken::new(); + stopping.cancel(); + let (server, mut client) = UnixStream::pair()?; + client + .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") + .await?; + exchange( + server, + &binding, + REQUEST_DEADLINE, + &stopping, + |_path| async { panic!("stopped owner must not admit native work") }, + ) + .await? + .require_settled()?; + drop(client); + Ok(()) +} + +#[tokio::test] +async fn private_socket_collision_never_removes_the_original() -> Result<()> { + let state = PathBuf::from(std::env::var_os("HOME").context("HOME missing")?).join(".parsar"); + let parent = tempfile::Builder::new().prefix("hm-").tempdir_in(state)?; + std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o700))?; + let root = parent.path().join("owner"); + let socket = PrivateSocket::bind(&root)?; + assert_eq!(std::fs::metadata(&root)?.mode() & 0o777, 0o700); + assert_eq!( + std::fs::metadata(root.join("files.sock"))?.mode() & 0o777, + 0o600 + ); + assert!(PrivateSocket::bind(&root).is_err()); + let client = UnixStream::connect(root.join("files.sock")).await?; + let (server, _) = socket.listener.accept().await?; + assert_eq!(server.peer_cred()?.uid(), socket.uid); + drop((client, server, socket)); + assert!(!root.exists()); + Ok(()) +} + +#[tokio::test] +async fn invalid_requests_and_local_manager_never_reach_host_metadata() -> Result<()> { + let manager = EnvironmentManager::default_for_tests(); + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/".into(), + ipc_root: "/unused".into(), + }; + for (request, expected) in [ + ( + b"{\"environment_id\":\"expected\",\"path\":\"etc/passwd\"}\n".to_vec(), + "environment_unavailable", + ), + ( + b"{\"environment_id\":\"expected\",\"path\":\"etc/passwd\",\"operation\":\"read\",\"max_bytes\":1}\n".to_vec(), + "environment_unavailable", + ), + ( + b"{\"environment_id\":\"wrong\",\"path\":\"etc/passwd\"}\n".to_vec(), + "wrong_environment", + ), + (vec![b'x'; MAX_FRAME + 1], "invalid_request"), + ] { + let (server, mut client) = UnixStream::pair()?; + let exchange = async { + client.write_all(&request).await?; + let mut response = Vec::new(); + client.read_to_end(&mut response).await?; + anyhow::Ok(serde_json::from_slice::(&response)?) + }; + let stopping = CancellationToken::new(); + let (_, response) = tokio::try_join!( + serve_connection(server, &manager, &binding, &stopping), + exchange + )?; + assert_eq!(response, json!({"error":expected})); + } + let (server, mut client) = UnixStream::pair()?; + assert!( + tokio::time::timeout( + Duration::from_millis(20), + serve_connection(server, &manager, &binding, &CancellationToken::new()) + ) + .await + .is_err() + ); + assert_eq!(client.read(&mut [0; 1]).await?, 0); + Ok(()) +} + +#[tokio::test] +async fn pending_native_response_requires_owner_failure() -> Result<()> { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + let (server, mut client) = UnixStream::pair()?; + client + .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") + .await?; + let (dispatched, observed) = oneshot::channel(); + let (response, received) = oneshot::channel(); + let outcome = exchange( + server, + &binding, + Duration::from_millis(20), + &CancellationToken::new(), + |_path| async { + dispatched.send(()).unwrap(); + // The remote side has accepted work but has not settled its reply. + received.await.unwrap() + }, + ) + .await?; + observed.await?; + assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); + assert!(outcome.require_settled().is_err()); + assert_eq!(client.read(&mut [0; 1]).await?, 0); + // A response producer can still complete after its receiver was dropped; + // this is why timeout must fail the owner rather than release admission. + assert!(response.send(Ok(json!({"size": 1}))).is_err()); + + let (server, mut client) = UnixStream::pair()?; + let outcome = exchange( + server, + &binding, + Duration::from_millis(20), + &CancellationToken::new(), + |_path| async { panic!("a stalled frame must not dispatch native work") }, + ) + .await?; + outcome.require_settled()?; + assert_eq!(client.read(&mut [0; 1]).await?, 0); + Ok(()) +} + +#[test] +fn rejects_wrong_identity_and_nonrelative_paths() { + let binding = Binding { + write: None, + directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), + native_binary: "/native".into(), + environment: "expected".into(), + workspace: "/workspace".into(), + ipc_root: "/unused".into(), + }; + for path in ["", "/etc/passwd", "../file", "a/../file", "a\\b"] { + let frame = format!("{}\n", json!({"environment_id":"expected","path":path})); + assert!(request_command(frame.as_bytes(), &binding).is_err()); + } + assert!( + request_command( + b"{\"environment_id\":\"wrong\",\"path\":\"file\"}\n", + &binding + ) + .is_err() + ); + assert!( + request_command( + b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n", + &binding + ) + .is_ok() + ); + assert!(request_command(&vec![b'x'; MAX_FRAME + 1], &binding).is_err()); +} diff --git a/packages/codex-harness/src/files_write.rs b/packages/codex-harness/src/files_write.rs new file mode 100644 index 000000000..3542879cd --- /dev/null +++ b/packages/codex-harness/src/files_write.rs @@ -0,0 +1,160 @@ +use super::{OperationError, process_output::Output}; +use crate::options::WriteBinding; +use codex_exec_server::{ + Environment, ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, WriteStatus, +}; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::{ + FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, + FileSystemSpecialPath, NetworkSandboxPolicy, +}; +use codex_sandboxing::SandboxType; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::PathBuf; +use uuid::Uuid; + +pub(super) const MAX_BYTES: usize = 50 * 1024 * 1024; +const CHUNK_BYTES: usize = 64 * 1024; + +pub(super) struct Upload { + pub binding: WriteBinding, + pub workspace: PathBuf, + pub relative: String, + pub size: usize, + pub bytes: Vec, +} + +pub(super) async fn install( + environment: &Environment, + upload: Upload, +) -> Result { + let invalid = |_| OperationError::Rejected("unsupported"); + let workspace = PathUri::from_host_native_path(&upload.workspace).map_err(invalid)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&upload.binding.parent).map_err(invalid)?, + }, + FileSystemAccessMode::Write, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&upload.binding.helper).map_err(invalid)?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + FileSystemAccessMode::Read, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + workspace.clone(), + ); + let started = environment + .get_exec_backend() + .start(ExecParams { + process_id: ProcessId::from(format!("file-write-{}", Uuid::new_v4())), + argv: vec![ + upload.binding.helper.to_string_lossy().into_owned(), + upload.workspace.to_string_lossy().into_owned(), + upload.relative, + upload.size.to_string(), + upload.binding.staging.to_string_lossy().into_owned(), + ], + cwd: workspace, + shell_snapshot: None, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: true, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }) + .await + .map_err(|_| OperationError::Unsettled)?; + let process = started.process.as_ref(); + if !matches!(started.sandbox_type, Some(SandboxType::LinuxSeccomp)) { + return stop_unknown(process).await; + } + transfer(process, &upload.bytes).await +} + +async fn transfer(process: &dyn ExecProcess, bytes: &[u8]) -> Result { + let digest = Sha256::digest(bytes); + for chunk in bytes + .chunks(CHUNK_BYTES) + .chain(std::iter::once(digest.as_slice())) + { + // Native write owns its request identity. Never recapture a connection or + // retry a chunk here; Accepted only acknowledges queued stdin. + match process.write(chunk.to_vec()).await { + Ok(response) if matches!(response.status, WriteStatus::Accepted) => {} + _ => return stop_unknown(process).await, + } + } + let mut output = Output::default(); + loop { + let response = process + .read(Some(output.after()), None, Some(1000)) + .await + .map_err(|_| OperationError::Unsettled)?; + if response.failure.is_some() { + return Err(OperationError::Unsettled); + } + if output.append(&response).is_err() { + return stop_unknown(process).await; + } + if response.closed && response.exited { + if response.exit_code != Some(0) || response.sandbox_denied { + return Err(OperationError::Unsettled); + } + return decode(&output.bytes, bytes.len()); + } + } +} + +async fn stop_unknown(process: &dyn ExecProcess) -> Result { + // Best-effort termination cannot establish whether replacement committed. + // The existing retained owner deadline bounds this attempt and any wait. + let _ = process.terminate().await; + Err(OperationError::Unsettled) +} + +#[derive(Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] +enum Receipt { + Completed { version: u32, size_bytes: usize }, + Failed { version: u32, error: String }, +} + +fn decode(bytes: &[u8], expected: usize) -> Result { + match serde_json::from_slice::(bytes) { + Ok(Receipt::Completed { + version: 1, + size_bytes, + }) if size_bytes == expected => { + Ok(json!({"write":{"size_bytes":size_bytes,"committed":true}})) + } + Ok(Receipt::Failed { version: 1, error }) + if matches!(error.as_str(), "invalid_input" | "write_failed") => + { + Err(OperationError::Rejected("native_error")) + } + _ => Err(OperationError::Unsettled), + } +} + +#[cfg(test)] +#[path = "files_write_process_tests.rs"] +mod tests; diff --git a/packages/codex-harness/src/files_write_process_tests.rs b/packages/codex-harness/src/files_write_process_tests.rs new file mode 100644 index 000000000..b0a25f7af --- /dev/null +++ b/packages/codex-harness/src/files_write_process_tests.rs @@ -0,0 +1,152 @@ +use super::*; +use codex_exec_server::{ + ExecOutputStream, ExecProcessEventReceiver, ExecProcessFuture, ProcessOutputChunk, + ProcessSignal, ReadResponse, WriteResponse, +}; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::watch; + +struct Process { + id: ProcessId, + chunks: Mutex>>, + reject: bool, + receipt: Vec, + terminated: AtomicBool, +} +impl Process { + fn new(receipt: &[u8]) -> Self { + Self { + id: ProcessId::from("test"), + chunks: Mutex::new(Vec::new()), + reject: false, + receipt: receipt.into(), + terminated: AtomicBool::new(false), + } + } +} +impl ExecProcess for Process { + fn process_id(&self) -> &ProcessId { + &self.id + } + fn subscribe_wake(&self) -> watch::Receiver { + watch::channel(0).1 + } + fn subscribe_events(&self) -> ExecProcessEventReceiver { + ExecProcessEventReceiver::empty() + } + fn read( + &self, + after: Option, + max: Option, + _: Option, + ) -> ExecProcessFuture<'_, ReadResponse> { + assert_eq!(after, Some(0)); + assert_eq!(max, None); + Box::pin(async { + Ok(ReadResponse { + chunks: vec![ProcessOutputChunk { + seq: 1, + stream: ExecOutputStream::Stdout, + chunk: self.receipt.clone().into(), + }], + next_seq: 4, + exited: true, + exit_code: Some(0), + closed: true, + failure: None, + sandbox_denied: false, + }) + }) + } + fn write(&self, chunk: Vec) -> ExecProcessFuture<'_, WriteResponse> { + self.chunks.lock().unwrap().push(chunk); + Box::pin(async { + Ok(WriteResponse { + status: if self.reject { + WriteStatus::StdinClosed + } else { + WriteStatus::Accepted + }, + }) + }) + } + fn signal(&self, _: ProcessSignal) -> ExecProcessFuture<'_, ()> { + panic!("unused") + } + fn terminate(&self) -> ExecProcessFuture<'_, ()> { + self.terminated.store(true, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } +} + +#[tokio::test] +async fn exact_binary_chunks_and_empty_body_require_a_commit_receipt() { + for size in [0, 1, CHUNK_BYTES * 2 + 17] { + let bytes: Vec<_> = (0..size).map(|n| (n % 256) as u8).collect(); + let process = Process::new( + format!("{{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":{size}}}").as_bytes(), + ); + assert_eq!( + transfer(&process, &bytes).await.unwrap()["write"]["size_bytes"], + size + ); + let chunks = process.chunks.lock().unwrap(); + assert!(chunks.iter().all(|chunk| chunk.len() <= CHUNK_BYTES)); + assert_eq!(chunks[..chunks.len() - 1].concat(), bytes); + assert_eq!( + chunks.last().unwrap().as_slice(), + Sha256::digest(&bytes).as_slice() + ); + assert!(!process.terminated.load(Ordering::SeqCst)); + } + let process = Process::new(b""); + assert!(matches!( + transfer(&process, b"data").await, + Err(OperationError::Unsettled) + )); +} + +#[tokio::test] +async fn rejected_stdin_stops_without_replaying_or_reporting_commit() { + let mut process = Process::new(b""); + process.reject = true; + assert!(matches!( + transfer(&process, &vec![0; CHUNK_BYTES + 1]).await, + Err(OperationError::Unsettled) + )); + assert_eq!(process.chunks.lock().unwrap().len(), 1); + assert!(process.terminated.load(Ordering::SeqCst)); +} + +#[test] +fn only_complete_versioned_exact_receipts_resolve_the_write() { + assert!(matches!( + decode( + br#"{"version":1,"outcome":"failed","error":"write_failed"}"#, + 3 + ), + Err(OperationError::Rejected("native_error")) + )); + for value in [ + json!({"version":1,"outcome":"completed","size_bytes":2}), + json!({"version":2,"outcome":"completed","size_bytes":3}), + json!({"version":1,"outcome":"completed","size_bytes":3,"error":null}), + json!({"version":1,"outcome":"unknown","error":"write_failed"}), + json!({"version":1,"outcome":"failed","error":"unknown"}), + ] { + assert!(matches!( + decode(&serde_json::to_vec(&value).unwrap(), 3), + Err(OperationError::Unsettled) + )); + } + for bytes in [ + b"{}".as_slice(), + b"{", + br#"{"version":1,"outcome":"completed","size_bytes":3}{}"#, + ] { + assert!(matches!(decode(bytes, 3), Err(OperationError::Unsettled))); + } +} diff --git a/packages/codex-harness/src/files_write_tests.rs b/packages/codex-harness/src/files_write_tests.rs new file mode 100644 index 000000000..afac7ddd8 --- /dev/null +++ b/packages/codex-harness/src/files_write_tests.rs @@ -0,0 +1,156 @@ +use super::*; +use crate::options::WriteBinding; + +fn binding() -> Binding { + let workspace = PathBuf::from("/data/workspace"); + Binding { + write: WriteBinding::from_paths( + &workspace, + Some("/bin/helper".into()), + Some("/data/staging".into()), + ) + .unwrap(), + directory_helper: None, + native_binary: "/native".into(), + environment: "expected".into(), + workspace, + ipc_root: "/unused".into(), + } +} +fn frame(path: &str, size: usize) -> Vec { + format!( + "{}\n", + json!({"environment_id":"expected","path":path,"operation":"write","size_bytes":size}) + ) + .into_bytes() +} + +#[test] +fn write_is_opt_in_and_never_admitted_by_read_only_preparation() { + let mut binding = binding(); + for size in [0, write::MAX_BYTES] { + assert!(request_command(&frame("a/b", size), &binding).is_ok()); + } + assert!(request_command(&frame("file", write::MAX_BYTES + 1), &binding).is_err()); + for path in ["", "/file", "a//b", "a/./b", "../b", "a/", "a\nb"] { + assert!(request_command(&frame(path, 0), &binding).is_err()); + } + for extra in [ + json!({"max_bytes":1}), + json!({"max_entries":1}), + json!({"size_bytes":null}), + json!({"size_bytes":-1}), + ] { + let mut value: Value = serde_json::from_slice(&frame("a", 0)).unwrap(); + value + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + assert!(request_command(format!("{value}\n").as_bytes(), &binding).is_err()); + } + binding.restrict_reads(true); + assert!(matches!( + request_command(&frame("file", 0), &binding), + Err("unsupported") + )); +} + +#[tokio::test] +async fn body_must_be_complete_before_any_native_dispatch() -> Result<()> { + let binding = binding(); + for (declared, payload) in [ + (4, b"abc".as_slice()), + (write::MAX_BYTES + 1, b"".as_slice()), + ] { + let (server, mut client) = UnixStream::pair()?; + client.write_all(&frame("file", declared)).await?; + client.write_all(payload).await?; + client.shutdown().await?; + assert_eq!( + exchange( + server, + &binding, + REQUEST_DEADLINE, + &CancellationToken::new(), + |_| async { panic!("incomplete input reached native execution") } + ) + .await?, + ConnectionOutcome::Settled + ); + } + Ok(()) +} + +#[tokio::test] +async fn coalesced_header_and_binary_body_are_preserved_and_detach_retains_wait() -> Result<()> { + for size in [0, 256 * 1024 + 3] { + let binding = binding(); + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + let bytes: Vec<_> = (0..size).map(|n| (n % 256) as u8).collect(); + let mut input = frame("binary", size); + input.extend(&bytes); + let send = tokio::spawn(async move { + client.write_all(&input).await?; + Ok::<_, std::io::Error>(client) + }); + let (admitted, admission) = oneshot::channel(); + let (complete, completion) = oneshot::channel(); + let operation = exchange( + server, + &binding, + REQUEST_DEADLINE, + &stopping, + |command| async move { + let upload = command.write.expect("write"); + assert_eq!(upload.bytes, bytes); + admitted.send(()).unwrap(); + completion.await.unwrap() + }, + ); + tokio::pin!(operation); + tokio::select! { result = &mut operation => panic!("premature: {result:?}"), result = admission => result? } + drop(send.await??); + stopping.cancel(); + assert!(futures::poll!(&mut operation).is_pending()); + complete.send(Err(OperationError::Unsettled)).unwrap(); + assert_eq!( + operation.await?, + ConnectionOutcome::UnsettledNativeOperation + ); + } + Ok(()) +} + +#[tokio::test] +async fn body_shutdown_is_safe_but_native_deadline_is_unresolved() -> Result<()> { + for admitted in [false, true] { + let binding = binding(); + let stopping = CancellationToken::new(); + let (server, mut client) = UnixStream::pair()?; + client + .write_all(&frame("pending", if admitted { 0 } else { 1 })) + .await?; + server.readable().await?; + let (started, start) = oneshot::channel(); + let duration = Duration::from_millis(10); + let operation = exchange(server, &binding, duration, &stopping, |_| async move { + started.send(()).unwrap(); + std::future::pending::>().await + }); + tokio::pin!(operation); + if admitted { + tokio::select! { result = &mut operation => panic!("premature: {result:?}"), result = start => result? } + stopping.cancel(); + assert_eq!( + operation.await?, + ConnectionOutcome::UnsettledNativeOperation + ); + } else { + assert!(futures::poll!(&mut operation).is_pending()); + stopping.cancel(); + assert_eq!(operation.await?, ConnectionOutcome::Settled); + } + } + Ok(()) +} diff --git a/packages/codex-harness/src/main.rs b/packages/codex-harness/src/main.rs new file mode 100644 index 000000000..8ffc34d9a --- /dev/null +++ b/packages/codex-harness/src/main.rs @@ -0,0 +1,211 @@ +mod files; +mod options; +mod owner; +mod read_profile; + +use anyhow::{Context, Result}; +use clap::Parser; +use codex_app_server::{ + AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, + PluginStartupTasks, RemoteControlStartupMode, + run_main_with_transport_options_and_environment_manager, +}; +use codex_arg0::{Arg0DispatchPaths, Arg0PathEntryGuard, arg0_dispatch}; +use codex_config::LoaderOverrides; +use codex_protocol::protocol::SessionSource; +use std::future::Future; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +fn main() -> Result<()> { + let (binding, _native_paths) = prepare_native(); + let cli = options::Cli::parse(); + run_owned_runtime(run_harness(cli, binding?)) +} + +fn prepare_native() -> (Result, Option) { + // Freeze operator selectors before native dotenv loading can change the + // environment. Native helper dispatch and CLI help may exit without them. + let binding = options::Binding::from_environment(); + let paths = arg0_dispatch(); + (binding, paths) +} + +fn run_owned_runtime(operation: impl Future>) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let result = runtime.block_on(operation); + // Native stdin uses an uncancellable blocking read. Bound local teardown so + // the caller observes process exit even while it keeps stdin open. + runtime.shutdown_timeout(Duration::from_secs(1)); + result +} + +async fn run_harness(cli: options::Cli, mut binding: options::Binding) -> Result<()> { + let read_only = cli.workspace_read_only; + binding.restrict_reads(read_only); + let loader = if read_only { + read_profile::loader(&std::path::PathBuf::from( + std::env::var_os("CODEX_HOME").context("read preparation requires CODEX_HOME")?, + ))? + } else { + LoaderOverrides::default() + }; + let overrides = cli.overrides()?; + binding.check_native().await?; + let socket = files::PrivateSocket::bind(&binding.ipc_root)?; + let (publish, published) = oneshot::channel(); + let runner = run_main_with_transport_options_and_environment_manager( + Arg0DispatchPaths { + codex_self_exe: Some(binding.native_binary.clone()), + ..Default::default() + }, + overrides, + loader, + false, + false, + AppServerTransport::Stdio, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions { + plugin_startup_tasks: if read_only { + PluginStartupTasks::Skip + } else { + PluginStartupTasks::Start + }, + remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, + ..Default::default() + }, + publish, + ); + // The stock single-client runner owns stdin/stdout. No typed event client or + // forwarding queue is inserted between it and the existing Go RPC caller. + let stopping = CancellationToken::new(); + owner::supervise( + async { runner.await.context("native harness stopped") }, + socket.serve(published, &binding, &stopping), + &stopping, + ) + .await + // Process exit is not evidence that remote mutations or descendants retired. +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::Instant; + + #[test] + fn native_bootstrap_loads_credentials_and_freezes_binding() -> Result<()> { + let state = + std::path::PathBuf::from(std::env::var_os("HOME").context("HOME")?).join(".parsar"); + let home = tempfile::Builder::new().prefix("hb-").tempdir_in(state)?; + std::fs::write( + home.path().join(".env"), + "PARSAR_HARNESS_TEST_PROVIDER_KEY=from-native-dotenv\nPARSAR_CODEX_HARNESS_WORKSPACE=/wrong\nCODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=wrong\nPARSAR_CODEX_HARNESS_WRITE_HELPER=/wrong/helper\nPARSAR_CODEX_HARNESS_STAGING=/wrong/staging\n", + )?; + let output = Command::new(std::env::current_exe()?) + .args(["--exact", "tests::native_bootstrap_child", "--nocapture"]) + .env("PARSAR_HARNESS_BOOTSTRAP_TEST", "1") + .env_remove("PARSAR_HARNESS_TEST_PROVIDER_KEY") + .env("CODEX_HOME", home.path()) + .env("PARSAR_CODEX_HARNESS_NATIVE", "/operator/codex") + .env("PARSAR_CODEX_HARNESS_WORKSPACE", "/operator/workspace") + .env("PARSAR_CODEX_HARNESS_WRITE_HELPER", "/trusted/installer") + .env("PARSAR_CODEX_HARNESS_STAGING", "/operator/staging") + .env("PARSAR_CODEX_HARNESS_IPC_ROOT", home.path().join("ipc")) + .env( + "PARSAR_CODEX_HARNESS_ENVIRONMENT", + "11111111-1111-4111-8111-111111111111", + ) + .env( + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", + "11111111-1111-4111-8111-111111111111", + ) + .output()?; + assert!(output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("native bootstrap verified")); + Ok(()) + } + + #[test] + fn native_bootstrap_child() -> Result<()> { + if std::env::var_os("PARSAR_HARNESS_BOOTSTRAP_TEST").is_none() { + return Ok(()); + } + let (binding, _native_paths) = prepare_native(); + let binding = binding?; + assert_eq!( + std::env::var("PARSAR_HARNESS_TEST_PROVIDER_KEY")?, + "from-native-dotenv" + ); + assert_eq!(std::env::var("PARSAR_CODEX_HARNESS_WORKSPACE")?, "/wrong"); + assert_eq!( + binding.workspace, + std::path::Path::new("/operator/workspace") + ); + assert_eq!( + std::env::var("CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID")?, + binding.environment + ); + let write = binding.write.context("frozen write binding")?; + assert_eq!(write.helper, std::path::Path::new("/trusted/installer")); + assert_eq!(write.staging, std::path::Path::new("/operator/staging")); + println!("native bootstrap verified"); + Ok(()) + } + + #[test] + fn runtime_failure_exits_with_stdin_open() { + let mut child = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", "tests::runtime_failure_child", "--nocapture"]) + .env("PARSAR_HARNESS_SHUTDOWN_TEST", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start shutdown child"); + let held_stdin = child.stdin.take().expect("child stdin"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if child.try_wait().expect("poll child").is_some() { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("runtime shutdown waited for open stdin"); + } + std::thread::sleep(Duration::from_millis(20)); + } + let output = child.wait_with_output().expect("read child EOF"); + drop(held_stdin); + assert!(output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("runtime failure returned")); + } + + #[test] + fn runtime_failure_child() { + if std::env::var_os("PARSAR_HARNESS_SHUTDOWN_TEST").is_none() { + return; + } + let error = run_owned_runtime(async { + let (started, wait_started) = oneshot::channel(); + // Exercise the blocking-pool read used by native Tokio stdin, with + // a deterministic admission signal instead of a timing assumption. + tokio::task::spawn_blocking(move || { + started.send(()).expect("signal blocking read"); + let _ = std::io::stdin().read(&mut [0_u8; 1]); + }); + wait_started.await?; + anyhow::bail!("controlled native operation failure") + }) + .expect_err("native failure survives runtime shutdown"); + assert_eq!(error.to_string(), "controlled native operation failure"); + println!("runtime failure returned"); + } +} diff --git a/packages/codex-harness/src/options.rs b/packages/codex-harness/src/options.rs new file mode 100644 index 000000000..7ef6fec5e --- /dev/null +++ b/packages/codex-harness/src/options.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Result, ensure}; +use clap::{Parser, Subcommand}; +use codex_features::is_known_feature_key; +use codex_utils_cli::CliConfigOverrides; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; +use uuid::Uuid; + +#[derive(Parser)] +#[command(version, about = "Private exact-pin Parsar Codex harness integration")] +pub struct Cli { + #[arg(long, global = true)] + pub workspace_read_only: bool, + #[command(flatten)] + config: CliConfigOverrides, + #[arg(long, global = true)] + enable: Vec, + #[arg(long, global = true)] + disable: Vec, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + AppServer { + #[arg(long, required = true)] + stdio: bool, + }, +} + +impl Cli { + pub fn overrides(self) -> Result { + let mut config = self.config; + for (features, enabled) in [(self.enable, true), (self.disable, false)] { + for feature in features { + ensure!(is_known_feature_key(&feature), "unknown native feature"); + config + .raw_overrides + .push(format!("features.{feature}={enabled}")); + } + } + Ok(config) + } +} + +#[path = "options_write.rs"] +mod write; +pub use write::WriteBinding; + +pub struct Binding { + pub write: Option, + pub native_binary: PathBuf, + pub directory_helper: Option, + pub environment: String, + pub workspace: PathBuf, + pub ipc_root: PathBuf, +} + +impl Binding { + pub fn from_environment() -> Result { + fn required(suffix: &str) -> Result { + std::env::var(format!("PARSAR_CODEX_HARNESS_{suffix}")) + .context("explicit private harness configuration is required") + } + let workspace = PathBuf::from(required("WORKSPACE")?); + let binding = Self { + write: WriteBinding::from_environment(&workspace)?, + native_binary: PathBuf::from(required("NATIVE")?), + directory_helper: std::env::var_os("PARSAR_CODEX_HARNESS_DIRECTORY_HELPER") + .filter(|value| !value.is_empty()) + .map(PathBuf::from), + environment: required("ENVIRONMENT")?, + workspace, + ipc_root: PathBuf::from(required("IPC_ROOT")?), + }; + let id = Uuid::parse_str(&binding.environment).context("invalid Environment identity")?; + ensure!( + !id.is_nil() && id.to_string() == binding.environment, + "canonical Environment UUID required" + ); + ensure!( + std::env::var("CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID") + .ok() + .as_deref() + == Some(binding.environment.as_str()), + "native registry Environment must match the operator binding" + ); + ensure!( + clean_absolute(&binding.native_binary), + "native helper requires an absolute path" + ); + ensure!( + clean_absolute(&binding.workspace), + "remote workspace requires an absolute path" + ); + ensure!( + clean_absolute(&binding.ipc_root), + "IPC root requires an absolute path" + ); + Ok(binding) + } + + pub fn restrict_reads(&mut self, read_only: bool) { + if read_only { + self.write = None; + } + } + + pub async fn check_native(&self) -> Result<()> { + let mut command = tokio::process::Command::new(&self.native_binary); + command.arg("--version").kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(5), command.output()) + .await + .context("native version probe timed out")? + .context("native version probe failed")?; + ensure!( + output.status.success() && output.stdout == b"codex-cli 0.153.4\n", + "matching stock Codex 0.153.4 helper required" + ); + Ok(()) + } +} + +fn clean_absolute(path: &Path) -> bool { + path.is_absolute() + && path + .components() + .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_existing_rpc_arguments_and_rejects_unknown_modes() -> Result<()> { + let args = Cli::try_parse_from([ + "harness", + "-c", + "model=example", + "app-server", + "--stdio", + "--disable", + "multi_agent", + ])?; + let config = args.overrides()?; + assert_eq!( + config.raw_overrides, + ["model=example", "features.multi_agent=false"] + ); + assert!( + Cli::try_parse_from(["harness", "app-server", "--listen", "ws://0.0.0.0:1"]).is_err() + ); + assert!(Cli::try_parse_from(["harness", "exec"]).is_err()); + Ok(()) + } +} diff --git a/packages/codex-harness/src/options_write.rs b/packages/codex-harness/src/options_write.rs new file mode 100644 index 000000000..7789e43e7 --- /dev/null +++ b/packages/codex-harness/src/options_write.rs @@ -0,0 +1,113 @@ +use anyhow::{Context, Result, bail, ensure}; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct WriteBinding { + pub helper: PathBuf, + pub staging: PathBuf, + pub parent: PathBuf, +} + +impl WriteBinding { + pub fn from_environment(workspace: &Path) -> Result> { + let helper = std::env::var_os("PARSAR_CODEX_HARNESS_WRITE_HELPER").map(PathBuf::from); + let staging = std::env::var_os("PARSAR_CODEX_HARNESS_STAGING").map(PathBuf::from); + Self::from_paths(workspace, helper, staging) + } + + pub fn from_paths( + workspace: &Path, + helper: Option, + staging: Option, + ) -> Result> { + let (helper, staging) = match (helper, staging) { + (None, None) => return Ok(None), + (Some(helper), Some(staging)) => (helper, staging), + _ => bail!("write helper and protected staging must be configured together"), + }; + ensure!( + clean(workspace) && clean(&helper) && clean(&staging), + "write binding requires clean absolute paths" + ); + let parent = workspace + .parent() + .filter(|parent| *parent != Path::new("/")) + .context("workspace must have a non-root Environment parent")?; + ensure!( + staging.parent() == Some(parent) && staging != workspace, + "workspace and staging must be distinct siblings below one private Environment parent" + ); + let parent = parent.to_owned(); + ensure!( + !helper.starts_with(&parent), + "write helper must be outside the writable Environment parent" + ); + Ok(Some(Self { + helper, + staging, + parent, + })) + } +} + +fn clean(path: &Path) -> bool { + path.to_str().is_some_and(|value| { + value.starts_with('/') + && !value.contains(['\\', '\0', '\r', '\n']) + && value + .split('/') + .skip(1) + .all(|part| !part.is_empty() && part != "." && part != "..") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_explicit_disjoint_siblings_with_external_helper_are_admitted() { + let workspace = Path::new("/data/workspace"); + assert!( + WriteBinding::from_paths(workspace, None, None) + .unwrap() + .is_none() + ); + for (helper, staging) in [ + (Some("/bin/helper"), None), + (None, Some("/data/staging")), + (Some("/data/helper"), Some("/data/staging")), + (Some("/bin/helper"), Some("/data/workspace")), + (Some("/bin/helper"), Some("/data/workspace/staging")), + (Some("/bin/helper"), Some("/other/staging")), + (Some("/bin//helper"), Some("/data/staging")), + (Some("/bin/helper"), Some("/data/../staging")), + ] { + assert!( + WriteBinding::from_paths( + workspace, + helper.map(Into::into), + staging.map(Into::into) + ) + .is_err() + ); + } + assert!( + WriteBinding::from_paths( + Path::new("/workspace"), + Some("/bin/helper".into()), + Some("/staging".into()) + ) + .is_err() + ); + assert!( + WriteBinding::from_paths( + workspace, + Some("/bin/helper".into()), + Some("/data/staging".into()) + ) + .unwrap() + .is_some() + ); + } +} diff --git a/packages/codex-harness/src/owner.rs b/packages/codex-harness/src/owner.rs new file mode 100644 index 000000000..eb036de0a --- /dev/null +++ b/packages/codex-harness/src/owner.rs @@ -0,0 +1,81 @@ +use anyhow::{Context, Result}; +use std::future::Future; +use tokio_util::sync::CancellationToken; + +/// Stop file admission with the runner, retaining the admitted operation's wait. +/// The file service owns its existing deadline; this does not reset that budget. +pub async fn supervise( + runner: impl Future>, + files: impl Future>, + stopping: &CancellationToken, +) -> Result<()> { + tokio::pin!(runner, files); + tokio::select! { + biased; + result = &mut runner => { + stopping.cancel(); + files.await.context("private file operation did not drain")?; + result + }, + result = &mut files => result.context("private metadata endpoint stopped"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::oneshot; + + #[tokio::test] + async fn runner_failure_waits_for_drain_and_remains_a_failure() -> Result<()> { + let stopping = CancellationToken::new(); + let (finish, finished) = oneshot::channel(); + let operation = supervise( + async { anyhow::bail!("runner failed") }, + async { + stopping.cancelled().await; + finished.await?; + Ok(()) + }, + &stopping, + ); + tokio::pin!(operation); + assert!(futures::poll!(&mut operation).is_pending()); + assert!(stopping.is_cancelled()); + finish.send(()).expect("drain still owned"); + assert_eq!(operation.await.unwrap_err().to_string(), "runner failed"); + Ok(()) + } + + #[tokio::test] + async fn unresolved_drain_is_not_clean_runner_exit() { + let stopping = CancellationToken::new(); + let result = supervise( + async { Ok(()) }, + async { + stopping.cancelled().await; + anyhow::bail!("native response unresolved") + }, + &stopping, + ) + .await; + let error = result.unwrap_err(); + assert_eq!(error.to_string(), "private file operation did not drain"); + assert_eq!(error.root_cause().to_string(), "native response unresolved"); + } + + #[tokio::test] + async fn file_failure_still_stops_the_runner() { + let stopping = CancellationToken::new(); + let result = supervise( + std::future::pending(), + async { anyhow::bail!("native deadline") }, + &stopping, + ) + .await; + assert_eq!( + result.unwrap_err().root_cause().to_string(), + "native deadline" + ); + } +} diff --git a/packages/codex-harness/src/read_profile.rs b/packages/codex-harness/src/read_profile.rs new file mode 100644 index 000000000..463edc9c2 --- /dev/null +++ b/packages/codex-harness/src/read_profile.rs @@ -0,0 +1,87 @@ +use anyhow::{Result, ensure}; +use codex_config::LoaderOverrides; +use std::path::Path; + +// Read preparation has no execution configuration. Keep native security +// requirements, while excluding host/user/project model, MCP and plugin settings. +pub fn loader(home: &Path) -> Result { + loader_with_legacy_path(home, Path::new("/etc/codex/managed_config.toml")) +} + +fn loader_with_legacy_path(home: &Path, legacy: &Path) -> Result { + // Native legacy config also supplies enforced requirements. Do not silently + // remove those constraints while isolating execution configuration. + ensure!( + !legacy.try_exists()?, + "read preparation requires separate native requirements, not legacy managed config" + ); + ensure!( + home.is_absolute(), + "read preparation requires an absolute home" + ); + let empty = home.join("read-config.toml"); + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&empty)?; + Ok(LoaderOverrides { + system_config_path: Some(empty.clone()), + managed_config_path: Some(empty), + ignore_user_config: true, + ignore_project_config: true, + ..LoaderOverrides::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::config::{ConfigBuilder, ConfigOverrides}; + + #[test] + fn rejects_legacy_requirements_instead_of_dropping_them() -> Result<()> { + let state = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join(".parsar"); + let root = tempfile::Builder::new() + .prefix("read-legacy-") + .tempdir_in(state)?; + let legacy = root.path().join("managed_config.toml"); + std::fs::write(&legacy, "approval_policy = 'never'\n")?; + assert!(loader_with_legacy_path(root.path(), &legacy).is_err()); + assert!(!root.path().join("read-config.toml").exists()); + Ok(()) + } + + #[tokio::test] + async fn ignores_execution_layers_but_keeps_security_requirements() -> Result<()> { + let state = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join(".parsar"); + let root = tempfile::Builder::new() + .prefix("read-config-") + .tempdir_in(state)?; + let home = root.path().join("home"); + let project = root.path().join("project"); + std::fs::create_dir_all(&home)?; + std::fs::create_dir_all(project.join(".codex"))?; + let sentinel = + "model = 'must-not-load'\n[mcp_servers.sentinel]\ncommand = '/must-not-run'\n"; + std::fs::write(home.join("config.toml"), sentinel)?; + std::fs::write(project.join(".codex/config.toml"), sentinel)?; + let overrides = loader(&home)?; + assert_eq!(overrides.system_config_path, overrides.managed_config_path); + assert!(std::fs::read(overrides.system_config_path.as_ref().unwrap())?.is_empty()); + assert!(!overrides.ignore_managed_requirements); + assert!(!overrides.ignore_login_requirements); + assert!(overrides.system_requirements_path.is_none()); + let config = ConfigBuilder::default() + .codex_home(home) + .loader_overrides(overrides) + .harness_overrides(ConfigOverrides { + cwd: Some(project), + ..Default::default() + }) + .build() + .await?; + assert_ne!(config.model.as_deref(), Some("must-not-load")); + assert!(config.mcp_servers.get().is_empty()); + Ok(()) + } +} diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md new file mode 100644 index 000000000..2c7872e01 --- /dev/null +++ b/packages/mcode-harness/README.md @@ -0,0 +1,54 @@ +# MiniMax Code workspace bridge + +This adapter companion keeps the published MiniMax Code CLI, ACP, model loop and +history. Its trusted MCP server exposes six original native tools inside the +upstream-vendored Linux sandbox. There is no CLI source patch or replacement loop. +Hosted public execution is not qualified by this package alone. + +The harness process and ACP Session use a private control directory. Builtin file +tools are disabled. Only the adapter registers this bridge; callers cannot supply +its command, profile, working directory or environment. Workspace project files +are read through sandboxed tools rather than imported by the privileged harness. +Native diff/undo capture is not provided by this path. Common Files/Artifacts use +the bound public workspace independently of the native control directory. + +`source.json` pins the native tool and sandbox source. The published CLI is a +separate dependency; both artifacts require qualification. On Linux x86_64: + +```sh +MCODE_NATIVE_SOURCE=/absolute/upstream/checkout bash scripts/build-mcode-harness.sh +``` + +This standalone companion uses its own npm lock and is excluded from the root +pnpm workspace. The build archives the exact source revision, bundles its native tools and sandbox +and installs pinned MCP dependencies. It does not build the upstream CLI. Install +the artifact immutably at `/opt/mcode-harness`. The private profile supplies +`workspace`, `scratch`, `protectedDirs` and `network`. Only the +isolated worker receives the real workspace as its tool root. Missing or mismatched +profiles reject; there is no unsandboxed fallback. + +The bridge owns each launcher until exit. MCP cancellation and transport shutdown +stop all owned workers before releasing the bridge. The outer Runtime owns the +native process group. Both boundaries require real Docker cancellation tests. + +Native tool schemas are retained. Text and image results use standard MCP content; +video results reject explicitly. The published CLI may add task/skill utility tools; +qualification must inspect the actual inventory rather than assume exactly six. + +See [workspace qualification](../../contracts/agents-api/mcode-workspace-v1.md) for +the required tests and stopping conditions. Synthetic isolation probes and native +model runs do not complete public Files/Artifacts or independent Core acceptance. + +For the packaged Linux regression, provide an operator-owned private profile and +artifact directory, then run `native.test.mjs` inside the qualified Docker Runtime: + +```sh +PARSAR_MCODE_NATIVE_PROFILE=/absolute/private-profile.json \ +PARSAR_MCODE_NATIVE_ARTIFACT=/opt/mcode-harness \ +node --test packages/mcode-harness/native.test.mjs +``` + +Run once for each supported network policy. It verifies writable native TMPDIR, +large Bash output retention and subsequent native Read. The ordinary repository +gate skips this case without those explicit inputs; it cannot replace Docker +isolation, cancellation or real-model acceptance. diff --git a/packages/mcode-harness/bridge.mjs b/packages/mcode-harness/bridge.mjs new file mode 100644 index 000000000..2f986f9ea --- /dev/null +++ b/packages/mcode-harness/bridge.mjs @@ -0,0 +1,34 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { readFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { ToolExecutor } from './tool-executor.mjs'; + +const profile = process.argv[2]; +if (!profile || !isAbsolute(profile)) throw new Error('Private workspace profile is required'); +const definitions = JSON.parse(readFileSync(new URL('./dist/tools.json', import.meta.url), 'utf8')); +const tools = new Map(definitions.map(tool => ['workspace_' + tool.name, tool])); +const executor = new ToolExecutor(profile); +const server = new Server({ name: 'parsar-workspace', version: '1' }, { capabilities: { tools: {} } }); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...tools].map(([name, tool]) => ({ ...tool, name, + description: 'Bound working directory: /workspace. ' + tool.description })), +})); +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const tool = tools.get(request.params.name); + if (!tool) throw new Error('Unknown workspace tool'); + const result = await executor.execute(tool.name, request.params.arguments, extra.signal); + if (result.content.some(item => item.type !== 'text' && item.type !== 'image')) + return { isError: true, content: [{ type: 'text', text: 'This workspace transport supports text and image results only.' }] }; + return { isError: result.isError === true, + content: result.content.length ? result.content : [{ type: 'text', text: result.text }] }; +}); + +let shutdown; +const close = () => shutdown ??= executor.close().then(() => server.close()); +server.onclose = () => { void close(); }; +process.stdin.on('end', () => { void close(); }); +process.on('SIGTERM', () => { void close(); }); +process.on('SIGINT', () => { void close(); }); +await server.connect(new StdioServerTransport()); diff --git a/packages/mcode-harness/build-sandbox.mjs b/packages/mcode-harness/build-sandbox.mjs new file mode 100644 index 000000000..25deca882 --- /dev/null +++ b/packages/mcode-harness/build-sandbox.mjs @@ -0,0 +1,18 @@ +import { build } from 'esbuild'; +import { mkdirSync, cpSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const here = dirname(fileURLToPath(import.meta.url)); +const source = process.env.MCODE_SOURCE; +if (!source) throw new Error('MCODE_SOURCE is required'); +mkdirSync(resolve(here, 'dist'), { recursive: true }); +const result = await build({ entryPoints: [resolve(here, 'sandbox-entry.ts')], + outfile: resolve(here, 'dist/sandbox.mjs'), bundle: true, platform: 'node', format: 'esm', + target: 'node22', nodePaths: [resolve(here, 'node_modules')], metafile: true, + plugins: [{ name: 'sandbox-source', setup(b) { b.onResolve({ filter: /^@sandbox\// }, () => + ({ path: resolve(source, 'third_party/sandbox-runtime/src/sandbox/sandbox-manager.ts') })); } }], + banner: { js: 'import { createRequire as srtCreateRequire } from "node:module"; const require = srtCreateRequire(import.meta.url);' }, + logLevel: 'info' }); +cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/seccomp'), resolve(here, 'dist/vendor/seccomp'), { recursive: true }); +cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/java-proxy-agent'), resolve(here, 'dist/vendor/java-proxy-agent'), { recursive: true }); +writeFileSync(resolve(here, 'dist/sandbox-metafile.json'), JSON.stringify(result.metafile, null, 2) + '\n'); diff --git a/packages/mcode-harness/build.mjs b/packages/mcode-harness/build.mjs new file mode 100644 index 000000000..12d6b4a45 --- /dev/null +++ b/packages/mcode-harness/build.mjs @@ -0,0 +1,31 @@ +import { build } from 'esbuild'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const here = dirname(fileURLToPath(import.meta.url)); +const source = process.env.MCODE_SOURCE; +if (!source) throw new Error('MCODE_SOURCE is required'); +const revision = '33b259bbbeb1c16433390869938191d09bdb0680'; +if (readFileSync(resolve(source, '.parsar-source-revision'), 'utf8').trim() !== revision) + throw new Error('source revision mismatch'); +const paths = JSON.parse(readFileSync(resolve(source, 'tsconfig.standalone.json'))).compilerOptions.paths; +const plugin = { name: 'pinned-native-source', setup(builder) { + builder.onResolve({ filter: /^[^./]/ }, ({ path }) => { + if (path === '@earendil-works/pi-coding-agent') return { path: resolve(here, 'native-pi-tools.ts') }; + if (path.startsWith('@native/')) return { path: resolve(source, 'packages/agent-tools/src/desktop', path.slice(8) + '.ts') }; + if (path.startsWith('@pi/')) return { path: resolve(source, 'third_party/pi-mono/packages/coding-agent/src', path.slice(4) + '.ts') }; + if (paths[path]) return { path: resolve(source, paths[path][0]) }; + }); +}}; +mkdirSync(resolve(here, 'dist'), { recursive: true }); +const result = await build({ entryPoints: [resolve(here, 'worker.ts')], outfile: resolve(here, 'dist/worker.mjs'), + bundle: true, platform: 'node', format: 'esm', target: 'node22', packages: 'external', + plugins: [plugin], tsconfig: resolve(source, 'tsconfig.standalone.json'), metafile: true, + banner: { js: 'import { createRequire as workerCreateRequire } from "node:module"; const require = workerCreateRequire(import.meta.url);' }, + logLevel: 'info' }); +writeFileSync(resolve(here, 'dist/metafile.json'), JSON.stringify(result.metafile, null, 2) + '\n'); +const external = [...new Set(Object.values(result.metafile.outputs).flatMap(o => o.imports) + .filter(i => i.external && !i.path.startsWith('node:')).map(i => i.path))]; +writeFileSync(resolve(here, 'dist/build-evidence.json'), JSON.stringify({ revision, + sourceFiles: Object.keys(result.metafile.inputs).length, external }, null, 2) + '\n'); +console.log(JSON.stringify({ sourceFiles: Object.keys(result.metafile.inputs).length, external })); diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs new file mode 100644 index 000000000..4d2cd1b91 --- /dev/null +++ b/packages/mcode-harness/check.mjs @@ -0,0 +1,14 @@ +import '@modelcontextprotocol/sdk/server/index.js'; +import { accessSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Linux x86_64 is required'); +for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', + 'dist/sandbox.mjs', 'dist/vendor/seccomp/x64/apply-seccomp']) accessSync(new URL(file, import.meta.url)); +for (const command of ['bwrap', 'socat', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); +const pin = JSON.parse(readFileSync(new URL('source.json', import.meta.url))); +const tools = JSON.parse(execFileSync(process.execPath, + [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), '/workspace', '--describe'], { maxBuffer: 1024 * 1024 })); +if (tools.map(t => t.name).sort().join(',') !== 'bash,edit,glob,grep,read,write') throw new Error('Native tool inventory mismatch'); +process.stdout.write(JSON.stringify({ protocol: 1, native: pin.version, source: pin.revision }) + '\n'); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs new file mode 100644 index 000000000..51d7ddb29 --- /dev/null +++ b/packages/mcode-harness/launch.mjs @@ -0,0 +1,37 @@ +import { SandboxManager } from './dist/sandbox.mjs'; +import { spawn } from 'node:child_process'; +import { readFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const here = dirname(fileURLToPath(import.meta.url)); +const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); +if (profile.workspace !== process.argv[3] || profile.workspace !== '/workspace') + throw new Error('Workspace profile does not match execution binding'); +if (!['disabled','enabled'].includes(profile.network)) throw new Error('Invalid network policy'); +const baseEnv = {PATH:'/usr/local/bin:/usr/bin:/bin',HOME:profile.scratch,TMPDIR:profile.scratch,LANG:'C.UTF-8'}; +if (profile.systemPackages) { + if (!profile.toolEnvironment) throw new Error('System packages require initialized tool configuration'); + baseEnv.PARSAR_RUNTIME_TOOL_SCRATCH=profile.scratch; +} +let child; +let cancelled=false; +const cancel=()=>{cancelled=true;child?.kill('SIGKILL');}; +process.on('SIGTERM',cancel);process.on('SIGINT',cancel); +try { + mkdirSync(profile.scratch,{recursive:true}); + await SandboxManager.initialize({ + network:{allowedDomains:[],deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled'}, + filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.skills ? ["/environment/initialization/capabilities"] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, + seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, + },undefined,false); + const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; + const command=[process.execPath,join(here,'dist/worker.mjs'),profile.workspace,...(profile.toolEnvironment ? ['--tool-environment'] : []),...(profile.systemPackages ? ['--system-packages'] : [])].map(quote).join(' '); + const wrapped=await SandboxManager.wrapWithSandbox(command,'/bin/bash',undefined,undefined,{baseEnv,sandboxTempDir:profile.scratch}); + if (cancelled) throw new Error('Cancelled before workspace tool start'); + child=spawn('/bin/bash',['-c','exec '+wrapped],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); + process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr); + child.stdin.on('error',()=>cancel()); + const status=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('close',resolve);}); + process.exitCode=cancelled?1:(status??1); +} catch(error) {process.stderr.write(String(error)+'\n');process.exitCode=1;} +finally {await SandboxManager.reset();} diff --git a/packages/mcode-harness/native-pi-tools.ts b/packages/mcode-harness/native-pi-tools.ts new file mode 100644 index 000000000..dadd17e4f --- /dev/null +++ b/packages/mcode-harness/native-pi-tools.ts @@ -0,0 +1,5 @@ +export { createReadTool } from '@pi/core/tools/read'; +export { createWriteTool } from '@pi/core/tools/write'; +export { createEditTool } from '@pi/core/tools/edit'; +export { createBashTool } from '@pi/core/tools/bash'; +export { getShellConfig } from '@pi/utils/shell'; diff --git a/packages/mcode-harness/native.test.mjs b/packages/mcode-harness/native.test.mjs new file mode 100644 index 000000000..7f922cb7c --- /dev/null +++ b/packages/mcode-harness/native.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const profile = process.env.PARSAR_MCODE_NATIVE_PROFILE; +const artifact = process.env.PARSAR_MCODE_NATIVE_ARTIFACT; + +test('packaged native tools use writable scratch and retain large output', { + skip: !profile || !artifact, + timeout: 30_000, +}, async t => { + assert.ok(isAbsolute(profile) && isAbsolute(artifact)); + const config = JSON.parse(await readFile(profile, 'utf8')); + const { ToolExecutor } = await import(pathToFileURL(join(artifact, 'tool-executor.mjs'))); + const executor = new ToolExecutor(profile); + t.after(() => executor.close()); + const temporary = await executor.execute('bash', { + command: 'node -p "require(\'os\').tmpdir()"; f=$(mktemp) && printf TEMP_OK > "$f" && cat "$f" && rm "$f"', + }); + assert.notEqual(temporary.isError, true); + assert.ok(temporary.text.includes(config.scratch)); + assert.ok(temporary.text.includes('TEMP_OK')); + const output = await executor.execute('bash', { + command: 'python3 -c "print((\'native-spill-line\' + chr(10)) * 16000, end=\'\')"', + }); + assert.notEqual(output.isError, true); + const full = output.details?.fullOutputPath; + assert.equal(typeof full, 'string'); + assert.ok(full.startsWith(config.scratch + sep)); + assert.equal(await readFile(full, 'utf8'), 'native-spill-line\n'.repeat(16000)); + const read = await executor.execute('read', { path: full, offset: 1, limit: 1 }); + assert.notEqual(read.isError, true); + assert.ok(read.text.includes('native-spill-line')); +}); diff --git a/packages/mcode-harness/package-lock.json b/packages/mcode-harness/package-lock.json new file mode 100644 index 000000000..712c13f46 --- /dev/null +++ b/packages/mcode-harness/package-lock.json @@ -0,0 +1,1841 @@ +{ + "name": "parsar-mcode-harness", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parsar-mcode-harness", + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "@pondwader/socks5-server": "1.0.10", + "@silvia-odwyer/photon-node": "0.3.4", + "@sinclair/typebox": "0.34.52", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "get-east-asian-width": "1.6.0", + "highlight.js": "10.7.3", + "marked": "18.0.12", + "minimatch": "10.2.6", + "node-forge": "1.4.0", + "typebox": "1.1.38", + "unpdf": "1.8.1", + "zod": "3.25.76" + }, + "devDependencies": { + "esbuild": "0.28.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/marked": { + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz", + "integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/unpdf": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.8.1.tgz", + "integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69 || ^1.0.0" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/mcode-harness/package.json b/packages/mcode-harness/package.json new file mode 100644 index 000000000..0280f4c21 --- /dev/null +++ b/packages/mcode-harness/package.json @@ -0,0 +1,28 @@ +{ + "name": "parsar-mcode-harness", + "private": true, + "type": "module", + "devDependencies": { + "esbuild": "0.28.2" + }, + "dependencies": { + "@pondwader/socks5-server": "1.0.10", + "@silvia-odwyer/photon-node": "0.3.4", + "@sinclair/typebox": "0.34.52", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "get-east-asian-width": "1.6.0", + "highlight.js": "10.7.3", + "marked": "18.0.12", + "minimatch": "10.2.6", + "node-forge": "1.4.0", + "typebox": "1.1.38", + "unpdf": "1.8.1", + "zod": "3.25.76", + "@modelcontextprotocol/sdk": "1.30.0" + }, + "scripts": { + "test": "node --test *.test.mjs" + } +} diff --git a/packages/mcode-harness/sandbox-entry.ts b/packages/mcode-harness/sandbox-entry.ts new file mode 100644 index 000000000..75c6242d7 --- /dev/null +++ b/packages/mcode-harness/sandbox-entry.ts @@ -0,0 +1 @@ +export { SandboxManager } from '@sandbox/sandbox-manager'; diff --git a/packages/mcode-harness/source.json b/packages/mcode-harness/source.json new file mode 100644 index 000000000..132360443 --- /dev/null +++ b/packages/mcode-harness/source.json @@ -0,0 +1,5 @@ +{ + "repository": "https://github.com/MiniMax-AI/minimax-code", + "revision": "33b259bbbeb1c16433390869938191d09bdb0680", + "version": "0.4.12" +} diff --git a/packages/mcode-harness/tool-executor.mjs b/packages/mcode-harness/tool-executor.mjs new file mode 100644 index 000000000..db0e2e36d --- /dev/null +++ b/packages/mcode-harness/tool-executor.mjs @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const limit = 16 * 1024 * 1024; +const launcher = fileURLToPath(new URL('./launch.mjs', import.meta.url)); + +// One bridge owns every launcher until its stdio and sandbox have settled. +export class ToolExecutor { + #calls = new Set(); + #closed = false; + + constructor(profile, entrypoint = launcher) { + this.profile = profile; + this.entrypoint = entrypoint; + } + + async execute(tool, input, signal) { + if (this.#closed) throw new Error('Workspace transport is closed'); + signal?.throwIfAborted(); + const request = JSON.stringify({ tool, input }); + if (Buffer.byteLength(request) > limit) throw new Error('Workspace tool input exceeds limit'); + const child = spawn(process.execPath, [this.entrypoint, this.profile, '/workspace'], { + env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const call = { stop: () => child.kill('SIGTERM') }; + this.#calls.add(call); + let output = Buffer.alloc(0); + let failure; + signal?.addEventListener('abort', call.stop, { once: true }); + if (signal?.aborted) call.stop(); + child.on('error', error => { failure = error; }); + child.stdin.on('error', error => { failure = error; call.stop(); }); + child.stderr.resume(); + child.stdout.on('data', chunk => { + if (output.length + chunk.length > limit) { + failure = new Error('Workspace tool output exceeds limit'); + call.stop(); + } else output = Buffer.concat([output, chunk]); + }); + call.settled = new Promise(resolve => child.on('close', resolve)); + child.stdin.end(request); + try { + const code = await call.settled; + if (failure) throw failure; + signal?.throwIfAborted(); + if (this.#closed) throw new Error('Workspace transport is closed'); + const result = JSON.parse(output.toString('utf8')); + if (result.tool_name !== tool || typeof result.text !== 'string' || !Array.isArray(result.content)) + throw new Error('Invalid native workspace tool result'); + if (code !== 0 && result.isError !== true) throw new Error('Workspace tool execution failed'); + return result; + } finally { + signal?.removeEventListener('abort', call.stop); + this.#calls.delete(call); + } + } + + async close() { + this.#closed = true; + const calls = [...this.#calls]; + for (const call of calls) call.stop(); + await Promise.all(calls.map(call => call.settled)); + } +} diff --git a/packages/mcode-harness/tool-executor.test.mjs b/packages/mcode-harness/tool-executor.test.mjs new file mode 100644 index 000000000..e87ab353c --- /dev/null +++ b/packages/mcode-harness/tool-executor.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, mkdir, writeFile, rm, access } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ToolExecutor } from './tool-executor.mjs'; + +async function fixture(t, body) { + const root = join(homedir(), '.parsar', 'tests'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'mcode-worker-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const script = join(dir, 'launcher.mjs'); + await writeFile(script, body); + const executor = new ToolExecutor(dir, script); + t.after(() => executor.close()); + return { dir, executor }; +} + +test('returns the original native result, including a native tool error', async t => { + const { executor } = await fixture(t, ` + process.stdin.resume(); process.stdin.on('end', () => { + console.log(JSON.stringify({tool_name:'read',text:'denied',content:[],isError:true})); + process.exitCode=1; + });`); + assert.deepEqual(await executor.execute('read', { path: 'private' }), { + tool_name: 'read', text: 'denied', content: [], isError: true, + }); +}); + +test('rejects a result attributed to a different native tool', async t => { + const { executor } = await fixture(t, ` + process.stdin.resume(); process.stdin.on('end', () => + console.log(JSON.stringify({tool_name:'write',text:'wrong',content:[]})));`); + await assert.rejects(executor.execute('read', {}), /Invalid native/); +}); + +for (const shutdown of ['cancel', 'transport close']) { + test(`${shutdown} waits for worker exit and prevents late effects`, async t => { + const { dir, executor } = await fixture(t, ` + import { writeFileSync } from 'node:fs'; + process.stdin.resume(); + process.stdin.on('end', () => { + writeFileSync(process.argv[2]+'/ready','1'); + setTimeout(() => writeFileSync(process.argv[2]+'/late','1'),500); + });`); + const controller = new AbortController(); + const failed = assert.rejects(executor.execute('bash', {}, controller.signal)); + let ready = false; + for (let i = 0; i < 100; i++) { + try { await access(join(dir, 'ready')); ready = true; break; } + catch { await delay(10); } + } + assert.equal(ready, true, 'fixture worker started'); + if (shutdown === 'cancel') controller.abort(); + else await executor.close(); + await failed; + await delay(550); + await assert.rejects(access(join(dir, 'late')), { code: 'ENOENT' }); + await executor.close(); + await assert.rejects(executor.execute('read', {}), /transport is closed/); + }); +} diff --git a/packages/mcode-harness/worker.ts b/packages/mcode-harness/worker.ts new file mode 100644 index 000000000..97666c09c --- /dev/null +++ b/packages/mcode-harness/worker.ts @@ -0,0 +1,48 @@ +import { LocalReadTool, LocalWriteTool, LocalEditTool, LocalBashTool } from '@native/local-pi-tools'; +import { LocalGrepTool } from '@native/local-grep'; +import { LocalGlobTool } from '@native/local-glob'; +import { toRuntimeTool, isRuntimeToolInputValid } from '@mavis/agent-core/tools'; +import { readFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; + +const root = process.argv[2]; +if (!root || !isAbsolute(root)) throw new Error('absolute workspace argument required'); +const tools = [new LocalReadTool(root), new LocalWriteTool(root), new LocalEditTool(root), + new LocalBashTool(root, undefined, { mode: 'off' }), new LocalGrepTool(root), new LocalGlobTool(root)] + .map(toRuntimeTool); +if (process.argv[3] === '--describe') { + process.stdout.write(JSON.stringify(tools.map(t => ({ name: t.def.name, + description: t.def.description, inputSchema: t.def.schema }))) + '\n'); + process.exit(0); +} +const request = JSON.parse(readFileSync(0, 'utf8')); +const tool = tools.find(value => value.def.name === request.tool); +if (!tool || !request.input || typeof request.input !== 'object' || Array.isArray(request.input) || + !isRuntimeToolInputValid(tools, request.tool, request.input)) + throw new Error('invalid tool request'); +if (process.argv[3] === '--tool-environment') { + const systemShell = process.argv.includes('--system-packages') && request.tool === 'bash'; + const env = JSON.parse(readFileSync('/environment/initialization/tool-env.json', 'utf8')); + for (const [name, value] of Object.entries(env)) { + if (typeof value !== 'string') throw new Error('invalid initialized tool environment'); + if (!systemShell) process.env[name] = value; + } + // The native Bash boundary strips native identity variables even in mode:off. + // Reapply user values in the already isolated shell without changing tools. + if (request.tool === 'bash') { + const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; + request.input.command = systemShell + ? '/usr/bin/python3 -I -S /usr/local/bin/agents-api-tool-root ' + quote(request.input.command) + : '. /environment/initialization/tool-env.sh && eval -- ' + quote(request.input.command); + } +} +const context = { sessionId: 'worker', turnId: 'call', allowBashAutoPromotion: false, + canConsumeBackgroundBashOutput: false }; +try { + const result = await tool.impl.execute(context, request.input); + process.stdout.write(JSON.stringify(result) + '\n'); +} catch (error) { + process.stdout.write(JSON.stringify({ tool_name: request.tool, isError: true, + text: error instanceof Error ? error.message : String(error), content: [] }) + '\n'); + process.exitCode = 1; +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 000000000..4a189d05d --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..fafa066cc --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,966 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/claude-sdk-adapter: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.269 + version: 0.3.269(@anthropic-ai/sdk@0.125.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) + '@modelcontextprotocol/sdk': + specifier: 1.30.0 + version: 1.30.0(zod@4.4.3) + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.9.3 + +packages: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.269': + resolution: {integrity: sha512-o7vdVlbJjkX9RL7+Mwa6owEGFvHNVfbNRRpoYSJ0jzyvimxKa+8kSjwZo7nYBRr+5WydxB/ApSE+xrWflDUhKQ==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.269': + resolution: {integrity: sha512-cRipBje68i3Irqw0coNEo2KSuVnBRZr317G+tohpas3dUPvJ9BfawMTPy+TBTVcg0kZln1TziUOMBIpnLLogyw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.269': + resolution: {integrity: sha512-mKPC61EpvkcziR3WuXgOJqBjyKDQgX6xdkPXpybKvjJ3vLLB+ulII6duJF+XBjzhh4d4jkKaJjxZilU1Ol/tCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.269': + resolution: {integrity: sha512-S2bX/sYXBMNkYuQ4PVCIigmCUG3HUuq57+URWz47ZSi/mA+bAMFDAew2Rvmn5SJZ384oKGPDeZknaL/2ypPD4A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.269': + resolution: {integrity: sha512-t0s+neygaBe9/f7h4n73vFPWEv7ud5WP/7NRIWpihdDZvLS/SvKNoAt3IJR0Js2DMbAh0XF2I9TDAiHRZhUM4A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.269': + resolution: {integrity: sha512-uPiNkQu6CjdnC61Sz0zs4vLdFwkqes90gZwCHl9tCFDPU2Eu3a8UZZ/RALAq10myEMwXcJsKOhszoyaEqO3Pww==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.269': + resolution: {integrity: sha512-1nRPu6seuvFJMd+QzRX5pvruJiaRYvY1kHhEficUNSG5jsXbM/6mrZ6WFUsNxPc5SgHK15u2YvAxesRAlRSD7A==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.269': + resolution: {integrity: sha512-AQFeH8B6/WlPuuHivKo8FzMdvA8vb4ewZ9QBaHKLq0grzk6Kc517fk6yHEnJpCHHKN0s5Kiwih4CyDXo51zt0A==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.269': + resolution: {integrity: sha512-IzghXcFmIEGvkf4WOswDFrYb7twhCNnZxDU+3R0lz5PYPe0K0+QJ+/KzOT9xi5n8ZGn15bKfR2s65k3dFHUz1A==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.125.0': + resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.269': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.269(@anthropic-ai/sdk@0.125.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.125.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.269 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.269 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.269 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.269 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.269 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.269 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.269 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.269 + + '@anthropic-ai/sdk@0.125.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.4.3 + + '@babel/runtime@7.29.2': {} + + '@hono/node-server@2.1.1(hono@4.13.7)': + dependencies: + hono: 4.13.7 + + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.7) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.7 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@stablelib/base64@1.0.1': {} + + '@types/node@26.1.0': + dependencies: + undici-types: 8.3.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.7: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.7: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + ts-algebra@2.0.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..6a9c3b7e1 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/claude-sdk-adapter" diff --git a/provenance/README.md b/provenance/README.md new file mode 100644 index 000000000..6f41a21af --- /dev/null +++ b/provenance/README.md @@ -0,0 +1,57 @@ +# Source import + +This is a tracked-source snapshot from +[`MiniMax-AI-Dev/parsar@72ab4d37d49245f15b63d34f5741780e540bcec0`](https://github.com/MiniMax-AI-Dev/parsar/tree/72ab4d37d49245f15b63d34f5741780e540bcec0). +`source.json` records the original SHA-256 of every imported file. No untracked +files, credentials, build artifacts or source Git history were imported. +The source repository was not modified or stripped of Core. + +## Scope + +| Included | Purpose | +| --- | --- | +| `services/agents-api` | Service, workers, providers, operator commands, migrations, generated queries, tests and deployment files | +| `contracts/agents-api` | Pinned protocol, schema/types, extensions and coverage evidence | +| `apps/parsar-daemon` | Native execution daemon and existing adapters | +| `internal/agentdaemon`, `internal/agentskill`, `internal/runtimecrypto`, `internal/obs/log` | Shared execution protocol, placement, assets, crypto and logging | +| `packages/agents-client` | Core HTTP client and protocol tests, independent of product business code | +| `packages/codex-executor`, `packages/codex-harness` | Pinned native helpers and Harness artifact builder | +| `packages/claude-sdk-adapter`, `packages/mcode-harness`, `packages/tsconfig` | Existing alternative native execution adapters and their build dependencies | +| Selected `scripts` and workflows | API/runtime build, packaging, database/protocol and native checks | + +Parsar's Web, product server/database, business CLI, plugin UI, product deployment +and business documentation remain exclusively in the source repository. Existing +daemon compatibility helpers stay with the unchanged daemon; their presence does +not bring a product backend or make it an execution prerequisite. + +## Adaptations + +Execution sources, tests, migrations and protocol artifacts are byte-identical to +the source snapshot. Five imported files have packaging-only adaptations: + +- `go.mod`/`go.sum`: prune dependencies used only by the excluded product. +- `pnpm-lock.yaml`: prune excluded product workspace importers/dependencies using + pinned pnpm 10.30.3; preserve the native adapter versions. +- `services/agents-api/RELEASE.md` and `HOSTED-RELEASE.md`: link new release commit + IDs to this repository. + +Root README, contributor rules, Makefile, Node workspace and the complete-check +workflow are standalone scaffolding. The contributor guide retains source Core +architecture rules. Product-only checks are absent; equivalent Core persistence, +native/runtime and generated-query checks remain required. + +To audit the initial import: + +```sh +python3 scripts/verify-source-copy.py +``` + +This audit is specific to the import commit. It does not prohibit subsequent Core +changes. Upstream native sources remain pinned in their existing manifests and +must be fetched using the documented builders. This repository is not an offline +vendor archive and does not claim additional live model coverage. + +## Validation + +See [the import acceptance record](verification.md) for commands, environment, +results and limits. No existing mx deployment is changed by this import. diff --git a/provenance/source.json b/provenance/source.json new file mode 100644 index 000000000..728a6a8f5 --- /dev/null +++ b/provenance/source.json @@ -0,0 +1,1308 @@ +{ + "source_repository": "https://github.com/MiniMax-AI-Dev/parsar", + "source_commit": "72ab4d37d49245f15b63d34f5741780e540bcec0", + "copied_roots": [ + "services/agents-api/", + "contracts/agents-api/", + "apps/parsar-daemon/", + "internal/agentdaemon/", + "internal/agentskill/", + "internal/obs/log/", + "internal/runtimecrypto/", + "packages/agents-client/", + "packages/claude-sdk-adapter/", + "packages/codex-executor/", + "packages/codex-harness/", + "packages/mcode-harness/", + "packages/tsconfig/" + ], + "files": { + ".github/workflows/actionlint.yml": "cb81bc0e45661f7113d07250a57b9b1f67e733544b34948ef2543e722afb071c", + ".github/workflows/agents-api.yml": "87429b30fe4e9256f9b74ad5c9cfabfb07e73aabb5030904c497a87e4571cbc0", + ".github/workflows/agents-executor.yml": "4552ecbf1b0d2fc6a94d9f0c7b9134165b760f37b41484293f9093a4a4e14ca7", + ".github/workflows/agents-harness.yml": "4cd5fb9c51cd45cdf040aefaa5cce7513ea60d08910befd31a7f0b4feb5be398", + "LICENSE": "64ff4748b63ecf4b4475c64dbf3f22de3d348e9fd41c0d51d66c976710683087", + "apps/parsar-daemon/.gitignore": "aaccb1a00557171b31d00a99a6a2666856e417964732490685cdcba9f02de491", + "apps/parsar-daemon/cmd/parsar-daemon/main.go": "020ecb5a248d57d01181023eb8788b78ed11bcdb3ab72f747c39360aac11e87a", + "apps/parsar-daemon/internal/agent/binpath/binpath.go": "cd5872bf957d1cabebdf6759102d37e41be848dc81872f04f4c755560aa5e6b4", + "apps/parsar-daemon/internal/agent/binpath/binpath_test.go": "5868f81ecacf1167e1cde149ba514e1710670e11c67078f4d0ffba8ce6c44ae0", + "apps/parsar-daemon/internal/agent/claudecode/ask.go": "2c3ca61e615838872a3671a272c6785c7608a1a82214c0e76c1a095b0d3dbba6", + "apps/parsar-daemon/internal/agent/claudecode/ask_test.go": "91007631db4d6457b13875be1d69ff0dc71d1a078fd076f9866202d70024d615", + "apps/parsar-daemon/internal/agent/claudecode/export_test.go": "ff3c5f512833256eb4c4f806dadf4797544b3db74c8540e8353a4f43575ce272", + "apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go": "d99d36f9400c5d4e76c563812fcc2cd734efbcc5797fd463b2a7b461a2795a71", + "apps/parsar-daemon/internal/agent/claudecode/options.go": "1520ae27b6efff1996c331484d1969b7bdb5a59e7dc8614c4dce0e4ad3d98f7a", + "apps/parsar-daemon/internal/agent/claudecode/options_test.go": "011b0be8aa5c8edf47533d11acf66b03c170e873ff3fd74a263de44534f222b5", + "apps/parsar-daemon/internal/agent/claudecode/parser.go": "09e46395b05e7f5505556455b8af0704944f53895c4f81a17489974e9adc23a4", + "apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go": "9ebfb404149a0d759e438330963fb00b08c9277690ec734e0ff2fcb4c5241f94", + "apps/parsar-daemon/internal/agent/claudecode/parser_result.go": "900890618ad1a4a11b0761f7456f9301ebf338d150e30a4f3f2e75bbff01062f", + "apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go": "179eeeda9809c2a71c9dd8df5e5889164ba01a252ee59e6bfa3c4c77997fe8a4", + "apps/parsar-daemon/internal/agent/claudecode/parser_test.go": "eb924409e5e8eb0a3fe1e095f3b7c4e7d9e2e2af61868780d434b9a37fc8c7e9", + "apps/parsar-daemon/internal/agent/claudecode/permission.go": "3ffa0a52ddf361498593fc734f72c88a81eb4e1b2d04a573c8682e7c9649ea5d", + "apps/parsar-daemon/internal/agent/claudecode/permission_test.go": "8654eaf7ffe874a8c4473a80478d20ba6ef7da0be453c08874b6c048526f6033", + "apps/parsar-daemon/internal/agent/claudecode/plugins.go": "1f71e396b9d4d3138c2836849f175eba4a54b9ac4d3a30c3826edf425dc849f0", + "apps/parsar-daemon/internal/agent/claudecode/plugins_install.go": "64009a0b2e411fa02bb4287244017c2a031987f04089d80022bdcf0083798e9c", + "apps/parsar-daemon/internal/agent/claudecode/plugins_test.go": "63c03c4ca4d65a0073734d6644db43cebe7c23e3275b66e66caa3e700ac71173", + "apps/parsar-daemon/internal/agent/claudecode/session.go": "293e80bdf6fbce6a7bcd6f57ca55dcfbf5f9c096800f575186fe077cd64ffa6d", + "apps/parsar-daemon/internal/agent/claudecode/session_export_test.go": "be15bcc88d4e62d8d01f09f8e371ab30ec696f1d856d3a975ad88da8c09b550d", + "apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go": "a5f1990279c52ad59b034f4e2008a9c92bba48db6f11959e5007fb614f72f29f", + "apps/parsar-daemon/internal/agent/claudecode/session_test.go": "cc7fc01cc078f1a75007414a91b2587eb0ecf826944d380e7f0ab3fc4df278ec", + "apps/parsar-daemon/internal/agent/claudecode/skills.go": "f5b57cf02a9ad57b5d0691ce2c69a921e76ed11cf20a1d5edd6358f15d487f9f", + "apps/parsar-daemon/internal/agent/claudecode/skills_test.go": "114e47ae0adfc22bbb71a4e3ae766d9b7d1847c1edc9e04a92ebe27828e7f746", + "apps/parsar-daemon/internal/agent/claudecode/version.go": "7692b917df4e1acaf975eebbeef5adcd843f9b5756ba906ac0d73cd85697f1fb", + "apps/parsar-daemon/internal/agent/claudecode/version_test.go": "da7b5bd1769e78335f56038c230e839188d16050144f18aeead2a69b35fc7937", + "apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go": "3e9d251367bfd68c12076af2d3886befe29db3e7bb9e8d3092b0482051e3cfdf", + "apps/parsar-daemon/internal/agent/claudesdk/cancellation.go": "3acb22ba215c20f77ea910d4249628ce762bce7f9c3e563a6ed08c49131e2511", + "apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go": "01f09d15f2e31bd6a08a43a49249a3780cc7b44547328857162a7ab36bbb05ba", + "apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go": "53c553e3e9a7e17d97b36f244933f67d4071eaa599e1d34a59e101f67a263c34", + "apps/parsar-daemon/internal/agent/claudesdk/commands.go": "a8f37734495c3a26cb85fdef0f546b16d3b56ed9f11316d107bddc1880b41d3d", + "apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go": "61a36afe71e9b4497fd2463cf08778500dc28d6386a7a416748dee0e0c9c8267", + "apps/parsar-daemon/internal/agent/claudesdk/commands_test.go": "020584edafbc39fece424ef7b8a33b86d773a8e44357c9227bf5954c8801cfb0", + "apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go": "7a6c6877e8ac522d65ffe2404662e6da0f317460501d67e06b4b71fa5dd898b8", + "apps/parsar-daemon/internal/agent/claudesdk/functions.go": "57a0b4a5c01ce00f7649d54c370e4f7de2c661d796cfab606654896eb07a1a67", + "apps/parsar-daemon/internal/agent/claudesdk/functions_test.go": "ed92b02e411a1be46725577e5e28cfc3b2df79fa3d721a4b4d8e549358ed1e3e", + "apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go": "055d9ac5d34e950171cd9d9cebd1faa68ab412c0c3fe0805bc74a4fd83b279ae", + "apps/parsar-daemon/internal/agent/claudesdk/local.go": "0c0c53e03de618a6415c42776199135867940a45d7cd24eebef0c18b7af11549", + "apps/parsar-daemon/internal/agent/claudesdk/local_test.go": "b259e04b9f831c56be3b12d4fa2cf03fcc8b5e826cb15bc92dbfad8421f50534", + "apps/parsar-daemon/internal/agent/claudesdk/mcp.go": "53d5fdd149cb11f1419f71d0ef8c12a08a561a5fca22d9499a308c348d79bf3d", + "apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go": "1814fc5a16a0d69d31a1076bdd0a109914c4b48a0defa87dce81366e050d8171", + "apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go": "4b065854aaa440d3106ab1374dbb019ea5f35b0fe22c88bbb65b9fc02a53154a", + "apps/parsar-daemon/internal/agent/claudesdk/messages_test.go": "7cc9fb3007135965486abe45bb066ea04f99e8f30eb25c8e2f7ac9f903ef1656", + "apps/parsar-daemon/internal/agent/claudesdk/options.go": "9d138bd8f918ea26c52c7bb63e2b6149ac5fd537a3c76ceac74ca7af0cc4bbe5", + "apps/parsar-daemon/internal/agent/claudesdk/options_test.go": "54f7028a977bfb95595c525089c180ec5ae7461a3435ecea2e61408a70c8e3b1", + "apps/parsar-daemon/internal/agent/claudesdk/preparation.go": "4e4a4dc0488dafe44613a36c4d5f6ba00502d3c80bab725702987c73e4f96ae8", + "apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go": "c6820a2f13346f1802a555be8373d25db5370e604b4e046bf79164b111190b78", + "apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go": "9e0d3e5a2f2ca363235d4c3f4c96f81b275614ac83c5fa5e4643d03122a069d4", + "apps/parsar-daemon/internal/agent/claudesdk/provider.go": "9f330bacc1701eae343d2f2753a22039bdb445d036b44375d2517965766f261c", + "apps/parsar-daemon/internal/agent/claudesdk/readiness.go": "d1959e6e92846a7d049194cd070450c19fee487b3d5500742197655b6080cb8a", + "apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go": "83f9466af5e0b030d22cdcb80286461e7e795c1b4f550e8e70b3a7317b540724", + "apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go": "ec2487b91d479ef58a8710d4671810e20ecc9ddeab3760499f5a98d55d3365f4", + "apps/parsar-daemon/internal/agent/claudesdk/session.go": "ba3af76744d609d8b550646f579012ab99b3cde81bf3f52097f2f4faf80acb40", + "apps/parsar-daemon/internal/agent/claudesdk/session_test.go": "550b98b9e5deccb36e2d2b107a864071ded4b6f759fa88be865e4a45b68061a0", + "apps/parsar-daemon/internal/agent/claudesdk/steering.go": "fed7627776ad8607ae739324fdbbb57763b81a943beb50d94760075824cf6b27", + "apps/parsar-daemon/internal/agent/claudesdk/steering_test.go": "0f44c63065f2baac11f236c9c7d7b36c981b3e1611b62bf7dd4fd6572a50ebef", + "apps/parsar-daemon/internal/agent/claudesdk/usage.go": "4fc8e6fa8f45e30b94170c34936f03b53f3fb049e4b03564708af7f0c63ef618", + "apps/parsar-daemon/internal/agent/claudesdk/usage_test.go": "7ec69563a2efdd554d182235a32a965ddd25487d6552309c1604f94c167d58a4", + "apps/parsar-daemon/internal/agent/claudesdk/workspace.go": "3ea6207ac7091a9cef0e6185d930764b9734b761ff96a5768be6712176e29772", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go": "be074dea166d6ae3ba3b610407abd412599a7be59b9e28b80499a05ede150847", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go": "fed3481a554b5f7b13832ad010ead6d21695bbfc50c79e46e8eff08263d29743", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go": "974d50c5c1490caf5b92b1a131a908974e8e09216429976bc733fa8cf95c0ed5", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go": "3e8eb6afddee99b7758835e234e6c0b60230672b984bda573022551430ef18d6", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go": "bfbf67d7210fae477b6828fde12361e7a01a9862b48ce9f2766790386ba1fc74", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go": "998aac3a5e0116c22709d4744d3f16ec38d2c8f77779308951fa70c14c7c9ccb", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go": "d31ccfd1c7ff31ce98378d0d49ee131a48c9ddda83ed3bce8faeb8b912f493fc", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go": "1e90012d85720917b7677101d9bb8ff81576b3894aa93ceaaeae55213eaa5ba6", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go": "dd8c8ce37f43fc49478ad6f20140a78c2e3dc614adb3b192d7667adfa2ed1308", + "apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go": "ad94817ad9bd57017b428f95074cc749651a72bf8c29e02bd7a75b36c00be0bb", + "apps/parsar-daemon/internal/agent/clirunner/process.go": "a935ec83cbc05193dd36ac4c7b970e81680bd427e350d322d44b6187e5d0d02e", + "apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go": "9b5a73d2f7cc727c5af9efcd8982c07c16aa9289876ce81df2283579e1bd9f75", + "apps/parsar-daemon/internal/agent/clirunner/process_group_other.go": "c79dbbfd888e4f66eb60b56064226362ac435957a707ad85b0515d693b5ce5ed", + "apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go": "67eb5ac1089e19124687252f8c2f0abd14a01d5ed73d6199e9c9d4dfc8a5794a", + "apps/parsar-daemon/internal/agent/clirunner/process_test.go": "2a91c6b6140b35d0880ddd1410befa4eea8f7ea09473a99e639b5126a528e89a", + "apps/parsar-daemon/internal/agent/codex/approval_policy.go": "289da803fd3f8813780e3053f4d83f58ea1e560cc9d84c10bc8f6cbc25674814", + "apps/parsar-daemon/internal/agent/codex/approval_policy_test.go": "2c9d1f8aca46da374319e26bbfd8959f84b1ae213512b71f7251b9bac5911208", + "apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go": "5975366996aceb9e26df02e4901a8f51d6ad74a58a3d1d230590fffdb973a295", + "apps/parsar-daemon/internal/agent/codex/environment.go": "28963139a7706223d9eefe6be5fe43ab4df7d1fe46c463c22e757f794cf3b5d3", + "apps/parsar-daemon/internal/agent/codex/environment_local.go": "b56044ebaf5b1f25d0c8b432ed6daffda5357dc3aa372294a6b849a3f3289598", + "apps/parsar-daemon/internal/agent/codex/environment_remote.go": "1532fa3a2c4b2528afb20898ce3652a34e4f7f812042f040c17283549813e493", + "apps/parsar-daemon/internal/agent/codex/environment_remote_test.go": "08a5bff48a463d4c85236b3dc60df5fec605793c0d0684a03c101d0fc1bcde72", + "apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go": "f96ec67b923debea96878fba583098b7c95dd5121fcc62cd16a017243f48cded", + "apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go": "d454945e7f8a87b28291ab543f8257b2b201436ef512088997b3acc59d6af54d", + "apps/parsar-daemon/internal/agent/codex/environment_test.go": "0a1dde498f251f53f465abc8213446c424c3b4e93bed2b527684e5bcd50c768e", + "apps/parsar-daemon/internal/agent/codex/execution_controls.go": "07dd61dde3364dd7647b52d982492d05fdd45c25882318acb0e399fbc621e92e", + "apps/parsar-daemon/internal/agent/codex/execution_controls_test.go": "1ae18a124e963363a4e29c3cd457eee239ece5312ea29cbe76822ac2981653c1", + "apps/parsar-daemon/internal/agent/codex/export_test.go": "c2149376cc2eb2d4bc7a25796df2865017012c5883099dcdee86de4465a91ac9", + "apps/parsar-daemon/internal/agent/codex/functions.go": "2461ff35003f2d9a542bbe0b81833b0d2cadd094300bc88a9a26afa46fb5069e", + "apps/parsar-daemon/internal/agent/codex/functions_test.go": "972dcfa96088eb7ffb817ddb498f772fa68516bf3620f8d476a18b0cf3f61ed0", + "apps/parsar-daemon/internal/agent/codex/generation_config.go": "71e583bf57e00a1b9af2835cb8862aca604fdefae0b9d6c7a53e5cb878c26f6a", + "apps/parsar-daemon/internal/agent/codex/hosted_skills.go": "6001438ef40203aa41bfa0a302cb9d7086c8ded82a0ee52306b4e86796f81a2d", + "apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go": "5157b03a4981be24ef428043e8441d22600a49f548a6b776ddac878b8d7665be", + "apps/parsar-daemon/internal/agent/codex/mcp_config.go": "5e414243c5c8a81b7b7ed34e508ca62f16cfd264d013f7667b2f4f8b117c7f42", + "apps/parsar-daemon/internal/agent/codex/mcp_config_test.go": "a4c292cd063f8c8dee36c0d8d04ac19e3bf062e555fdf9c6641d0136fbcfe20d", + "apps/parsar-daemon/internal/agent/codex/mcp_http.go": "0a532108d6561bc0dbb88f345af64a9cb07fcf59787ddab857a5d19d06d3ace9", + "apps/parsar-daemon/internal/agent/codex/mcp_http_bearer.go": "32c6976300a333cd82c73abd419ffef3d9f1386295e1fefb7da9aa5b884af62e", + "apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go": "a3a362b4943dcb80c7802e9d4e70412b257c38167bafcf315163922282c10b54", + "apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go": "d90385b8ca83506f723269a419cafe9aef79c0f6b7d0f8587a20b163142e44cc", + "apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go": "60a84121397f8d9a9f7a1abd1b8c09b145e0f7b71b61354009f15938f0c451d7", + "apps/parsar-daemon/internal/agent/codex/mcp_http_test.go": "4b8a82633ba5d66325f63ec06af0a8bcac10feec03e9fef780bd89cba06dbec2", + "apps/parsar-daemon/internal/agent/codex/mcp_required_test.go": "fa1791a91fa165169e0d143a911ce069d7526119dd7b3a251c9a2c51784fe069", + "apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go": "fec1405635dc0f338329d6275093b33680414b2536eca67ca2dd5c128db689b8", + "apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go": "0fff6448d14faf33303dc59b44466b61c4a38784d2b5407064283de90775a6ce", + "apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go": "3dcef5b4f99435b0a796db95fdffcfeaa730664082022acaa250d86d1fa5235c", + "apps/parsar-daemon/internal/agent/codex/model_verbosity.go": "a6c032dbcb326edfb53314c528bdd222adfbc4774065ee6f1d465f33c831d39a", + "apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go": "331714b5e730667432803902df91f6d2763c13d56ca25481f0402ed7f4d83852", + "apps/parsar-daemon/internal/agent/codex/options.go": "7632cd7a482ee285b9efa076198f673bafe840ed56e393341cc92966b56b1388", + "apps/parsar-daemon/internal/agent/codex/options_test.go": "623dac85b868cb314e5fd3dba59483c2aaa8184e7339f6f41d0f4776317c95e6", + "apps/parsar-daemon/internal/agent/codex/permission_profile.go": "0e2257628701cc98c06cc9adec07e55d6e2748a42462398f9fbf4e6b4eae6dc2", + "apps/parsar-daemon/internal/agent/codex/permission_profile_test.go": "dd54ab4f56fee5821c57ddb5853465da5f6f84003304616522d84994ed5076ec", + "apps/parsar-daemon/internal/agent/codex/preparation.go": "c9cf20570aab31a2e8cf2980ba9003df690a9fbdf0d87d63e23cfbf14bb9c899", + "apps/parsar-daemon/internal/agent/codex/preparation_close_test.go": "1710efe5c1706812ba4887fe5b21fe3c385ad1e8fc5f5923670e4aa6fd372e65", + "apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go": "e471772785501b69db15d7c5fa2fdfd1651e26e1229e1cc9cf82ff1fe0d53344", + "apps/parsar-daemon/internal/agent/codex/preparation_router_test.go": "9acc27704cda15b85d57ec6fcee600d2b4c1c4e3009558a962af46fe1155c699", + "apps/parsar-daemon/internal/agent/codex/prepared.go": "4daeb8090f91117dbcef0490d717641d3ebb7d7bc359316596f24c3f8ec1a452", + "apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go": "750ad7cdf9830c0c1e2130608b08113a19a17ef217656bf532efbab7ca89b879", + "apps/parsar-daemon/internal/agent/codex/prepared_test.go": "e7096c650d3770da4052ca3d5f26f0b8c3b3456fabda83cc10cc8f0802beabba", + "apps/parsar-daemon/internal/agent/codex/private_harness.go": "8f7be96991effa7e509fcdeb479b88f3b61d852731ba7b6523d4596e1ffecfbe", + "apps/parsar-daemon/internal/agent/codex/private_harness_test.go": "1bb949e1a25e9fff04fd9001c1a76818d0aa4ce02a73cf050125209fbc35329d", + "apps/parsar-daemon/internal/agent/codex/protocol.go": "0f37b8b265cf4631a99c30d1d91b5abb2fde92ee226269a69d6ea8dea9f5e4ad", + "apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go": "df0d79013f09242c24b27a4b2c18f78f27b12307e5be1e93a7a669a70b3eb4bf", + "apps/parsar-daemon/internal/agent/codex/provider_config.go": "72d291bcf59a89d0244b53d30bffd57e90af7701ac835f0535631b9301ff6396", + "apps/parsar-daemon/internal/agent/codex/provider_config_test.go": "9caff41f8949fd45b99668390e684247a4a2a730a8c22a1296197ae5a5147ff8", + "apps/parsar-daemon/internal/agent/codex/recovery.go": "22deb853a5fb1c636039fe7a0f2f4a63d520de6ac81aa9636ddf886b69b9cfe5", + "apps/parsar-daemon/internal/agent/codex/recovery_test.go": "d1fdf36793444caa0a82fd156bf3f0cc1d39ce7c6d37a391bacab65adbbfd333", + "apps/parsar-daemon/internal/agent/codex/resume.go": "495ad30e6e93aa58c7ee0bc1383aaba66a121aaa12f2686f5dce1b80e3cd4a99", + "apps/parsar-daemon/internal/agent/codex/resume_test.go": "aa70f7358a4cb3d02f440b86c5f831adb50bf03d228dc4600acbc97eb357b1e2", + "apps/parsar-daemon/internal/agent/codex/rpc.go": "0f8542848c3c0f247b4d90cb8f5baa716f48249b999704b0f8194519a39bed63", + "apps/parsar-daemon/internal/agent/codex/rpc_close.go": "3454a0e7d7533694828b48c87bb67e8e4ee9c2ee981c144f2e7631d69860de05", + "apps/parsar-daemon/internal/agent/codex/rpc_close_test.go": "6cc303e3f17b35d55be9989b44a44a6f373f7d95b20842d75991c23dce1ae9b5", + "apps/parsar-daemon/internal/agent/codex/rpc_process_test.go": "fad4b2cbba2e1d200e5ff0c946299e71ce358b2c2b32afe0f5a332b6c8b21658", + "apps/parsar-daemon/internal/agent/codex/rpc_request.go": "15835aae901915156a978bf69ca43d59b8d9acedc09bd1d717873c487faae74f", + "apps/parsar-daemon/internal/agent/codex/rpc_test.go": "876ec450d8d16e5485aefbd3dcb81115098d1c34b238632c6b4829bc9dcf99b0", + "apps/parsar-daemon/internal/agent/codex/rpc_write.go": "da8913f4318aedce0746057d3f3856bda0ec24977d957451f35cb7646c96f210", + "apps/parsar-daemon/internal/agent/codex/server_requests.go": "0101cb1d1dbf067d96d07b9cd5426b6d2763196175f21d02bcb237bdea746cc1", + "apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go": "beaccc2b844d5f475267014f34c574f7964aeedfa2e192ed04d8628aa5912a18", + "apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go": "52af6c4ea6534bd144983c5e8c1c7e6ef25365d4cca7d64002838c713f5de035", + "apps/parsar-daemon/internal/agent/codex/server_requests_test.go": "f25594ad791223a92a72ce096ed32432c7c368254ca377bd017923884fddd795", + "apps/parsar-daemon/internal/agent/codex/session.go": "4d2e7c6a10b2bc82121a65447b8aefb32e9caaaa8877a09be7f12d68f37ce989", + "apps/parsar-daemon/internal/agent/codex/session_cancel.go": "6ee16a2209a7826fb6220ff008ed8deb244296a80198901d2ba6506621531d2e", + "apps/parsar-daemon/internal/agent/codex/session_cancel_test.go": "274619a7da540800d953e83f6fb64134b5a46e80eb9bb0587df921ea80bb3825", + "apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go": "68e8aa9cec21b468c28674eccefabbc828357a18234a8853a5c8a396a1bec0df", + "apps/parsar-daemon/internal/agent/codex/session_command_output.go": "499f73869f3b6ebbb20e66cc2c546e2ceeea398e38349e4a2026af4a4a1d65fa", + "apps/parsar-daemon/internal/agent/codex/session_command_output_test.go": "f0f34e56a520bc5da59db34de239785dc5911efce2b79713c1f3702cc445f620", + "apps/parsar-daemon/internal/agent/codex/session_items.go": "d6652b08212a4822d809091c3384bd758a37c7739e4a2ef3c61e70371ae1d663", + "apps/parsar-daemon/internal/agent/codex/session_items_test.go": "0da9b2ee49524f39a8dc2bef7cbd2367d9a410ca70939f1c1a79e1ed1f9b2504", + "apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go": "d43b973e73244fd77e1e97bc232c43f471e0a09dae998183b1cef1a99a49a4bf", + "apps/parsar-daemon/internal/agent/codex/session_log_test.go": "d81d8e0ddefca58d035e20ade5110f150de1905433c414859d18f4676bbe8646", + "apps/parsar-daemon/internal/agent/codex/session_messages.go": "3898536cce5dc521ae61fe705175e8fb5f64dbfd5ebd36140ab162dca40ec2cc", + "apps/parsar-daemon/internal/agent/codex/session_messages_test.go": "684bcfc92263e38bce12c7f1e1e3915d4c0201eca6923d0dfacc4432866fbea5", + "apps/parsar-daemon/internal/agent/codex/session_notifications.go": "56bd3189bcc17866470aeef303f6224eecf1a440f6a4882ca29078dae380ae5e", + "apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go": "b73ed10af256847266230a79cac101a68115935174a0383a72dae5f2972b9288", + "apps/parsar-daemon/internal/agent/codex/session_notifications_test.go": "92053e852ac0b1a4caed9c073b9d53fcc30c9e3c6ff3872568b75a1858445049", + "apps/parsar-daemon/internal/agent/codex/session_output.go": "5f61de02269f7822e68afb0c987863facf95689f77211ea5ad938fba48f4a969", + "apps/parsar-daemon/internal/agent/codex/session_plan.go": "2b6d47b250f39f8bc0ac6995f5fc21f9b07fcbab87919038fedd73861bc0adf9", + "apps/parsar-daemon/internal/agent/codex/session_policy_test.go": "f4f4e2b3a7505f88a11c93f1b4df69076aad0420f914941b26599fc0ef34c70b", + "apps/parsar-daemon/internal/agent/codex/session_run.go": "c6c89f9898a94ede294cc291b2faa548088b36f434947157eae77d592d4009ee", + "apps/parsar-daemon/internal/agent/codex/session_steering.go": "14c2eb149c768f161bee337f25610311748ea57ad1816c008bf8e645c387c326", + "apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go": "4f0a1b4b11d65d4b5c0cd2877fb6796d52c06faa318e5378a5aba5bd1f0858dc", + "apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go": "4b0bc4a1bb0bf2a5e12ec82e0b67a157951195e2e141325ebe660b000930ae16", + "apps/parsar-daemon/internal/agent/codex/session_steering_test.go": "55c09a2d12c701eea8536437fd05281e5e3cda333f9a422a5f427e6f5fae44dd", + "apps/parsar-daemon/internal/agent/codex/session_thread.go": "d9d1a2301325c38393b46c72d9ce6bdb8555ab08115971716bf64e4af3d19264", + "apps/parsar-daemon/internal/agent/codex/session_tools.go": "300b9a03ebfb9aed41bcb767bc4dbc32023853e26bcc9d5a1b8625cdaf003e4c", + "apps/parsar-daemon/internal/agent/codex/session_tools_test.go": "d3d169dfb9cbdd2b872fe0e8426535eb916ba36e0e886e141373399f8e4e6299", + "apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go": "79cd94e4b46b443677cd92c1a2bede10935b4dc3c97da764df2653b0cc66b8c7", + "apps/parsar-daemon/internal/agent/codex/session_usage.go": "e7fac5cb68daa9348a3caa389bd713a281ece748461d48f1196734490e5f1357", + "apps/parsar-daemon/internal/agent/codex/session_usage_test.go": "9cb713b6e7cecbfd01cd70fd90ac7636a217ee925777c648ea04e856a2aa726d", + "apps/parsar-daemon/internal/agent/codex/skills.go": "aba4c03018148bb42018f4a758dcaaa3b86705eca22a7ce3c36fd6cf253a4ac4", + "apps/parsar-daemon/internal/agent/codex/skills_test.go": "323f25a09f2b81115d71c7c9018fcf5275878a21b64f35036a85cdc6c2f70a45", + "apps/parsar-daemon/internal/agent/codex/subagent_metadata.go": "8f4430679c41ae2f2f9cdd82227cde6aff20fdda158768a863cea8b082ac62ac", + "apps/parsar-daemon/internal/agent/codex/subagent_observations.go": "58460c963b4fcf02ca438b9968d95ed233b14f1de3b5dcfa51502a682cb0e5bc", + "apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go": "f9e95eb69c537cb63ea045e211da11e9974b842a61bd8156bfb2d0dd8df25b9a", + "apps/parsar-daemon/internal/agent/codex/subagents.go": "c92d01f53c10462146e7525b720326d74ac956b3f9c445dfd4d6377cc7b662f8", + "apps/parsar-daemon/internal/agent/codex/subagents_test.go": "5607eba543af69833f753fa4357c5becc71469dff3c92d02fb3b8c8a4281bded", + "apps/parsar-daemon/internal/agent/codex/tool_environment.go": "37114ec973b2641672d1b5fbae78e9d9da8752e87ac7cd9fd348b50635ceb28b", + "apps/parsar-daemon/internal/agent/codex/tool_environment_test.go": "66490ee52148f9e63403fb6285a72d3957a355465f0ffc31215d72d852d68801", + "apps/parsar-daemon/internal/agent/codex/tool_observations.go": "6cda336ab1f02e7ea1a9a97b5eec63a0b9635bfa01ce2ca29b573615b344fa99", + "apps/parsar-daemon/internal/agent/codex/tool_observations_test.go": "69cd824c4fcea75538319d4218a6001d894eb854bf3c0c13f69f555ae63ecb23", + "apps/parsar-daemon/internal/agent/codex/verbosity_test.go": "bffc60525d56f5f100e6ce74edc666ec26e21e2a07f2da1a0bf453bd0c30de0f", + "apps/parsar-daemon/internal/agent/codex/version.go": "a49e4258611bdfafbef8b00f475704afe2dba4864a278ebf4ec6d79346307be8", + "apps/parsar-daemon/internal/agent/codex/web_search_test.go": "213552f74af899f549333d9e20c3489a87a1b08e33ad65565c5aa2fc716a8435", + "apps/parsar-daemon/internal/agent/codex/workspace_directory.go": "a667027c8da9e1f22780124ae1a55e39d2d96cfe83f308369b0ac59ef6068ad5", + "apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go": "be2e22344257e54681823d8c35d283b6339a5bd2b08baf8ec92b0949400b9b8c", + "apps/parsar-daemon/internal/agent/codex/workspace_preparation.go": "c99e1727f5b5fc7d900370aa5f18995f03d4651cfb2400f5155e8fb3b17edaf3", + "apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go": "58bc5ca19ea769a8386d0201f35ebe2d2256a5840f01c654aec7673277a69f8a", + "apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go": "2cc792bba6a7a6816626585081685be801d48d3758dc8b1afd32a2a351a5d161", + "apps/parsar-daemon/internal/agent/codex/workspace_read.go": "5df1077d8c43f0bcf863416dd383e29c671c777be153b5db3adb1537a0074705", + "apps/parsar-daemon/internal/agent/codex/workspace_read_test.go": "3789e254d9f90130ca4b688c20c302ef8a9d60f2db5f4e209ea2818d3ee2a3eb", + "apps/parsar-daemon/internal/agent/functions.go": "a0c68ea5511410ac3509e813084295878e4b8d4deee5a219d94b04084b7204cf", + "apps/parsar-daemon/internal/agent/installroot/lock.go": "a50ca3315f3dbc5a2035f49fd3b8858131798e05103b37fae5e08379b8f4a76b", + "apps/parsar-daemon/internal/agent/installroot/lock_test.go": "9d1596fc4cdc17ab92ea43330ec94f6c65be3762aa79b4c28f1d95016dd7d74d", + "apps/parsar-daemon/internal/agent/interactions.go": "5cda7ee84a16aed9d8cffccc761f0c71d6ca0ea3318d437be6c44d1fc6c1b183", + "apps/parsar-daemon/internal/agent/mcode/events.go": "ce16b92aa9e483af4b257cf37700bde866ddada1598d4be06c31028316385c5c", + "apps/parsar-daemon/internal/agent/mcode/execution.go": "6307301313d8671b80e01082701a7f4bcfb3797968e716f3f89cc4c6a5219809", + "apps/parsar-daemon/internal/agent/mcode/execution_test.go": "f36811cd2cd57108c41ad1aba34d21d9605a73678e4096c95e75f29663a7c0c8", + "apps/parsar-daemon/internal/agent/mcode/native_history_test.go": "09dcfc3717ad10449407535b97927784d0551c12cd1dbd9045f15555fc885a68", + "apps/parsar-daemon/internal/agent/mcode/native_test.go": "ca6c4e3a3e52d44e9cab97be6e44fbc03c091c6414770e1d543dde0cd57cb7c1", + "apps/parsar-daemon/internal/agent/mcode/options.go": "41de8639c0033d3aa72e7fd82a90ae08b690ae07e86c1795d75a7ba4e21d95ae", + "apps/parsar-daemon/internal/agent/mcode/options_test.go": "c3b9d62053fa6ba1bb8ff4b7e3ac694a10ca070894d2e5e61202cb77c991db54", + "apps/parsar-daemon/internal/agent/mcode/preparation.go": "0b134ba9f92ab8c6e7032714d35e39948262c4aed7c319972801534ab85a287b", + "apps/parsar-daemon/internal/agent/mcode/preparation_test.go": "8b46473410e96dc2a424c4b7552a3cf85b6076c4a069c5e3a68fe107c1536b6a", + "apps/parsar-daemon/internal/agent/mcode/protocol.go": "5b9f0c00f5d57e530da7646730f7bd08c518e125521a25c77a8f2a4e0d33d3ff", + "apps/parsar-daemon/internal/agent/mcode/questions.go": "2ccf7323067ee1f6ecb5e49128ee7f0761daa2f44fdf577c8cca88bfbff75eef", + "apps/parsar-daemon/internal/agent/mcode/questions_test.go": "b78ce41d2a9631bd43434fd8f389013cdf0278cd5f7b0eaba2e892dd429f6172", + "apps/parsar-daemon/internal/agent/mcode/session.go": "987ede3af8769aef742e1275ad05afa75fe5de3609f683792184e096b461f2ab", + "apps/parsar-daemon/internal/agent/mcode/session_test.go": "bf2065590d6734999cc54e4cdf7fb4632c93a766db9c98a2018b35899b708698", + "apps/parsar-daemon/internal/agent/mcode/steering.go": "a3d6602d069d40460148461b91a8b24b29643f57aec005c51362c1cee066298d", + "apps/parsar-daemon/internal/agent/mcode/steering_test.go": "34fea5dc67cef3510611dda755f3c2a6a766772f8285d343d7166aa9905f92f0", + "apps/parsar-daemon/internal/agent/mcode/tool_observations.go": "1c2ee6dd1f1d3d371e05823615862be1a440f1a4dd4ac8c03b6ec37ce842b7bb", + "apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go": "cb125e34796dacb70009d34f306e71b598902c780001df4fbeda57257538b85e", + "apps/parsar-daemon/internal/agent/mcode/version.go": "5475bcf776ca8d259fd65d714ba41b0d74b0ffc047d52eb68d41a0f69daf2040", + "apps/parsar-daemon/internal/agent/mcode/version_test.go": "b2751d697abe1d40cd4c89446c50c1a06458aa5f810d6d7352b7f81a29ab7ac3", + "apps/parsar-daemon/internal/agent/mcode/workspace.go": "0e442e0d5b84a61be1a86a78875017810b60180ebaa9ab105e652d3f713b6d60", + "apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go": "549fa9f136c294e488adc1f3841665d7722be735b4eb64199fd26cc26c81792b", + "apps/parsar-daemon/internal/agent/mcp.go": "c577fca19a945b88a4bf1a335751a71627c3065695b1e96f2dfb50945ff75b3d", + "apps/parsar-daemon/internal/agent/opencode/export_test.go": "f26572b0812fb43faa85ff7e2062e6942d9b9bcc6208776548214957832a02d1", + "apps/parsar-daemon/internal/agent/opencode/options.go": "a23eb04e75cc806d99bc57199b4865c0ea5a94e093d181025f22aa50409646c1", + "apps/parsar-daemon/internal/agent/opencode/options_test.go": "58c6e267b07ed7c12e1424d7d37b5d8723c6c17d399c793b5be710333137f6a7", + "apps/parsar-daemon/internal/agent/opencode/parser.go": "25b328e8500ef72b727841b6cef56ec69708bd3f63cb3652b61e251582d65ee1", + "apps/parsar-daemon/internal/agent/opencode/parser_test.go": "11c2ce3006b1d664c94e3615d80446be6c864cc04f279d545444163395b16241", + "apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go": "09a4e0c43289fa6eacbc3736d65c548d3467cea8f4aad7ed8160442c0ed1ddf5", + "apps/parsar-daemon/internal/agent/opencode/session.go": "f5d3c7e72293cce39382f498ecf339f827cdd244a8ba5c76f248d51d690a672a", + "apps/parsar-daemon/internal/agent/opencode/session_model_test.go": "d117c14199efa442c0b75c9c1473b10ccbace1dab9e4c9d951d5c022095b7bc8", + "apps/parsar-daemon/internal/agent/opencode/session_test.go": "66a1008fa7c6fc0189562792fe81deb23384e55b3057f9c84761344e6c246d2b", + "apps/parsar-daemon/internal/agent/opencode/skills.go": "74a5f507e2804aaade2813cd4aeb8f1e6b16f4f203cdb5f0ab0990120c247c54", + "apps/parsar-daemon/internal/agent/opencode/skills_test.go": "26ca46932bb832ba99d5380ac17e30e9634bcbabd8d666cca065e0da49ab30de", + "apps/parsar-daemon/internal/agent/opencode/version.go": "d53be63877b5db8841dce5070222a5be184bd5c0581242ce787bedbaef8e4aa2", + "apps/parsar-daemon/internal/agent/opencode/version_test.go": "bf16235a75ef02b9cc587f51f055be6e2c6113639ce6810f5f5f1c47e450c572", + "apps/parsar-daemon/internal/agent/pi/export_test.go": "087ca5ddc9f9f6b47cf9d7384b855b90cbeb407fa6bfc15df2f3a2fa1aabbfff", + "apps/parsar-daemon/internal/agent/pi/options.go": "524ae616cbe01530b412c6fee981bc2117c59859a53eb7e55bed1b219dbc7f06", + "apps/parsar-daemon/internal/agent/pi/options_test.go": "ebceeb4c16951613508a7b6b292d67a19b3e42d66bd7b14a969284c04550a66e", + "apps/parsar-daemon/internal/agent/pi/parser.go": "7d178f1ca7013043eafbc20740bc18892461ab931a4aa264ec11e758cd5044d7", + "apps/parsar-daemon/internal/agent/pi/parser_test.go": "d6c5ea905366e1dc2df1b7e160757397bce5c5853ed3eb86a88e773eaab63d12", + "apps/parsar-daemon/internal/agent/pi/provider_config.go": "b2a394b371ad8799860336ddf552a7d67997f0695387eb1e092ab7ac65d7bfec", + "apps/parsar-daemon/internal/agent/pi/provider_config_test.go": "b53f96c66e8719f72fa8820a32cb264812b7e1b51674a30622dde28b98dee22f", + "apps/parsar-daemon/internal/agent/pi/session.go": "99b72b78918c941364b4e1fca2fbacfe40e669ca58003cd6ea2534eb89329bae", + "apps/parsar-daemon/internal/agent/pi/session_provider_test.go": "7f6a9cec600213f2e73c9531f4f96c132ef39583922697a2fba6f93507989259", + "apps/parsar-daemon/internal/agent/pi/session_skills_test.go": "f40787abf14eff9f50e9e2ab4741f7d61580791f9fc9ac38b487bf892efbca83", + "apps/parsar-daemon/internal/agent/pi/session_test.go": "e0e766c54d2aa9534659925a64a71ab93a59927b54a326f90cf8a1721da90814", + "apps/parsar-daemon/internal/agent/pi/skills.go": "b569ba874b49eb1ac6164e58471b516e9420e1f5032352e5db8cacf61478abf1", + "apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go": "a85125ce967caa63ee6236b60035e2376d530886b571a675a44a288fc4454051", + "apps/parsar-daemon/internal/agent/pi/skills_install.go": "f5a7604d933874419f74210a4bfdfb215961b7ee854c2a3b4316f5a2c1b5d5aa", + "apps/parsar-daemon/internal/agent/pi/skills_test.go": "6be2acf5630fa16c61237cc6f88f8a97d7bed4e4ac31ffa21e6f5bd012c16e35", + "apps/parsar-daemon/internal/agent/pi/version.go": "36b5a101837a69d44fa230533e839a9d7191ac29f94f2d1bf573b79cbd50194e", + "apps/parsar-daemon/internal/agent/pi/version_test.go": "b2551625babfea3567b3b96afd9c7254343647ed47baf1021cc8847e57d20b26", + "apps/parsar-daemon/internal/agent/preparation.go": "edb56c6320d02d6df9dbe91041fc39f1425298f5c59df7eb8d4529d193a8d2cd", + "apps/parsar-daemon/internal/agent/registry.go": "ebd7cca855a0ef65d441d5e3227171fa38ca2fc01b085a9594d502a06f6ba480", + "apps/parsar-daemon/internal/agent/registry_test.go": "ab7a7b43502a4c92bcd810a07f16860b0b5b9887ffc115dee4d81a46a846c94e", + "apps/parsar-daemon/internal/agent/runtime_paths.go": "819667d6a6cdbb10c52ce40112f5a38ff237bbdf7010a6b6901e24f1c76904ad", + "apps/parsar-daemon/internal/agent/runtime_paths_test.go": "0ef3cc1862beb3eeb110fc669a19785c81a1076b27fb259be8e3c6ce4628971f", + "apps/parsar-daemon/internal/agent/steering.go": "a5499b6853a9eda6f2873cb87dac5225f7875481d69c333a847db6c38e049478", + "apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go": "6ca79604b8994848e16902da45a6b6239a49c8a4fd1f63f2eb08ec30e0a31335", + "apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go": "4ce70cbd8c5aa1bf3fe4ca7cb6a74335a30c9430d54e8a75e09dc9db888b7d57", + "apps/parsar-daemon/internal/agent/workspace_directory.go": "ffc56d6f2a50653e911e79eefd711e77491ce826415037cc1ec3783d0e4aa2a1", + "apps/parsar-daemon/internal/agent/workspace_read.go": "60b8744b11035463d854122fcc50c333c7be1301cc5a311b4f250077796f28c3", + "apps/parsar-daemon/internal/agent/workspace_write.go": "a4f7b0b5bd685293272067a87fe8c7cc05b75c4a27bf4c27e0cf5839ec9d8825", + "apps/parsar-daemon/internal/auth/store.go": "9643d6f02bbe5551fcd77d8975f6acd0a95e70c174e1c7344c7b563db82844f6", + "apps/parsar-daemon/internal/auth/store_test.go": "be56cd3bbdf4bc2775b2c55c097ee1238f64be4be039bc9d7cd2aed6e4207bbc", + "apps/parsar-daemon/internal/authoring/bridge.go": "d068d1b68a416f44f27dfa1e5c142a80d2be32fd3d4e5791642d0a0d43faf35a", + "apps/parsar-daemon/internal/authoring/bridge_test.go": "1bdc2163444a33e5a605ef2250288b00f5caed5a901275393ec626a8b6d8c0bd", + "apps/parsar-daemon/internal/cli/agent_discovery.go": "d3b763cf3da80d18a4ea0c790c3f4595cd0267b91d35a226e209c50a9208b35c", + "apps/parsar-daemon/internal/cli/agent_registration.go": "dce1021a7cf310dfba0b27e15e84c51bef3aeb166f8e35194ae762cc4cc4ee9a", + "apps/parsar-daemon/internal/cli/authoring.go": "15b6ecdbf456fb062e819d0fa796ad87ed49761e165b859d2277515451bfd1e0", + "apps/parsar-daemon/internal/cli/authoring_test.go": "918c2d0962d968dfdbe4ebb711dbaf50561af12c40f21819050504591a3b88c7", + "apps/parsar-daemon/internal/cli/capability_downloads.go": "62b809e1b2ce49b010059df61740d78202813ff7e2f5d620b425f687975d9479", + "apps/parsar-daemon/internal/cli/capability_downloads_test.go": "93cfcdf35d74118383d2ac0b4ef0221a45e875b3df6d0dbbbfce2d724d786264", + "apps/parsar-daemon/internal/cli/claude_sdk.go": "bcb267c48d4086ddbb281b5777353c9311f008ce8d246862973bf91453a3ae72", + "apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go": "961e607886641e5977217890220fe488f0f1189ec0f250b06679781e56bbff23", + "apps/parsar-daemon/internal/cli/claude_sdk_test.go": "871cb054fa33eb086ab27ac26ab2fb967e1eef9dcd1091b67e9b068133624bb4", + "apps/parsar-daemon/internal/cli/companion_path_test.go": "6caef90007ba98fb45e1b11ad8fee0fafb5c9414cc6a35b9a71eb193be37f121", + "apps/parsar-daemon/internal/cli/connect.go": "6a669333359b1fe8080f1b0eb3aff637801c6a97982171bbd2ba4d0bed01eabe", + "apps/parsar-daemon/internal/cli/connect_test.go": "063ae1e3a002bcd474c65bf4a434faef95e3f455c8391e17307d9523a4dcf4d8", + "apps/parsar-daemon/internal/cli/logout.go": "5918e7eb00c5b8c9e982d6019b47650c323c5dd46584f38c15dc3ec0d7c021cb", + "apps/parsar-daemon/internal/cli/logs.go": "de3b52a6f7fa5c71262f4ec5282fb4598fc3521cf886a8d7e055be9d97456333", + "apps/parsar-daemon/internal/cli/mcode.go": "384502574f35a5f0c9446fff5322c782dc684ba5f9ee64c3bef40008b5482671", + "apps/parsar-daemon/internal/cli/mcode_execution_test.go": "3b0f9f0be400464374ccdb23012c8567adfaecd9009d1917fb7e98dafde8c921", + "apps/parsar-daemon/internal/cli/mcode_workspace.go": "88524cd47e67f5805a912052b2e0187b0daca74591b485d5095c02a96745d32a", + "apps/parsar-daemon/internal/cli/mcp_test.go": "fa228ab581a2ed5d37e34d4263fce2810d5eb1880f09eeb897bd7ccb434a11a5", + "apps/parsar-daemon/internal/cli/pair.go": "bc3401bb9127b4dd2e8a1d3550074bf9e7dfbdd4ade591a4fa7dc4b2435bee1f", + "apps/parsar-daemon/internal/cli/pair_test.go": "42e3215d3bcfe941bfa2fb10146c87e65eb1912a84eea25cb2f17380138eb495", + "apps/parsar-daemon/internal/cli/placement.go": "ef4eb65af29adf30567305308b5f3b2f783f50eecca2228c634822e438731001", + "apps/parsar-daemon/internal/cli/preparation_test.go": "c387847457e8b87b8fb36e83c1698c74fc198aeacd1bc2f71fb5429d66665ae6", + "apps/parsar-daemon/internal/cli/root.go": "7660c18ef9478ed4e019465f1980224e949f41c1d464d2eb03fd53bdd7523387", + "apps/parsar-daemon/internal/cli/root_test.go": "51a4d00ccd3d74b2621bd428b0ace07d5f75ee5d14b64b2b28e8cc8867c5c196", + "apps/parsar-daemon/internal/cli/skill_upload.go": "405fb76e040e59c0e62c1b69bd11eaff9cc13a6950e4a99bc43254a7f30147b7", + "apps/parsar-daemon/internal/cli/skill_upload_test.go": "09cd28154033892ec6b2b4e4ff058d70c9c719c6247b935dd2442e1a2e4ae780", + "apps/parsar-daemon/internal/cli/status.go": "e7a23259d96da6410205644f9ae3219dfe473c5abd0590e51245991f5390e33b", + "apps/parsar-daemon/internal/cli/stop.go": "1056ee18dc0d1672087e43de0e0b4f15124e1b75c5594260f7b2834dad51327c", + "apps/parsar-daemon/internal/daemonize/fork.go": "1ee472a2b1564bda20c79b0fe6032877f0e58abe4b0a4686d2caa6b785bd72e3", + "apps/parsar-daemon/internal/daemonize/fork_test.go": "0cc79e1a73c3086c32792ee63284ae010abf3cce10c65d4131e4948112e7d448", + "apps/parsar-daemon/internal/daemonize/helpers_test.go": "cbcb6a754f0544c4594f980ea65e527178d558128d2f83a55ff353fe7c5d7d64", + "apps/parsar-daemon/internal/daemonize/logfile.go": "e00970c465929bc8c97a054ee05670ac3b00cee0f952bad20cab0071bb74be31", + "apps/parsar-daemon/internal/daemonize/logfile_test.go": "07485eb1a72c8c63912aed285a991d5bd25383d35c98e850e3fb8e7bec4bf262", + "apps/parsar-daemon/internal/daemonize/pidfile.go": "b5d64870e2b0de8606a931cd24b0fc3823cda74602de576c23c6c749fa46547b", + "apps/parsar-daemon/internal/daemonize/pidfile_test.go": "95c08e61c41f7e2ef870d7fe984a113a4ec569263736ecbd15d44eee33fc0da5", + "apps/parsar-daemon/internal/dispatch/cancellation.go": "0fa216f024cb2061704b3423bfd31435470375151dcd59fc744e72dd6791971f", + "apps/parsar-daemon/internal/dispatch/cancellation_test.go": "f6e222428c98d395aa6d7c4a42b29c2ece61c75a51497d7d6b7674caff4cf505", + "apps/parsar-daemon/internal/dispatch/capabilities.go": "c289b1fe04b64337c6431328d1f160814fd4cadb3422eaed73e1b8223eb9f013", + "apps/parsar-daemon/internal/dispatch/environment.go": "49e4de577b9a294053e5b4db9fb9513e92400dc619b01c81d1c3e6c49c9875a6", + "apps/parsar-daemon/internal/dispatch/environment_test.go": "f9a40dbedbb357e014bcc870521425b22522e748229175877c83d8a2d4b2fd70", + "apps/parsar-daemon/internal/dispatch/export_test.go": "b23124e6b3fb30273452dcb5831e17d35a3d536bc752f9bea77b0a349cd22282", + "apps/parsar-daemon/internal/dispatch/functions.go": "2d44a3bd3f9190bf131fd2dedd3bb77af8698403e28cee2c2f2cf84495ce2ab6", + "apps/parsar-daemon/internal/dispatch/functions_native_test.go": "eb47db4e935b79ffbbeca1e05535ad57affb5f759e7ebf4fc1e9359100e662ec", + "apps/parsar-daemon/internal/dispatch/functions_test.go": "1214950dedeaf15870f68809feddbfdc4c6e5c2b1a4b1b410076be3845f1cf56", + "apps/parsar-daemon/internal/dispatch/interaction_decisions.go": "8ca91d6a434d22d80c2087359e61d6119f3d5397012902064aa491d505319f7f", + "apps/parsar-daemon/internal/dispatch/local_directory.go": "cada3a0771cf63f991bd83ec1fb576fd0bb7fa73b7657bb5917ce1cd6d645c0a", + "apps/parsar-daemon/internal/dispatch/local_directory_test.go": "7bc10ec34dba29e22bb9abbe25e0f0b942952739cb3bda240c217d75a257e9af", + "apps/parsar-daemon/internal/dispatch/mcp_http.go": "ce99c09c523faba68f753a523ac58c5a931c80505f6547306a99bd35141db794", + "apps/parsar-daemon/internal/dispatch/mcp_http_test.go": "b921480225e6dc6de8f04ac6fbe9a72c96142291fc5d062b8a73af8d0754a309", + "apps/parsar-daemon/internal/dispatch/optional_interactions_test.go": "404299289d24ae5f5111789c7d0d8df9317b370282a1538d0d62e3c84880c60b", + "apps/parsar-daemon/internal/dispatch/output.go": "0e5ebece033c84c64ae96f9babd928597c00979d4bda580dde6cbd90768e829e", + "apps/parsar-daemon/internal/dispatch/preparation.go": "c7235ea02bacae393d87a9d34eb8231a8214008e1050f54501556382bbdaa1f5", + "apps/parsar-daemon/internal/dispatch/preparation_cancel.go": "95ac97bf96fcf34d48580901f7cd74a2ce2ad03541b65960f95ab9a389d321c8", + "apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go": "5b1b04becad972ece908e209581245a641970ac916e2166213bdec9c3d5006ff", + "apps/parsar-daemon/internal/dispatch/preparation_cleanup.go": "534bbece5ee8c2bee58fd42c79fc3853abd0b0804035b567f994b648914857e6", + "apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go": "565a146a6f9a36dffde9d155174c80e9d2f83a3d78d78fa4c8e512f3a57f9e58", + "apps/parsar-daemon/internal/dispatch/preparation_start.go": "742ef410f9e934296f04c7882ea3b155375a74bb1b44a82d7978da4e68d5a318", + "apps/parsar-daemon/internal/dispatch/preparation_test.go": "455e29d1422a379a57eb503a73eaf3221ca96654897f897838e5976cfbfda839", + "apps/parsar-daemon/internal/dispatch/prepared_handoff.go": "f47e95760bfee7ff70284502891c0080076d03bc326320d17cf1e6d1c565fbae", + "apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go": "24713f4277454b416149fe70c3d902d0d02037ea458b9ee574e0edac3627ad08", + "apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go": "7efd0dd4081fcd319ee16d5f8f1f48f7092b920994fa5cfc64717dbbd21e3622", + "apps/parsar-daemon/internal/dispatch/prompt.go": "2710c95f6d26311cf205960323227c5c52d936c31e7cd1cd5896568915a1bd5f", + "apps/parsar-daemon/internal/dispatch/receipt_order_test.go": "27a53cbbd5b949cf554f3be4a3700afe70635cb5f66be2b4f28ca4aa6b248a49", + "apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go": "648daa69aee9148a8efe29bc48994a4eac58669891431b53723a65eccc441805", + "apps/parsar-daemon/internal/dispatch/router.go": "58681b04fe43b63466669f80162331b91b89617183e5d8eba2d75f3ecf7e34a8", + "apps/parsar-daemon/internal/dispatch/router_test.go": "b857d2aee21f1c16cc071929fe8edf401db6b3998bf145b39ed466b28b90c95d", + "apps/parsar-daemon/internal/dispatch/shutdown.go": "87385f283e950a21ba0b21643e3d88d6d48dc764b4f53bc591154a404c900985", + "apps/parsar-daemon/internal/dispatch/steering.go": "d335ecc603d028cb6401c5599632c86c1b3a269550451b95df9343eddc7fe111", + "apps/parsar-daemon/internal/dispatch/steering_lifetime.go": "453ef65cbb897d7430332f5ee4bf6032e5f751a8fa77d1d35cf455f73196fd4b", + "apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go": "fdd7bd4b1e52f4a20e2b9ec1b0b93ba30533f52a47e11c876b9016af3a1147cd", + "apps/parsar-daemon/internal/dispatch/steering_test.go": "38f55810a5cf5477fcdf6ef1b323d61af2a9020219a51edb61d9e7c4df4a179b", + "apps/parsar-daemon/internal/dispatch/workspace_directory_test.go": "ff6facbd137c042882d7806644dcf603a71c1e7ddc9dc52c004b208455b38469", + "apps/parsar-daemon/internal/dispatch/workspace_export.go": "76dac2fcaa69e3ad016302a8af84e3a0705c2e3801ee4d0bf73a1601c8761c6d", + "apps/parsar-daemon/internal/dispatch/workspace_export_test.go": "31216d7b324c953f604c6c426417549b963728dee98553e0d4f1cc2e69569890", + "apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go": "81dc3fe4555be3e31bbf2bb392ad998e1ff985a3df65fbe9bf7675c137cd0a80", + "apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go": "eea7cb85822c7c342a5a60f83f541c2bf750b68ec377310e89f1de00c48f2e61", + "apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go": "8ebb8e871fa0c494d873ec3c04ad40f7504fff9eff8c3f38a91d0715a415d1df", + "apps/parsar-daemon/internal/dispatch/workspace_read.go": "6cbb149e9e0a134c009868e5150547c3ef5410b020c43c320588f959080ae67d", + "apps/parsar-daemon/internal/dispatch/workspace_read_test.go": "23da17c59f49e042c497a1c01cb233b4be242a5cf1e3ff8fb3dbd54a07f7e634", + "apps/parsar-daemon/internal/dispatch/workspace_write.go": "2139dc3f5c3f76ff4c596272a7987e00874be12bbe49caa51fad37dff0332468", + "apps/parsar-daemon/internal/dispatch/workspace_write_test.go": "acf068abdb60074cef31d5dd364b2f0764e464a3c219f96c540307ea052f51fb", + "apps/parsar-daemon/internal/localworkspace/binding.go": "a554c97cef62c6f99f12f4094bb4b2791abc97c2d7d88d953a52117f96b351a3", + "apps/parsar-daemon/internal/localworkspace/binding_test.go": "b2ec947485729774b021479e80f7e62b5b663e866afd19c31e3c626b4c9006af", + "apps/parsar-daemon/internal/localworkspace/directory.go": "4e6d7102b59fe78086688c4cef502085920e69ba98eb991993ae0e5697564654", + "apps/parsar-daemon/internal/localworkspace/directory_native_test.go": "5b1adcb847d84b5bfbf0bfc405d1a32af58232373f3f068f2e104f349c5f7b72", + "apps/parsar-daemon/internal/localworkspace/export.go": "b2197e82bf7354dd273383c14231e838fce9bf8774bb62f580e6c4b08100bba7", + "apps/parsar-daemon/internal/localworkspace/initialization.go": "ce21698d7ee84b7bde7b4605ca35c1963cf43fec24b298ac50d0b15a9b9b12cb", + "apps/parsar-daemon/internal/localworkspace/network_policy_test.go": "5fb358dc621f5fd766feb66baee5002104e16ca485bab00eb7f33a9611b15c08", + "apps/parsar-daemon/internal/localworkspace/skills.go": "6e798a1014ae31f17a1d32a9e594ecfd5c7bd274a975baa33385b1506b216735", + "apps/parsar-daemon/internal/localworkspace/write.go": "39d8552fdf5d888d03eb17d2bb2d2f9a09fffdfbdb428d4ba6f155f124597bba", + "apps/parsar-daemon/internal/localworkspace/write_binding.go": "c0413119a73b6a8337191b8deabfbec147c31488fc913d3885e5499729a24354", + "apps/parsar-daemon/internal/localworkspace/write_test.go": "2dcf24d020d25aa00f11bbbe908632b1ce9432a26dd7fb39dc3807fbac969ce2", + "apps/parsar-daemon/internal/paths/paths.go": "10aafaef252867ab09adf5ff72367eef66f9a148c94e16c43d9f3b6ed9b5c98b", + "apps/parsar-daemon/internal/paths/paths_test.go": "c4079d9f3025734aa609e6beb258af15fc50ed24c60dd00a56a2a14edbe411ed", + "apps/parsar-daemon/internal/transport/bootstrap.go": "22d86e28982a35148e87542879d43e3b896ad415c4e2fb82a07d979f37f9ef90", + "apps/parsar-daemon/internal/transport/bootstrap_test.go": "5aba77c5b9ffa1ff66bbb8321213c76f0cf68549071a7e917f443dfc7fc81752", + "apps/parsar-daemon/internal/transport/reconnect.go": "ba0138d423c001d483ad3e828da38afb4ed121cfc9df030debcca6fcdac15360", + "apps/parsar-daemon/internal/transport/reconnect_test.go": "70bb51184ff5768fbc2a0e25061a254c5eac98c6eb4e5f1cfd154dbc06ffb29e", + "apps/parsar-daemon/internal/transport/ws.go": "4c08b38b348dbad2d77a18c3909c192784c06589a09aadc5da6165325757a162", + "apps/parsar-daemon/internal/transport/ws_test.go": "b81d95b979e664546e295a174f47d3b6af1184af6af3411888109eb4ed81f4b3", + "apps/parsar-daemon/testdata/onboarding/main.go": "f509e768e8b131fe6ed00503d37e574cf7b875eee1b2ac757316a828bc287ba8", + "contracts/agents-api/README.md": "ef3f5061f4fb0edc1010edb413d823b8455fb6e75ee73a82a849ddc43ff84fc2", + "contracts/agents-api/environment-files.md": "a14a291bea2570b829dd457ee87d43f40c12008dbe3c723cd32051be08e0e284", + "contracts/agents-api/environment-templates.md": "8e1435a6b352f70b240d3f66401565625459ed8a5cd93011d5fb91dd03fb6c39", + "contracts/agents-api/environments.md": "b9d9ece2ad3779adf1829ad35e70dd8021efc1c364ed90c5bf38038c5e39aea1", + "contracts/agents-api/harness-onboarding.md": "4030eef0864d98187b85eee0490eaee0cd72195a6d6aa54a9c90340358a1769c", + "contracts/agents-api/harness-selection.md": "1b4946b0b4eae20bc2214da4fed2099f3a05f081ddc0beff972aae885699472f", + "contracts/agents-api/harnesses.md": "1c1a1fd5ca7dfc704997bc3c8affeb5607139e6f95a4d4cc8c6a749bb3f6bf62", + "contracts/agents-api/mcode-workspace-v1.md": "7de34ce853cbf4cccb43b3f92a778f63b7bd07d647e537aaae5bb03e1e066674", + "contracts/agents-api/model-execution.md": "43a232e57c763887db4e3aede2d15174349e2b8d8dd1b9150af192c91259f3bf", + "contracts/agents-api/openapi.yaml": "468cefdac03a5a1218ade3cacca27b217c178252ae57da3f5a4b702cd0e6e455", + "contracts/agents-api/source-files.md": "64795ccf43eb1f353cc75c0e8c67ac1d1cbbd8edc12bbb312ac0e860ea0d9c2e", + "contracts/agents-api/upstream.json": "91b8fcd0999b179812029f37a580f02ca30e93b68f2d75b8100c79257eba7304", + "contracts/agents-api/v1/agents.go": "aa5019b59f26145f2276ba0b5608d6dae6892150357269c5a596b186bf139086", + "contracts/agents-api/v1/core_extension.go": "48ca9ac91a66a9e7641c10d05494dae1c45b68e39cce13ddbc3c120e8126a4ad", + "contracts/agents-api/v1/credentials.go": "67fbd02f12b0d9d3e9c97c12022ad50641c2663c0fb58f1ff45c8c6bcc53d16c", + "contracts/agents-api/v1/environment_events.go": "40146d3aa481569e1d5f6ee74a0586401c3e9652ab6cf4589353dccffccbf405", + "contracts/agents-api/v1/environment_files.go": "3364af726de028f65441ea44f2c473720b9a4868126434a31d52f7d862050f4e", + "contracts/agents-api/v1/environment_templates.go": "fde8aa379b5e6caa98d5d189ee58839ba6b476c0e1d0f341685ddcbaa5eae487", + "contracts/agents-api/v1/environments.go": "0d767cc4f1063c23849908b1ce049c63bc658ae31ce2d7db56bdc0e4df4106f5", + "contracts/agents-api/v1/events.go": "29d53335eed4b0f3a905c52ba940ca655d66010a8556e514452c43c691e83ba0", + "contracts/agents-api/v1/function_actions.go": "cdbe6c7d64c0186a12e7d41eb074c33d34b83fc34e1a174804f26488742e2c4a", + "contracts/agents-api/v1/function_tools.go": "73619eb7730fdaef3cff1a41e319a725609fae1b261c94e425e91273320e133e", + "contracts/agents-api/v1/inputs.go": "53e47f1938e4f41d0994490731aee4fa7c4a7fc3865c9279ab3f1fb8fbe44c59", + "contracts/agents-api/v1/items.go": "00e9e70e5bcd5ee5fb916307f64ba4ead341b3b0d739b19bd783994ad07180de", + "contracts/agents-api/v1/mcp_tools.go": "86b3c09108b100e682d79111402d70bdd2907ed8f4c5a17e81d33824f8e0eee8", + "contracts/agents-api/v1/model_execution.go": "83e555c918df097886ba2db46880b2def6906d95ae024865f86214f7a49f71ea", + "contracts/agents-api/v1/model_execution_test.go": "3007a6236b7acc58aed3b92a09683077e1a97180b45de70a02183ef2cc9ec2d4", + "contracts/agents-api/v1/required_actions.go": "ac78fac74edfe62f6b2fc16a708cc5c297483256a51bf3f879bc3fd976d2cf87", + "contracts/agents-api/v1/required_actions_test.go": "24e6a2d36d6bf3242542f87fd7701e5f10976638a095d5c82ae82b6ca77ac466", + "contracts/agents-api/v1/session_artifacts.go": "b9c78fcab78c898500aed133a01f70e4afb9f9c47abab0eaea4b8bc6cbf89585", + "contracts/agents-api/v1/session_deletion.go": "eaa09239de81f060b6e4ab5c2ec95c8168de0eb16c104349dc883dd08d854efc", + "contracts/agents-api/v1/session_environment.go": "3e3d62b20c3fe4e96d8f8de9a8e1056c2a6fd48fa4d602acb5a661cde15e1a0e", + "contracts/agents-api/v1/sessions.go": "2b579e01ecd0808f40df2e28e2c55cf6a99f660daf3c0f73d365a432e19ebc7b", + "contracts/agents-api/v1/source_files.go": "f34afa177cc5e1d0972bf6d45ec241f76e1024e8a3c7a8638f7bc9038aaae4b6", + "contracts/agents-api/v1/turns.go": "49893df33da1ae9ccafbfa136eece6b645d8766ef7c23231ac90f03ae3a0376f", + "contracts/agents-api/v1/usage.go": "033da3e1ba3fbe520bc8301594a1335811aa91ba9c036db82103ebd870d63286", + "contracts/agents-api/v1/vaults.go": "a8df6744de5d5328e2e9e2ee632b1dccb3a481678cb26848d498bc55ba8f18ef", + "contracts/agents-api/workspace-placement.md": "42be22be4d74f6cfa547c611d906756426e8a1e32ee5a29ee48c630a199ec6cb", + "go.mod": "843222d4d70b15598836218d50ebd1247f36a29bf4093c01369f53f0bf45a275", + "go.sum": "08faa841454b57be66a6b032bf4fa4561e873b852ae7abcf5d1fc6e9fc4da25a", + "go.work": "e798b3a7fa207e6ff4652ce799d26485c11441b7364675198b4b6f8e2081da43", + "internal/agentdaemon/device/credential.go": "9ed555738f84e37887582c36524eddaa84db65b9af6c8e2da8d0d3960aa77e15", + "internal/agentdaemon/device/state.go": "dca2daefeba1203a61e00e444b46c0b3e8207ad66c576956a6a104f2c3f7fabe", + "internal/agentdaemon/gateway/auth.go": "fd9bf38cdc1e5ce21931d7a50cfb490f6f630ff57f9edf2c82f5e3a76bc9b375", + "internal/agentdaemon/gateway/auth_test.go": "e358e165f1249ab1b222b4c9a74fb56e8cdd4ca31093298145aad49385e24a39", + "internal/agentdaemon/gateway/functions_test.go": "cc74ef848b5ae4ba3ab5714859e062faf5dffa07f7cb248400fc9092b6bf55d9", + "internal/agentdaemon/gateway/handler.go": "cbc50cf18003124b8f44bf4866ccf918303497ad10658249bbb3dd0120ddc6f1", + "internal/agentdaemon/gateway/mcp_bearer_fixture_linux_test.go": "aefcb7852d0502595570bd134fd03794d33d0b8b063aaf33f05a1e1cb232cdb8", + "internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go": "b0b71ef4bdcb6814da2c10b99fa8d8c7c49770ae5cd1648909b765fcfe204112", + "internal/agentdaemon/gateway/mcp_bearer_process_linux_test.go": "7207be37502784d133b0e100c4a00459bc4634e4efde162951e4ac7e22b11440", + "internal/agentdaemon/gateway/mcp_test.go": "4916d4cd42378cc4203b2cf5b19dc36a7ec66704cc129a9af0b74fcfa07b3c7c", + "internal/agentdaemon/gateway/owner.go": "4aa5bd3ed89c5b10e5b8ebed15240873cb7dd77a6ab69dd9824833988d1350a0", + "internal/agentdaemon/gateway/owner_test.go": "75c5779743e8c577694403a8c96aa3ce4944480da95716d43d1eef0065bfad24", + "internal/agentdaemon/gateway/preparation.go": "70ee097b3c7645f67151fd39b4820f47553ab2af80b53d8a12b07c86eca4d59d", + "internal/agentdaemon/gateway/preparation_test.go": "cb6fdf929d6c74a4c969dd84b96649c71a9b8b6ab05918353cfd934f5af97b10", + "internal/agentdaemon/gateway/registry.go": "64b7cd9b9806a026ae0e98de1741698aa1bdc859d890fc0b2823fe977b602299", + "internal/agentdaemon/gateway/registry_test.go": "73a8ec2e9d9e82adfa831cea6dc2dd57c7c8bb3f5df7c19784e18d82c9f5b45c", + "internal/agentdaemon/gateway/routes.go": "2cabf2fef9da26b8ff798125a5bd72752658499474ecc6c2b116c7ea038cbe21", + "internal/agentdaemon/gateway/session.go": "099f34cc1cdf2569c8a4bd403cfb83a4e8f242936b981ea9a475d072f4f7d996", + "internal/agentdaemon/gateway/session_test.go": "8b1b65d3929b3bfd4c50bffad5b8a1d2e24bc99b5590f12cd97cc267c912198a", + "internal/agentdaemon/gateway/subscription.go": "7bb110aa071094e9ff3573a193fc13c01c76be9c12af0e43de70787834905a3b", + "internal/agentdaemon/gateway/subscription_test.go": "0f2a02fd9fdb5cb348bcd5550e4fa563113c0a96f504d1882851b6dd20929c29", + "internal/agentdaemon/gateway/workspace_directory_test.go": "52eb2a28641f92ac1a779e056d08e34b404f6f5d4a5496594edf4a1cc599a62a", + "internal/agentdaemon/gateway/workspace_export.go": "423dfcc0a1b6acfd1885eb0ca7191f0c7b766d0811f42a6210e7cb5ab8b87e43", + "internal/agentdaemon/gateway/workspace_export_test.go": "c676ac6479a02c8df6a1ef3bd120493af23499491909d372a06d02212f682be7", + "internal/agentdaemon/gateway/workspace_read.go": "9b1e7ce17bedcd5b5153415b8437d2d21265733f8df3d3548d1a2e6c1da3a554", + "internal/agentdaemon/gateway/workspace_read_test.go": "9ec3640d4df235d24b06760d3a49599680d5da642b73414770425462386d4d05", + "internal/agentdaemon/gateway/workspace_write.go": "4a5e0c313fb0664034a7bb39baf296f06fbc6c612643c8afbcc87b7e21f769fb", + "internal/agentdaemon/gateway/workspace_write_test.go": "b834524aac1d17381386abbdc25b79cd817f72afb267123940ab5477c704a720", + "internal/agentdaemon/placement/controller_linux.go": "0b38e2c31514d3502981f1024cd5b2959cb3e40deac2a28c41ffd5ed94c3f647", + "internal/agentdaemon/placement/controller_linux_test.go": "40a1c93e62fc34eb61cc9dad6e488fb933b9157f73d7b2c7446ebfdfcfc3f5fd", + "internal/agentdaemon/placement/controller_other.go": "59423534188390def224902742271f8d778ebe3aa69a8ac82086072748365eea", + "internal/agentdaemon/placement/docker_linux.go": "f696bc6afff02077429d210a4a0fd09bec3857fa40cbf7726206fd981ade947c", + "internal/agentdaemon/placement/environment.go": "be0f2b8dbd9a853120b7d2b7c6878c4bedee1dfa89cb079d3b7e23e126fb5550", + "internal/agentdaemon/placement/environment_linux_test.go": "6a9a07791c5fa19430917a226d17c099cdadf3fc685afb92803cc16c99b63615", + "internal/agentdaemon/placement/mounts_linux.go": "cf718742ba25ff9d28549edf83aa26ea36681a57437713e732bd44117424a819", + "internal/agentdaemon/placement/mounts_linux_test.go": "4670a48ec466c701812c7dc72880b292ca9bf53ea04b075b88dcdf6266ed8fb5", + "internal/agentdaemon/placement/observe_linux.go": "7883ca12910bb6b3a8e965d4e8fe30b2e618ba5a236ab5a4dde68fe115340cf1", + "internal/agentdaemon/placement/state_linux.go": "3567b9f88cb32dcb4fe7415409f891b4e30cb4418ac80b631f5779b5acad7b80", + "internal/agentdaemon/placement/types.go": "92addb1579a153bbb980080b817025d71f1ae19f2e109b7cf770ae0c9338b9eb", + "internal/agentdaemon/proto/authoring.go": "8de34e9f4a7bdf0eb05eac081caba3ea7eefdccf76eeeeace58d0f59851d8668", + "internal/agentdaemon/proto/command_output.go": "361ad80c3634c20f1525f27e1714715db7d14da2230698d3fb4af64a4ab733d6", + "internal/agentdaemon/proto/envelope.go": "bee6d1c9ad90686d03d79b87f1eafa2ea66859d7a09a9e34f035f80517aa27d9", + "internal/agentdaemon/proto/envelope_test.go": "b6a5f29157b32c0b17e1bb5fc8e03edf186f316c5d9fa92923664936ecfaf794", + "internal/agentdaemon/proto/environment.go": "e7c6ac8c5c0e2f2d6c9115f171db86a9be6a6833e807493d733e3240323ecb87", + "internal/agentdaemon/proto/functions.go": "6a375e734f63f88049dcc877aaec8051c35052ddde9b518415a97153f74e4871", + "internal/agentdaemon/proto/functions_test.go": "199e003d4836db49e717012007bb8da52e49fbc6b4d95fdebeda769125e94a7c", + "internal/agentdaemon/proto/inbound.go": "6546d89ac6afaa0ef9915350b07bb850b0628e2cb0738e83372d89bad053393d", + "internal/agentdaemon/proto/mcp.go": "b288fe66ff902c1d957f8394a4ffed74be8ddec3f3083d8b28f988a1c50ec06c", + "internal/agentdaemon/proto/mcp_test.go": "76dfe3f83f878eb1ef97ec975653cddb37fdfde7185c088ab63400bc37f05117", + "internal/agentdaemon/proto/outbound.go": "ba0347407136d2667eb2bd70b1ad9bfdbf9829d7386bed5f58e196f7e790d18a", + "internal/agentdaemon/proto/preparation.go": "06356bfe3894beb7f22916884a33cef808eb558830f64cdaac8136bfc614d707", + "internal/agentdaemon/proto/steering.go": "ee321c9878d35d18a2df4e70390d2c3e9c6a7f428dc930cba68491b409773212", + "internal/agentdaemon/proto/subagents.go": "89caced5e7b48fbc8c19c71f4b5f24694392b45f90cfd76cffc390ccd29219d4", + "internal/agentdaemon/proto/token_usage.go": "34bb7ec0b8feae065f8e886ab5bdacc29df9237704b8b9f500fa77459b73e30c", + "internal/agentdaemon/proto/token_usage_test.go": "7e6da302af6953247ca44cb6b28f962520f5479c636f44b68578bf285d418fd7", + "internal/agentdaemon/proto/tool_observations.go": "4de14673a4f68bda80cf95e74d9319d0bd6a7a2ce3bab26c98f4fa7e1f8934c0", + "internal/agentdaemon/proto/version.go": "8af45f9d8cee906487a57647dc24d3b8358c205bf5d62fc32b0536024da26750", + "internal/agentdaemon/proto/workspace_directory.go": "8939c5d86ac718a71cdd87b0b2ffdd57b3a3387cad6af45f3e1211521d47ff54", + "internal/agentdaemon/proto/workspace_export.go": "c2a4f2fe228d641e1a51cbf8c2916e04382ae8287aafaa2d3fb6af90917a8a50", + "internal/agentdaemon/proto/workspace_read.go": "7e541ee6fa8c6dfd32a506552b540c8805a340a7ebccf2dbc61bce334e922f7e", + "internal/agentdaemon/proto/workspace_read_preparation.go": "ed99a8fb25a692be6df712af49997967d2a1e885b813f21496b2b404b0ba18a0", + "internal/agentdaemon/proto/workspace_write.go": "e22d75afdcf2e5b0234c9bf5aef1fd1e4bf1a58fa16fd5d7c048e0f39fea8b3a", + "internal/agentskill/bundle.go": "9da0ea7cd7b1bb22b33c2eecd0b8683e3534b671d4829118130eb67fa034e294", + "internal/agentskill/bundle_test.go": "8ea6a3ccbe4328a0b43bdf31b3e7f4a264cea703c15d1caaf248ee49bf693199", + "internal/obs/log/api.go": "bd3309df30357681860aa7807eeb7c84f606f1d376e38916c5d6deb5e359c12d", + "internal/obs/log/api_test.go": "0816731c4da1e32f835422529aa34bb775372fd2a211bcaf60184bd7f5a02072", + "internal/obs/log/background.go": "9f0d2a0ffec5f19334947ec1f67074851c7365b515112fea1873881b435eb4e1", + "internal/obs/log/carrier.go": "32a3420becd65238fcd01ea5766c55ed222f635f85da0854faa52df37d20fa6a", + "internal/obs/log/carrier_test.go": "83aa90da768a7f48cd7be5e1455664add09f79fd5c8eeaefbd56361175ee6149", + "internal/obs/log/context.go": "1bc395ec5daadc67d433341e14e4db6d24481c65bccdad664955bc8b7512e71c", + "internal/obs/log/discard.go": "ece9cfc56d7a9edf9210f559205284937666574a154c1cd92d07882947bdd4e0", + "internal/obs/log/handler.go": "dcda4cdcadc1920f816961f5a4fa6b527b3ff1152c4b639e5e045d2f6cec9323", + "internal/obs/log/handler_test.go": "75346466d0c05a3e8e98243f38066b9eb05d967f47aefac6682daed32478fa1a", + "internal/obs/log/http.go": "a2fcd9712a7761a5c0f7a6647d366aa8c4e44650e14b76eab6eeff9e963ea58b", + "internal/obs/log/http_test.go": "86eb8cc79e0067432d1be9c81449fbd62ff2f71b670d21313817f7e4ca8bca07", + "internal/obs/log/init.go": "d1424250abf27661d86283ef2d649fec67359a79d6850cc4303fb3ed708dbd8c", + "internal/obs/log/trace.go": "9de780021a86ef24a5bb9a04e5ecccad38b5f97484bbee2302d68541f77d298c", + "internal/runtimecrypto/cmd/emit-fixture/main.go": "3c830afbdadb12d647e3bc0ea4cabbb09e52de4414986cadf625ef46d8e9355a", + "internal/runtimecrypto/runtime_seal.go": "e54cb29f65a9e4f3642a97de06d856b73bc09ee286a93032c69a38a558d42d11", + "internal/runtimecrypto/runtime_seal_test.go": "08d44c5848c0cee89b41b41f813866321ea05beadf81950804a139c6993696f3", + "internal/runtimecrypto/runtime_seal_wire_test.go": "d764c59191d86d03a049c3588fbf6609148c55de0cccc76a5b50adad93deb310", + "internal/runtimecrypto/testdata/wire_v1.json": "cc67df0d258d7c7134441460d6b12b512ad03690477b157b077879a2f26f0702", + "packages/agents-client/README.md": "76567ce62df606be5c2ac98e97db00215aa98274a3f5d192f465b2ffa46f49c2", + "packages/agents-client/v1/client.go": "694caaa35a62db9ccf987d86e3eac1ae3b27e0ef767d155ff15ea56e92e8c3a2", + "packages/agents-client/v1/client_test.go": "b7e2d9a2d95dbf647ba58044115fd21587340e78955f7457498b007a927df823", + "packages/agents-client/v1/service_test.go": "9f98e94bcdf74190373ed00afceb4dfbbfa02353506b0c73de68410a0347d858", + "packages/claude-sdk-adapter/package.json": "f699d4383ce960708f7a9377d3dc49ad028c7eb5510f44901767cd57cb42510f", + "packages/claude-sdk-adapter/src/adapter.ts": "31590c5c6c0d031d43f3b53ebda9836971ead12977d7ef64ac12c373434965f4", + "packages/claude-sdk-adapter/src/command_observer.ts": "474ef0e90c84da72703ccec947d7ec37186a010b0b6f6834ce84658b820d6771", + "packages/claude-sdk-adapter/src/function_bridge.ts": "2e1eadcb6bfd95d4ce7a36c4551db6b9fe2125e05f8399e016730a914d2ca12c", + "packages/claude-sdk-adapter/src/functions.ts": "7f45b48cf702c6d0018b4197a99d4d8a54fb4f4f898c6adfa8bf11fed3c585ef", + "packages/claude-sdk-adapter/src/inputs.ts": "a3613e967a89a55606afb5c4e8f7152919cd6105ae2e09c1e170256becc69edf", + "packages/claude-sdk-adapter/src/main.ts": "119df91b4af73a5b6a7e662a691f886bbd75e6050c9ae98e8b9330543f0b1b34", + "packages/claude-sdk-adapter/src/mcp.ts": "baf1ee42787e95f65bfc65e4ddd525b5a02e925c728f7c903cf7b1687b31fafb", + "packages/claude-sdk-adapter/src/mcp_observer.ts": "2a4b297b5f0a8ffa491dbbcaafa8824e4067d1fedb3dc6625cf2bf6187dda29a", + "packages/claude-sdk-adapter/src/messages.ts": "c67906d67ec357f6d34e9b43a1136b9978da364a124154fc502e4f9831e6c528", + "packages/claude-sdk-adapter/src/native.ts": "81e203da63299b84c75d493a451dedc726389b0939a9de65052680c6894f8290", + "packages/claude-sdk-adapter/src/recovery.ts": "0e4ed5ed8077366c3da837baec8c7d43e970fc17836248624234ab9f7defa814", + "packages/claude-sdk-adapter/src/request.ts": "58de7081d6b95ed060ab652d137183f2a5cd68fbbcdf87771075eff4382d5e7a", + "packages/claude-sdk-adapter/src/runtime_check.ts": "48d82334149bf34d1ef66d98f4e9c3747b6b67f7c7e8eabdf418eac17e61660e", + "packages/claude-sdk-adapter/src/usage.ts": "bd595007d9dbfebdea2151526b67c2e6e5b49d25541e0e2669365e2e486897da", + "packages/claude-sdk-adapter/src/workspace.ts": "fd5be464627ed13f3d73b126a0da09578ecfffb50891035587d55f934ce02585", + "packages/claude-sdk-adapter/src/workspace_directories.ts": "ee968ba6bbb2600f89bf4c879046a1f8af7b7a676ea8691ab9e7d747546905c1", + "packages/claude-sdk-adapter/src/workspace_reads.ts": "57cbbdd7de1d84691876c6a43796f2485a72086e96be3920ac59f7847a1ba908", + "packages/claude-sdk-adapter/src/workspace_skills.ts": "522e1ab23775a29457a1a3d561ecd96cc512813ef840a53b2c1214438becf2e5", + "packages/claude-sdk-adapter/tests/command_observer.test.mjs": "7aacf6139092a73af015e475bcebd4a73057f83b2cd010c634b318d5f52f8046", + "packages/claude-sdk-adapter/tests/execution.test.mjs": "d499ac40417dc46091d178f0db7863f81ba35bb979c379bc243497cfacf9edd5", + "packages/claude-sdk-adapter/tests/function_bridge.test.mjs": "eced48c50ef826681798a980d308506b5c44a531bfed29c607aed14e65f1f877", + "packages/claude-sdk-adapter/tests/functions.test.mjs": "d6fa33f6a8c884b01da65e16a57856cb3710e2858723495193f3c5f0d8d48fa5", + "packages/claude-sdk-adapter/tests/inputs.test.mjs": "72f1985ef51e84adade355941ba64dcf9dca6c3fc883dd3b561a11462336a3e3", + "packages/claude-sdk-adapter/tests/mcp.test.mjs": "830784010c49454fd28b066799d52dcfedbd98d6952980784cd064063bd9d984", + "packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs": "d2367207e250e29b8ad670321997f924f89a3eddea8eb17a941f91575b4c0498", + "packages/claude-sdk-adapter/tests/mcp_required.test.mjs": "ffac00cb5081496390f1faa34ab228ea316d45b8c8d8ec7798b1861fde7a818d", + "packages/claude-sdk-adapter/tests/messages.test.mjs": "d099b8e41553c74a73ae8592086a6d4c706918e55b5aa120b1e51c1a3545a6ba", + "packages/claude-sdk-adapter/tests/native.test.mjs": "579a89efe967d31b7e244e8522131acf330cc4cfc6872eaa2be24009a7a47f06", + "packages/claude-sdk-adapter/tests/preparation.test.mjs": "3ec7ef45dd441bb53a31c527922da16c5752e2bd54091ccaaae1f4ea625f674c", + "packages/claude-sdk-adapter/tests/recovery.test.mjs": "90d11786a771725c76bde950c8398e015b475401b080ad25b91cbd1207a46056", + "packages/claude-sdk-adapter/tests/usage.test.mjs": "9d92cf877b0bcbb1a2066d8b13d5ea34eea6fe749e4e168bbc6bc49428072e0f", + "packages/claude-sdk-adapter/tests/workspace.test.mjs": "e615b96cc30a1801bc23b5123567730a5e5e9463114d0b055e9e156b8e792193", + "packages/claude-sdk-adapter/tests/workspace_directories.test.mjs": "f1f8edf037026c8aad2442766f338274e6efabf4d2317ee830b510c7ab518e8a", + "packages/claude-sdk-adapter/tests/workspace_execution.test.mjs": "d684980f8af7e0b472f43988920dd490148e73aa85f33be590c8b5b8f8f49570", + "packages/claude-sdk-adapter/tests/workspace_reads.test.mjs": "7fb76c5b06b02d21d1a8dccdf5d7c96132cb32d1358808c0515375a68a6a621f", + "packages/claude-sdk-adapter/tsconfig.json": "e380c6712a2b1b64f1cb23458a71be0190cb6fc3ba73b44b835c5255e173ba94", + "packages/codex-executor/Cargo.lock": "2561944c74857ea1205175e906ee5ebf0865566b77583d64d74441e55c26a876", + "packages/codex-executor/Cargo.toml": "793f0243909f533326606e61eeb47727afa78cba5b300a8e960d8faac1a93d82", + "packages/codex-executor/README.md": "8fa9c85fa13b97f657b3f736e74888abe7d6a151ca9b98fad78feddea37df21d", + "packages/codex-executor/rust-toolchain.toml": "9e87a5aa3fa20f8853df37a25635f051850fca560b71ab9e2be4aa09857808fb", + "packages/codex-executor/src/bin/directory.rs": "4dd8298ff6b9b91527508c7512babb39b98fecbc40b752d85bf2864d5848ab55", + "packages/codex-executor/src/bin/export.rs": "6953ac76772bf6ec5fef8be74f8362a46eef8667d089383a2f9b9f9ebd55790a", + "packages/codex-executor/src/bin/write.rs": "b3604c54fd82e9dd388899367fb38110fa472ea6c4f7e2b6119ef7bbaa4c89b6", + "packages/codex-executor/src/directory.rs": "b12956ffc27458376ceed41f94da0caf55479fd1b92bb577b5a4f5ac823f1582", + "packages/codex-executor/src/directory_tests.rs": "d1f2d8ef2980d9bb6e6ac8b25a385be77dafcb692c1eedadfa9b32892c35de4b", + "packages/codex-executor/src/export.rs": "4539c257207c46f88f66463ccf7237d6e30031204016582a89df645c4a791b6a", + "packages/codex-executor/src/export_tests.rs": "8afca2591029d86e3ac48259d99454d769bf1b55e9c1129620c20bc74ae52401", + "packages/codex-executor/src/main.rs": "b62d1610ca958be7709073643b55840215372ebfc6b44a97a2d9b24098f985bc", + "packages/codex-executor/src/options.rs": "943ec928acc4c66ac20cb855d0061ffaa2fb5f5934a80d087986dd1ba9297b2f", + "packages/codex-executor/src/options_tests.rs": "32c5815a3f3fa5dd8c3f846a52b3400395647f596c66292c06e126dc482e051c", + "packages/codex-executor/src/runtime.rs": "2bdc9b1af0fb442d4aea37c1db9f7701c72bcd78a5c5a667f5424ca5d550c894", + "packages/codex-executor/src/workspace_path.rs": "32115a6826e9f14c45d4f4272134e6e7b3580f380e5d99635755a7c7842003df", + "packages/codex-executor/src/write_file.rs": "13a75a35467d4cfa0804eb5e70a79f36c95006f67a95c69ecd4656eaf1d8d778", + "packages/codex-executor/src/write_file_tests.rs": "db45c81bdfbcec2edff4d047a383607214f9919165b2ae7a5f06893788d800c2", + "packages/codex-harness/README.md": "53a6ae582a457b12cc869adb2c06be1fd9eb17b05f17ed388495db95985c800b", + "packages/codex-harness/patches/artifact-target.patch": "18606942555a060f4b626fd1ab5c0e7e6324f118dd13a65f28f546d03c622f1a", + "packages/codex-harness/patches/bounded-read.patch": "5a6a49ac0b9b9398b772bc27c6256eb11af64487427bc57715d7800178c2e5dc", + "packages/codex-harness/patches/manager-exposure.patch": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc", + "packages/codex-harness/prepare.py": "2031f326878eec5e69c5c87a5078ad250ef04d3227bed1190d51e63e39211abc", + "packages/codex-harness/prepare_test.py": "d9f4d88ebc4aae06a07339dbbdea70c4136aca839462416af7a57267d95bb27b", + "packages/codex-harness/source.json": "ce29c91923e52ccbaf312d55d2e5b5fd62f2d5d8e8ce47a8a318e9e3292dbd9f", + "packages/codex-harness/src/files.rs": "96ddf65fb772eaca3eaa5c6fea1caa39ce142317259bfb35e0730caaf3eca754", + "packages/codex-harness/src/files_directory.rs": "f0502c41a9a97ac02747b006085fd56c1a1df2453182bce8c7e4bde3cf66d46a", + "packages/codex-harness/src/files_directory_output.rs": "da5336b40f34b637b3792b24eb1c05a3b5a5f433c4d89520d5d708302dd0219d", + "packages/codex-harness/src/files_directory_output_tests.rs": "4652b1bd8b96dded2eb0608a1c054313119cf89a60250876c3620ad9c45d8d44", + "packages/codex-harness/src/files_directory_tests.rs": "973005f8a32bc92f13a2155f90641b89e3f211e36c76f9aba615395ab97348a3", + "packages/codex-harness/src/files_process_output.rs": "6ea2760bf4471924faa86fbea3fba04099c46a92d65ff6bdec3d48558be1c201", + "packages/codex-harness/src/files_read_tests.rs": "c1700d2cee67803a7d25b1b3cbe4f4fc9efc473755c0a0601eb9c2e286fbca08", + "packages/codex-harness/src/files_tests.rs": "bf8bafb2d3a7cfde07b0a3570c14cb4cbd74ff1474b0582c5f0efbdce003ab19", + "packages/codex-harness/src/files_write.rs": "b9364374f00a0122d474e6531071a64ac69d7f520c1b205e614554e6fea97459", + "packages/codex-harness/src/files_write_process_tests.rs": "5c003305fe0798995cbb0e639952092183de0a0fffa4c41fe41edc12c0c3b504", + "packages/codex-harness/src/files_write_tests.rs": "8c806b3a61a08267f3fc8294a5e450e4ef97c132f65be3e29a41344dd1db9337", + "packages/codex-harness/src/main.rs": "07beba04766b20698dd3825e93ff59c88ec7829c91a85557673970c2a00cbed4", + "packages/codex-harness/src/options.rs": "2d333f2616d126932e8553b398d141646b978c67cc95d4b8e108cb3d410581d4", + "packages/codex-harness/src/options_write.rs": "417bf1bd1ff49b411937277ba7470d1aae9e6931eeb3517ae1065bdc8283be4e", + "packages/codex-harness/src/owner.rs": "0672bcd0c8fe6c29da6105a87e37be5321b29221e8e42d3dd1cf9d959f8ea4c4", + "packages/codex-harness/src/read_profile.rs": "9ed794518b95c4cf2989ff9fc308ebf2ae04215017802dd3d30df361c2d0b091", + "packages/mcode-harness/README.md": "de1201b3464a9153225b792c9c1d395ff6a2878c9287bfe8bbcfb9dda40fb771", + "packages/mcode-harness/bridge.mjs": "84beb876b7d919001f1f4fdbd874ddc92f432fcc29e60d72cf78d3b5e7c7260c", + "packages/mcode-harness/build-sandbox.mjs": "ffdb7b97c61fe99e23ae4919fe059963e30b322c8db81cc1adb54907df693667", + "packages/mcode-harness/build.mjs": "d1087f55fec35b1070b51fda7af7fe366b1431a46becab4d7abe05192ee53508", + "packages/mcode-harness/check.mjs": "35bb2bcefa0c0e86119e1983fe25b46d7ec5140cd58119a65051968641eeeed7", + "packages/mcode-harness/launch.mjs": "ac09aa9362d9414c5a030fc90900526676b48b474f3f1905d408ba68b3792645", + "packages/mcode-harness/native-pi-tools.ts": "bd1ae8bcc13e50c7be10464f1d7676b4de8e698826d8eac75fce51ad7a1fca44", + "packages/mcode-harness/native.test.mjs": "c0eb0c7ca03006112e8d5a7795706b190cf1fe2cf22406372c5d8dd1ca8c838d", + "packages/mcode-harness/package-lock.json": "3c95204a8093aa40cd979104aca71725e96bb2871aedc6f6aa5d591b4de25344", + "packages/mcode-harness/package.json": "61ea160546c6535b60842417a0c70908937c3048ea4e534abddfc71e697f8ddd", + "packages/mcode-harness/sandbox-entry.ts": "55534a6ecbccdc8d1c6b295d9b0155eea3ab564d27a339425a1f48e7747580ee", + "packages/mcode-harness/source.json": "9d27dfe3e8c2c2d138770790ec00e55b0eb6536640c3118431cc98c982f23cc5", + "packages/mcode-harness/tool-executor.mjs": "8c18c63210396f93ffcec6aaa0b6249791993f4b018c67131b22b0eb98decabc", + "packages/mcode-harness/tool-executor.test.mjs": "1685af2f3b933aa1087cdd44d2f749b985df8e91cf615171c2184ffd7eec1c5d", + "packages/mcode-harness/worker.ts": "f321e802d4f5def1cc78702e7b38165ac038ef07e98276ecc0c0a2e334fe4d38", + "packages/tsconfig/base.json": "47bc2b459dc8bc8b3f1ef2a4063a58635b240dc3b238aa5a6e23c50f12906679", + "pnpm-lock.yaml": "1c5d4be88ba1bb16026c8d94ba2651ac03de64f92878141aab48ef83a3642e8c", + "scripts/build-agents-api-image.sh": "b42216ac6ebc59bd76d0d361da9641ae38d9fc185ffba3344f115987c528ec47", + "scripts/build-agents-api-release.sh": "50178e892135dd3bd195e0012ba852cd8a07113c3c7b0ab55ce6f16ba60d9a19", + "scripts/build-agents-api.sh": "bcf43f1de3c2455858a2d03046ee32440375bd10e7475c2d694e6597b47e05a1", + "scripts/build-agents-executor.sh": "c2de322e5ab8db82811e44dc5d8a88fe3deea5bddd20922dc03be0f40d152546", + "scripts/build-agents-harness.sh": "9d56a8bc84c0caf1032baa5c71b64bfe6c2145d6618250ca7d1c5b6901945ec7", + "scripts/build-agents-runtime.sh": "611973bbb8b191fcfb2f876ffac59e476ae5a4dab42f32a0db8ee9a3a0b06c6f", + "scripts/build-claude-runtime.sh": "fa54e5e2b2faf2ca96381b98ba0a6b5b762c50d7c2029a90b51dabae5cb932c8", + "scripts/build-claude-sdk-runtime.sh": "1af711ac51d042bcdc164baabc3aef11c9d5783f19ed0ebf84f9c34fb8bcdb18", + "scripts/build-mcode-harness.sh": "b6c7be07467de5590cfe8c1a5bc5b8a1f389e47485c7201c7462d916b88ca486", + "scripts/build-mcode-runtime.sh": "7ec993fa5331477097a01d522f3eb87363c84c4090c93ba937f82a916e47a097", + "scripts/check-agents-executor.sh": "230d2fdef226bb196b85949a0ed621d7a6b00311f2b8458b386ea69b4e0d5180", + "scripts/check-agents-harness.sh": "d4ea236e4227b4abc08ffd5dc36e66a1c71780bb096e623ce348e374eeef09b0", + "scripts/check-claude-sdk-runtime.mjs": "863a7f3ab04ac36495cdad89ccf480b8559ce0ce218b37b0706653a0f75c95df", + "services/agents-api/CONTAINER.md": "1de4d9b5e9e118e3766ec240d52cae29081eab6f308818f3ee54d7969672aed0", + "services/agents-api/Dockerfile": "023c0c920a1f7070e4b12678469f2d590788e96253bd180a6273deb5f284d45c", + "services/agents-api/HOSTED-RELEASE.md": "bcbd38c30a47d5edd89c1cc77ad626f9d1f9d67f379e6dea422227ff4561cd18", + "services/agents-api/README.md": "5f5815fa3dcacb73a0cc841afe346383be2e98f85f31a0d3b24639c142d61c85", + "services/agents-api/RELEASE.md": "4bee7a0fec404dbc0260ed12c20db872bb1e69c53dab95148b7d840eb522d66c", + "services/agents-api/cmd/device/main.go": "8ed7956b0330455d546f682ecb59341926468ab7026b1da0bb6dc45a997c1e5f", + "services/agents-api/cmd/environment-key/main.go": "57fddf10b7f40714a24805d1cb0d9f862114c38b7c46d2006efad5e0c6813903", + "services/agents-api/cmd/environment-key/main_test.go": "b596e67a6ad535ac1b75bda0e9a1c09f5708d6f84e56a547ab83a3362adbe024", + "services/agents-api/cmd/migrate/main.go": "8a313071b1e5d093f92dd3c771942933cbd848199772ff3203184eb08120c151", + "services/agents-api/cmd/server/credential_cipher.go": "f1dd68adef18ddf6f60b7487029bfffe7e22267142c34ab3cec08a71eb2abf15", + "services/agents-api/cmd/server/credential_cipher_test.go": "a2ff7a03e98d3735581de60e535b3e1fe2c5899ff2afd6dd9d58914fcd01ca8c", + "services/agents-api/cmd/server/environment_connection_test.go": "03a924011b4367880fc20e70446040221f50249c0f867c599dce9bc686f43fa7", + "services/agents-api/cmd/server/execution_options.go": "cf70c0ec3e476470c2d5d3174710fc295185e5e7271b74a2de29162b14a8fd90", + "services/agents-api/cmd/server/execution_options_test.go": "5b69f8c16d59a569b08d59743e8f5d2c26b0d45870d24b283b10e9bff9e79458", + "services/agents-api/cmd/server/executor.go": "8b7efdc104afc163531479f0f6ba135c27bdf78c4ae64cb42b36244b8a322db6", + "services/agents-api/cmd/server/executor_test.go": "af0122527674c641a45fe1ffa32cfdb9ddcd0d6de05eb4eafb0b10cd86ffa63d", + "services/agents-api/cmd/server/main.go": "aeb466a1dd780d8eb4cee96b9c5373995773ecc7b6e52bd181301344f7702ddf", + "services/agents-api/cmd/server/managed_runtimes.go": "187afae94d8c355f2289cf6d325c6d9755f7d7ae7f829ae68f462b6880445e64", + "services/agents-api/cmd/server/managed_runtimes_test.go": "581860480cef8ed307dbbe2c369f2cebddb32ee0fa3b788b128d1de1d5488247", + "services/agents-api/credentials.md": "35c42962289b393e8fe0649c19c2e42ef8d8e51ce3b1dee788e5c0299a02d1c6", + "services/agents-api/deploy/claude/Dockerfile": "b41ae6a768c25cf44530cdfd9f52755c2a4440ace5031c2414748cb0d0feba3d", + "services/agents-api/deploy/claude/README.md": "3b904f6bb720df09e90dcfa7542315c5510a776b94bedc89b275c0cacb54729a", + "services/agents-api/deploy/codex/Dockerfile": "d7512c312d71a06a969187e6297beaf2fe70404d22bed6999f9b3f2422c3a170", + "services/agents-api/deploy/codex/README.md": "d8771adf1ef29284ba75474333dcaedadf3170fb68151f4db8bc4309220785ed", + "services/agents-api/deploy/codex/requirements.toml": "25b1cd3a21ea22abb10dac8f07a502c6c6091bc907c888ac47c1665fc8300cb6", + "services/agents-api/deploy/codex/seccomp.LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "services/agents-api/deploy/codex/seccomp.json": "7bbf86d803329c57ee48343e0ad5eb600fd4e924533cf4274dbeef8b884554ba", + "services/agents-api/deploy/codex/tool-env.py": "0d96eb06f63c9397b1b1984219fff3851282165947120a9448194ee8e73846c9", + "services/agents-api/deploy/e2b/README.md": "72c9610afd84c2bd0071b034d39c7267faa7d1ee2e15082ca22607282d5a6063", + "services/agents-api/deploy/e2b/build-template.py": "7f7809afb5a12db76622ca941d7af26933d0975befe97a7ad6c85579fb1e4cd8", + "services/agents-api/deploy/e2b/init.py": "4da05f08944b931fa4c878e93334053c9ed8635d2bf769439a17f82d5c187e87", + "services/agents-api/deploy/e2b/requirements.txt": "6254e2e3170556b8155633983635cad630d781e79c3f0468bf582bd1694ef1e0", + "services/agents-api/deploy/mcode/Dockerfile": "1c9124f2c42ca41439373eec1fb416d0ffd6f7f7a7806f287ad5a33c6ef7f16f", + "services/agents-api/deploy/mcode/README.md": "48a77fccc9052ea87520866442bfc9b9839d902a07d864022a5d594db9023682", + "services/agents-api/deploy/runtime/build-system-seed.py": "c3ccaac47f711a5eca73f364be66d9e8f9dca09d90195704d1eef70edb6ec828", + "services/agents-api/deploy/runtime/initialize.py": "796529475765f349be1ffdce22cbf3ff1d313dd29c0756f1f306a3d2773ea1d9", + "services/agents-api/deploy/runtime/initialize_test.py": "076268e55b68119feb8180486c6a90ee1440155009f3c548b8eeded99649e043", + "services/agents-api/deploy/runtime/tool-root.py": "57d53ed91b562017e21b29a88a57d9523191525c04c44ff7f877ffaccc8f9cc7", + "services/agents-api/internal/api/agents.go": "5b92ae68f16c8008d106a7c868fe8a0e877cbdb8f754b990a89a435e36a45701", + "services/agents-api/internal/api/agents_delete.go": "ad6ce0db1d242dfacde623647278f2de78326f2e45966f3dae2d5babd070c608", + "services/agents-api/internal/api/agents_list.go": "a51f2a4386f9dbec2d566ead591adacd17206e4d05a71cd123244861cf18f830", + "services/agents-api/internal/api/agents_update.go": "bfe80e3f0e0c0f3a9f61167e1316be6b16bae55fb0edb35a62b9d80d34ed19d5", + "services/agents-api/internal/api/auth.go": "b93524eb185ecf07ec25d5eb6ea2f2ceded53e015dfeb81b7d1f064e43311c4c", + "services/agents-api/internal/api/auth_test.go": "3d6e322bf2a94d0ba9196e8cea5a81949d86eb3367e18432f633c9f093c8eb91", + "services/agents-api/internal/api/claude_admission_test.go": "4f9aa4c77ebf4d3c19b33d25cb1b9b36d1a6c78cf132413bc0a423cd475528b7", + "services/agents-api/internal/api/claude_mcp_test.go": "0342c879f755b7f42cdb584fc32922541d9c68a7b08085523aa33e2c2af747e4", + "services/agents-api/internal/api/configuration.go": "c12900314a5640aa8009e8db6e3d2e9680c60e0f92b4e5fe5ca88c417d4f93bf", + "services/agents-api/internal/api/credentials.go": "cd5345b9d2106af6fa26c8d5b21b8f7fe426eedf515f40edc69e6b684f807db6", + "services/agents-api/internal/api/credentials_delete.go": "c15c19c0cf60f35e3a9afcc449e007f64353e5fc4621a2f287db5f33e7c16fa0", + "services/agents-api/internal/api/credentials_delete_test.go": "b2e0155ea6f4ec1318e0659527be878fc0769a2b8f2ea389fd597c3c9b57b966", + "services/agents-api/internal/api/credentials_list.go": "c082b327a440d1c897362cf58ebd63d160c366661b6bb9bc7e4f25fdc65d4d2d", + "services/agents-api/internal/api/credentials_list_test.go": "7d91346664c3d65fe8a32ea3e5242148ffc4a58f61d61505fe61c0727b02f287", + "services/agents-api/internal/api/credentials_test.go": "29c9bbae7c152f32a7708506e053c814383523d203b210ac641f8bf4ece910ec", + "services/agents-api/internal/api/credentials_update.go": "9b9a27a1aac75454a20eb438da5f34a1565fb5243b0431b70a7e49e9737c2ecd", + "services/agents-api/internal/api/credentials_update_test.go": "2143fab6bc14ad681f085ad51a8efcc6cfcd6f3d26515e7a72505ab389d6b2cf", + "services/agents-api/internal/api/environment_creation_test.go": "e55c6c96d00773c9bd14af6f573644aa5fbf7b8d62a5b5f1bf0d70cb27b4323b", + "services/agents-api/internal/api/environment_files.go": "e6dede38245ae3205e6874e52c21e5b719f405a61f9aeb7e3e2d044ced48de81", + "services/agents-api/internal/api/environment_files_completeness_test.go": "bf631b0c9eb1010ad5bb6d5ba8752152423741ca74af49fdb7035714bd34c9cc", + "services/agents-api/internal/api/environment_files_create.go": "183b96a3dbce0604ad5fa0c9daf7a0a44a143705e4e7d7dfcb64bf926eaadfd8", + "services/agents-api/internal/api/environment_files_create_test.go": "20d6c9c2af4511969c92b8054b6667b6a1fe94745a48ed4178bc29ed43dbfffb", + "services/agents-api/internal/api/environment_files_cursor.go": "87bd88f03f6b076a0c5b177dd91380c3cb50275b16625c6848578cbdf13ec88f", + "services/agents-api/internal/api/environment_files_deadline_test.go": "5d1261a5394a5eb10e8c22c66eac9e1934e94dc4767ca51fc001644105497dad", + "services/agents-api/internal/api/environment_files_query.go": "d52dd7af7c48a59a62c6d691ce061c86d62cc4c583637c765eaf7db8218bbe10", + "services/agents-api/internal/api/environment_files_test.go": "c97b84729d4e2db0b0a233bfe8ee8a86411c96e0c3861dca1d931ef355b5ee63", + "services/agents-api/internal/api/environment_input.go": "76c69aee7408a69996fe176ec4aa8d35b5b9b1f858d49519cf98419a119c8bf6", + "services/agents-api/internal/api/environment_input_test.go": "a770331dbbb0f002b3d2f644435757a58bcb3797773b6586a70f38c812f44729", + "services/agents-api/internal/api/environment_request.go": "32a14f9f2b2c3e4472b67f4142bb92579dcf4f2d8e640882ed801d937fb5a083", + "services/agents-api/internal/api/environment_setup.go": "d895d4a3de7316fb57771a90cce17242a364c99f246f0c2dcee7f49dd05857f8", + "services/agents-api/internal/api/environment_setup_test.go": "423ee58b065bb13890a44bea7cef75500000c7857eba9058a84a85aa6e6910e3", + "services/agents-api/internal/api/environment_skills.go": "24d2fb7ec44553fce1d95ac1ba6f6d97c57f832c32abbcc09a43d489d642c889", + "services/agents-api/internal/api/environment_skills_test.go": "c2598e18f67740cc2075b1621158cf515ede949608e92b73d06f48e47e9c51e9", + "services/agents-api/internal/api/environment_templates.go": "d55e6c738133558c300793209d98ade9191a7e2eab19cf0b6931a073ef868d0c", + "services/agents-api/internal/api/environment_templates_test.go": "8d7ee3001daef4b9dfc7890c995ab240b328535d003f8a6b274ea3d4ce823113", + "services/agents-api/internal/api/environments.go": "0f6cbb7473032778c9c7a3f0893a62811a2342066dcff5bdae99ebf296b446fd", + "services/agents-api/internal/api/environments_test.go": "106b9e1377cad4b64e5b8cfe56e6116f19733103b7486d3be423eb4953c37ffb", + "services/agents-api/internal/api/errors.go": "74a9015687633a9f97bb796b0015157f221a8620b050859d550d7205470ec873", + "services/agents-api/internal/api/execution_policy.go": "a1e3b0193e6d480ca1574949e297f186cbc575589b13d8c4fe292476b6170f9d", + "services/agents-api/internal/api/function_configuration.go": "17fcf528de3dd50082efbc5f8196be31148a39612bf74c46382e4fb2429632c0", + "services/agents-api/internal/api/function_configuration_test.go": "d138243b79e9ec744e94f194d353d1791c59e0e2bd8dda9c98d6656764997ee5", + "services/agents-api/internal/api/function_inputs.go": "05beb845d973a42c8d8a76e4e2b1bfc270d7a117f5568d3df5f339cf71c1f160", + "services/agents-api/internal/api/function_inputs_test.go": "8a29ae33d998ae27f8d139a6a8203ad8d70a7abc6aa84fb796313bf01121dec9", + "services/agents-api/internal/api/function_state_test.go": "4a2148a45a6605c2789697b84a69d43afac93314f96a4f87ee89842f0b752ab2", + "services/agents-api/internal/api/handler.go": "275101eb5ff9cf13d01208b33645fbb07135eb219402ae72107a2dfd0448a3e3", + "services/agents-api/internal/api/handler_test.go": "c62e7fe79912afe62e901fc6ea6a1c80b9f598ce098626ad1d35e6a22f4b8289", + "services/agents-api/internal/api/harness.go": "09ea5bd698503826f4d1ae7eed52008715e2052ff68e1c491518f1c3432efd43", + "services/agents-api/internal/api/harness_test.go": "7c37426431b6f3c3d2097badeeb15ae341505ec6aa0df48b1d5c05744fe4725b", + "services/agents-api/internal/api/hosted_environment.go": "e43fc7f01eabfebb4a6e1de69bab3c7a82aefad5d1f1b5877af8b40bad33cb00", + "services/agents-api/internal/api/hosted_environment_test.go": "344fb03ac8c256f74bbcb1eab9fd3ba2aee2d251c5fb73094ed30cd5a7e6c075", + "services/agents-api/internal/api/initial_files.go": "508a95741b8a0d1231a1d91cd32dea7f9d6e99e8e6e6f61872686df5ed661c9c", + "services/agents-api/internal/api/initial_files_test.go": "59c5430f21ea129177b14002d45dbda87c9b0c554ec048ea3d3fb3f922878875", + "services/agents-api/internal/api/inputs.go": "076ee358a220acd6f82187139909d376291073a068d93474c4d51f67720115b5", + "services/agents-api/internal/api/inputs_test.go": "46ec777b5b30111d8bd5226d6501b0a3772563ddada43e071a9c977b8c781ead", + "services/agents-api/internal/api/items.go": "eae0f884d622f7fb0ce5801d7298c093c76d8ab0a7f3c0674e1431d949b57f84", + "services/agents-api/internal/api/items_test.go": "d25fd2ee333cb36b45c7763940024b07b481468655962e680113ee5799d49525", + "services/agents-api/internal/api/json_request.go": "44945787ac6f85e1195de6d300d5688707c33ee4cf8bcea5ffc32e0594b58d12", + "services/agents-api/internal/api/mcp_configuration.go": "326c5241e1dce30e5ea64f466cc48d48f979105488bae336f1354eb4eb49b41d", + "services/agents-api/internal/api/mcp_configuration_test.go": "5b83a46013fa14225242708fd05012d94ee147ef0ed885a0a3cbbe479f83926c", + "services/agents-api/internal/api/pagination.go": "c020d83db0df2aab486186988049b4060c62639128206197f67939feb3b94af5", + "services/agents-api/internal/api/pagination_test.go": "67e347f28552f858d83ce51fef3696bec19c8838b338f1de3c8dce3330a5b24d", + "services/agents-api/internal/api/saved_configuration.go": "310c58211cc47de566a9739b2df1cbb6423c8a24e2bc43a462bd098bc02f884b", + "services/agents-api/internal/api/saved_tools.go": "c867816d38cc43f51b9a799dcfb1b32684560443c1d2fee210dc093896660b81", + "services/agents-api/internal/api/session_agent.go": "59002dfddadb8d92fe29348c67f3de1e81e22688f1d1b3ca01d9cb82e53b8072", + "services/agents-api/internal/api/session_artifacts.go": "b4b868d3bf08be51e9304b9ceb14ac6a3fce6a75bac39f121d407ad44e2157a5", + "services/agents-api/internal/api/session_artifacts_test.go": "b7399a11bd3fbcd0a2e3755044ce369ebf44554056b6a5a8b3c5256c02c8c09a", + "services/agents-api/internal/api/session_creation_identity.go": "5ed948e8329083c27a8c16218084977df8194780d12cad17cac15d29cf1b3cfa", + "services/agents-api/internal/api/session_creation_stream.go": "b98c99a27551fbd0f910516e92621f1ae9f5b8e73c6d1cb08556e738f0f2ec61", + "services/agents-api/internal/api/session_credentials.go": "9798be8e77c88fbbf85e926e0d16515cfcf200f47b4aa1c8bf7f7efb46d02ffc", + "services/agents-api/internal/api/session_credentials_test.go": "c1cb202885588b254e23da0c670c727eabe49f0e2901ff437adda5e4486170c9", + "services/agents-api/internal/api/session_deletion.go": "9f8d92edf328945e1af7dba8fb23371e4e94dd24cfaadcad0c36619117cc9f35", + "services/agents-api/internal/api/session_environment_http_test.go": "7303079074c4a8a9fc762f2fdbb89762c5ef340f34adc6b92c3f725f8c7bba5d", + "services/agents-api/internal/api/session_environment_test.go": "3d1b0589ec138b092a00a3bf5a1d633364d457a5773057fb353788b945bb7ace", + "services/agents-api/internal/api/session_initial_input.go": "c03c0bbde74511761c4cb16edd3afd20682d121405f8c2e4a91f31aea41029d1", + "services/agents-api/internal/api/session_initial_input_test.go": "131c654c5191f5439a8d0794b6066a8d7def34e7d820c97bcdaf2d9c2079c570", + "services/agents-api/internal/api/session_metadata.go": "0ed277ab051b6ef5aaa75f69e3c6e2ca777a5278978d7dffd049ac58bf6862ff", + "services/agents-api/internal/api/session_request.go": "1400f43f7548686048f911892e5c39253997a201f4cfc8c91758d78dff5e10dc", + "services/agents-api/internal/api/session_request_test.go": "3660d73298d0e30e0db7d209b1b879772ded6de73ea77886008f8007e9dc1ade", + "services/agents-api/internal/api/session_response.go": "5a4683f86c978d3ede17968637c95649816b1c55fd4dceafe4e68b3369093132", + "services/agents-api/internal/api/session_template.go": "765f306d76780621cdd3f51eff3891fe5a8d87fba7a3b4fd5832e1061fbb160d", + "services/agents-api/internal/api/session_tools.go": "583e9573f90de78f82d057e579d89c5a52d4478894bacd91d9d2e1a5d3f08b57", + "services/agents-api/internal/api/source_files.go": "2cae9b0819dc8beb98f7240880560cc8ec17c02b5638b2424fd130061c7e4fcc", + "services/agents-api/internal/api/source_files_content.go": "2dc6a35c05266d54c8711733c5f1b6469714698edfacfa3658262a1f430d33c2", + "services/agents-api/internal/api/source_files_list.go": "876a5623c24b3e7c624c0bf5fdc113df90e073a91707898e075a4ddc95baa19a", + "services/agents-api/internal/api/source_files_list_test.go": "9c88ad48d51738aebab99fc9677e5d26b98bbdad3ebf03f3f7b126ce93ed2e5b", + "services/agents-api/internal/api/source_files_test.go": "b874826d0adda2679fb8c5e51e08308ee60b195014b12b49ba4a8434ffa1b024", + "services/agents-api/internal/api/source_files_upload.go": "f05e375a42519d6516f1d4f2ffdf78054700c0f1cfbf232ac55e506c2d8ac949", + "services/agents-api/internal/api/stream.go": "78b8b13603ce7a9ef175979ae0a654daa47f55ceb8c90ad68df27ef4c4273f8f", + "services/agents-api/internal/api/stream_test.go": "ab4849b955f202e727d9d3a11b73c22014eeb64c624792854cc0120e8e6e5689", + "services/agents-api/internal/api/text_configuration.go": "4d9fc885332ba76420e1a31634ebf82a69f8548ba1ac874582ffcc98ae8d4c1c", + "services/agents-api/internal/api/text_configuration_test.go": "0221cf263a652540f66796abcd20274f714a619193d86a9b9267cf6669aebc47", + "services/agents-api/internal/api/turns.go": "aa86ce48478ace13745eda5f86f244a0dcd944398b4872b673965fdd04020029", + "services/agents-api/internal/api/turns_test.go": "953ce95d03a58f6069248fe7664296d8bf24a113e1dea6fb86bff6455a471421", + "services/agents-api/internal/api/usage.go": "cf1ae822be0bb36812aaabe543b902ed33d2bdb956b53221228f77d3a164b728", + "services/agents-api/internal/api/vault_pagination.go": "4cb21ce9afa51063967987f6280fc4c73217df9c586e5dd477f042d8285f9dd9", + "services/agents-api/internal/api/vaults.go": "c76b4842f9e3523133fb8f01a141ac6397f11887bcefd000ad64e7d36e5f445f", + "services/agents-api/internal/api/vaults_delete.go": "f26c68561bef1a206aa22d842c00d47f831836439ba1958b762d08467bad2062", + "services/agents-api/internal/api/vaults_delete_test.go": "f3df8ad536d583ad3996572f862a38799f8b1f7e3247460abac31e3f1619cfab", + "services/agents-api/internal/api/vaults_list.go": "d413bc809c2cbe506583462f1db1d3f93d53bf02588176baafd0c6d4ccd7145a", + "services/agents-api/internal/api/vaults_list_test.go": "c8a2443226ac2506df1512701a8014b0cc836ecab36f20ebcda38857b6b797f7", + "services/agents-api/internal/api/vaults_test.go": "aad51d1b4d79d1948ff43f35db5058e60bdc2877b81cda62eab1280d62e226c7", + "services/agents-api/internal/credentialcrypto/cipher.go": "2d48d7d9095e5f9f482e3352490fcaf9061e15e23907a04376faf2f23e2a6217", + "services/agents-api/internal/credentialcrypto/cipher_test.go": "4c04c7f5e872c9e6301b08676410a245c8c3da92034fdc6c2607b859b7da99ac", + "services/agents-api/internal/credentialcrypto/environment_file.go": "f8b1fed9828a267aa64aa3f049320aab86b6c5c2e30f954c0b8ea3ee4cd73033", + "services/agents-api/internal/credentialcrypto/environment_file_test.go": "8a48e7d510efc354b5d42d645ec85ee38638f3bc629c06a7a09e1a83a49de6d4", + "services/agents-api/internal/credentialcrypto/environment_setup.go": "c5bfc62bb7c79a45f96d2d2b3b075c5c398b6fc1c7e8b93de84e0cdd7b3cce48", + "services/agents-api/internal/credentialcrypto/environment_setup_test.go": "b174bf301d3c8142bca487e0e6f90cc65a3c1f499ac73fed9a19ecf8d05be57a", + "services/agents-api/internal/credentialcrypto/model_execution.go": "eb859213872d857dbe4a2b3eebefc20bb7eebc19098ad72ad60fb5954bb6822f", + "services/agents-api/internal/db/queries/agents.sql": "43b896f90c7a0cb01e1365c0122ff655d3f41e211fdc5933eb979c2e414df38e", + "services/agents-api/internal/db/queries/devices.sql": "fb3b15dc54c049e484bc1537f25963804374508a83eb15417ffee0dff93b3701", + "services/agents-api/internal/db/queries/environment_connections.sql": "dc93815e1a45ea1ed38d14c0bb64bbaeb385b19e519fbc0244ccc9e43bb02840", + "services/agents-api/internal/db/queries/environment_executor_credentials.sql": "e5f2dfefeca9306156b9ab2c004b34674bbd4bd16c82f5a724e543d7b028682b", + "services/agents-api/internal/db/queries/environment_file_writes.sql": "89179ed02ee9cc2950bea35b6852d86ee2dc86e24ebeda1760cdd94349721a10", + "services/agents-api/internal/db/queries/environment_input_activity.sql": "95899ca04519de90d41af5101abaf695c0ab443fe363d7c075bd207955979856", + "services/agents-api/internal/db/queries/environment_input_expiry.sql": "a73e0636d833c92c9dec65f574ef2e604ee672186c14c2dfc15ab3234ccad61a", + "services/agents-api/internal/db/queries/environment_inputs.sql": "ec9023f3fbc2e54eecc67e38a0efdd2e970242bd7245efdf33410819c3be49be", + "services/agents-api/internal/db/queries/environment_setup.sql": "ff5ca2fe9f5b330539630362ab6fbcdcc70d7ef3769a6fc6f315ac5493d91198", + "services/agents-api/internal/db/queries/environment_templates.sql": "f56e641bd7d5eef7356b6b2442f682a837cb193ecab644b03f72065b9a0cccfb", + "services/agents-api/internal/db/queries/environments.sql": "15f0c083a874ea86b293499316ce5f4022f0d2d346bd47c6c6c18158ed7d08a1", + "services/agents-api/internal/db/queries/functions.sql": "f7a1f8b58dea54b5e9af7feace5d93c805289b5f8612b121486fe1088b694d45", + "services/agents-api/internal/db/queries/initial_environment_files.sql": "dee74567387496b9d201d0f0c2f5c69f73bda4f9d6667813fa1591007d5168fb", + "services/agents-api/internal/db/queries/local_environment_devices.sql": "408cb633f8668cb55274113bf042c4d88870ee8610b9961210e218fc5bc70a58", + "services/agents-api/internal/db/queries/mcp_credentials.sql": "7ca1f1469ff8a6af9e9b7b8cc977e7609b0cf9ec8e0d68856374afa167e63d5a", + "services/agents-api/internal/db/queries/project_scopes.sql": "71be3fcec54ec2c0490eb85618a18bccd77b6138c3b81b84cb46eb6453cadc15", + "services/agents-api/internal/db/queries/runtime_allocations.sql": "78ea81adde8e1af0fe4abb3b67e636baded78fdd1d8978623804854f808bb0b4", + "services/agents-api/internal/db/queries/scheduling.sql": "eefb34e0452a35fa31dbb9fb758b2ed19080aab3a32e6360543049b3129bad29", + "services/agents-api/internal/db/queries/session_artifacts.sql": "c78ac3c0751ebd6d1a4a154022aea09419a561d26c491f1e46d30f728c9b55bb", + "services/agents-api/internal/db/queries/session_events.sql": "ca1b98ee2b7cc40ec7182ffd359d7f7e14e01788e5a4bd55d0b2f91deebbc9e7", + "services/agents-api/internal/db/queries/session_items.sql": "34999f34dcafebe205f8573f32738d3f0215800a1e5e89676eb7879adbfa5a1c", + "services/agents-api/internal/db/queries/session_model_execution.sql": "e81add95b12b277b94819ac9f09a68d215118d080058725d38d04a15fc2dc5c3", + "services/agents-api/internal/db/queries/sessions.sql": "2ebff618b2a9504453dc6ae8b2f9294060df6d8985f70b048892342e9d0d55e3", + "services/agents-api/internal/db/queries/source_files.sql": "e0e4286a3e23e06c869985d06134ac4db72029f5a2f031158111f3cc5731b300", + "services/agents-api/internal/db/queries/subagent_identities.sql": "0bcd0d0cf603cbb228a1e4ede60816bc6e39668a86dd9492d1ab06dd17f8999d", + "services/agents-api/internal/db/queries/token_usage.sql": "07eb4fd281783978024dddaef7f761529b7793b75f8d548ceb767cbc80743bd8", + "services/agents-api/internal/db/queries/turn_events.sql": "34be3c66558c2a10fbef661fd66bcc84ada78229667bd9d13fac4e747f107891", + "services/agents-api/internal/db/queries/turn_reads.sql": "da495fb44102cd307f08b30f122bc70f7e4e4068f69677c53cd86f3831248a99", + "services/agents-api/internal/db/queries/turns.sql": "020042445d2b6239dc520d067ff4f791e3296ebb797531b9f75d5f01b173e996", + "services/agents-api/internal/db/queries/vault_credentials.sql": "c89cee3015a6804b722745c6863b53d3a8019fa3b5293420af7e64790faac26b", + "services/agents-api/internal/db/queries/vaults.sql": "b425e6a5839aa3fe079a285899ffa2bf4ab84fc6d81fd54b2af88bd7acdf858b", + "services/agents-api/internal/db/sqlc/agents.sql.go": "ce699e5b664ab2617bcb9cca6b54fd1aaf726517220c71f80a3ca830516adff0", + "services/agents-api/internal/db/sqlc/db.go": "94ebb623500dcd4f52eec4cae2accd4eed5f35fc96f08db088611c17346fc75a", + "services/agents-api/internal/db/sqlc/devices.sql.go": "73b2241aea2443c04116eae61355c6a5d0268057ac6115f855dc11fb741ff102", + "services/agents-api/internal/db/sqlc/environment_connections.sql.go": "a8fda31c2379134efc748b336ea812980890d4d34c5050914007716a545c417a", + "services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go": "92886e4d792b0e053a277a87e631748b4be5157d9f9e3b34819dcc47e764c87f", + "services/agents-api/internal/db/sqlc/environment_file_writes.sql.go": "e44797e2211fda676c3add5aad771eb4164408c83b54c7a6f3bbfb35946ad9f2", + "services/agents-api/internal/db/sqlc/environment_input_activity.sql.go": "7445a45ae3a251094e024631a7ac2f280758c586701c9c84adbffa33290ccd28", + "services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go": "33229e897b8a6327a99952121e6b9b6fef0c6f093f8bd59ecd9268eec057c29b", + "services/agents-api/internal/db/sqlc/environment_inputs.sql.go": "909b6f5ffdd510b1602267808d6e87d404b06ae9c91a9b5aacc1e24ae51d56e6", + "services/agents-api/internal/db/sqlc/environment_setup.sql.go": "1903f2528d01c8392c57689e626891e745d055504059c0dadc3dd9f8e7a0eb59", + "services/agents-api/internal/db/sqlc/environment_templates.sql.go": "9cb34fe8e1d50b7d12126b66a04b5171b5d820afb01f7cac380b4cffb1b00ca1", + "services/agents-api/internal/db/sqlc/environments.sql.go": "08e381c0b592ab042740349f455595b288a2620c87cd98bff09d9f9edd952817", + "services/agents-api/internal/db/sqlc/functions.sql.go": "c29c557fdc04d46d8234bc96ad8e6d2c106ccec93302191a92bab60e9ec1de55", + "services/agents-api/internal/db/sqlc/initial_environment_files.sql.go": "24027f0c7958911fe31e81ff052decbe6052f18c22c44561e6203eb0de7fcf74", + "services/agents-api/internal/db/sqlc/local_environment_devices.sql.go": "05b3db8cc439e0cff063901516c8352529fbee2be6cd05bb7060e3e249a02133", + "services/agents-api/internal/db/sqlc/mcp_credentials.sql.go": "4d3818a74ddf4749c7bedc80df217bca4bd55f0a4992976922facde5074e2a6e", + "services/agents-api/internal/db/sqlc/models.go": "c0595f4a8767d4e923244b7e33c701f43f325730a1c21cb9c4d69d0e696f40bf", + "services/agents-api/internal/db/sqlc/project_scopes.sql.go": "2de28fa355e1f23aba693a63fc9ccce80ff132d03a178c010cef0945bbe9acd5", + "services/agents-api/internal/db/sqlc/runtime_allocations.sql.go": "4ae0c4f15d851bd4f63e7f8f1df59e31a3556376ac4e6881d4bac486fb8c5b44", + "services/agents-api/internal/db/sqlc/scheduling.sql.go": "d169f5a3c1e77409e539606e97df0dfe83ce71f4d60a80395bacb2ad94a1485a", + "services/agents-api/internal/db/sqlc/session_artifacts.sql.go": "99040318b7385c68e612970026f6cbd5e91967f842670207920a967805c4cc2d", + "services/agents-api/internal/db/sqlc/session_events.sql.go": "774dde336a8692d1c24e1c740d4c125210932fb80af94df184eb664b4dcc04d5", + "services/agents-api/internal/db/sqlc/session_items.sql.go": "4754ed4e8e123a601b0838c2bbde770f779607ab5bd7f152ba5165b0fe40a67c", + "services/agents-api/internal/db/sqlc/session_model_execution.sql.go": "eed84911f21dc2425218919f89dbf5b2fc1805d3b56881ffe2886257209e72ab", + "services/agents-api/internal/db/sqlc/sessions.sql.go": "b1eacd1eaa31888b42f1552f598a8a4503f5980a085e1dd801e3c2d96112b887", + "services/agents-api/internal/db/sqlc/source_files.sql.go": "ec7e25c3930ad81304bc24817d53f8b359f776cafdfcbe85e00ad9ab36bb46b5", + "services/agents-api/internal/db/sqlc/subagent_identities.sql.go": "4e7bfdb223fd0779ba58a858181de319cf32b0747dc25df7d942808fe14d1427", + "services/agents-api/internal/db/sqlc/token_usage.sql.go": "feec479ca9cad0846d8e19f148284ca66301264242c02cbc53be9685da55e055", + "services/agents-api/internal/db/sqlc/turn_events.sql.go": "838e41551352c91f14a9d9dcef5556d38bddbe26b63c5541223c78540778c854", + "services/agents-api/internal/db/sqlc/turn_reads.sql.go": "25107480ad03d6f7f226b3f9fd10b11ee595a0bfd3c7ce9b2757bb76b76b8b6c", + "services/agents-api/internal/db/sqlc/turns.sql.go": "4d6e9dc115b91e1c764a836766a83ce0a997578bc13a192b55d04067c809d576", + "services/agents-api/internal/db/sqlc/vault_credentials.sql.go": "77d4df9d5d7af68f7ca35da1f27d5c255fe1270e0353df71b6e906b0290673a3", + "services/agents-api/internal/db/sqlc/vaults.sql.go": "46458380bfae212474f85b17d04fb651c62db6eca134f7f716b38fb099fd43d5", + "services/agents-api/internal/engine/claude.go": "a41e4944932edef38be20e91baec2b872bc4fe67b359eb11602bfdf5204b9b62", + "services/agents-api/internal/engine/claude_mcp.go": "864ea8512909b0847c6b9a5ee72fb3a623175c5c16711c097d4f8dd181371e86", + "services/agents-api/internal/engine/codex.go": "b933230c2eed0fc34d611ae8b26d8b469b92bc5781b49fa4d6441ac380c2c8be", + "services/agents-api/internal/engine/mcode.go": "f1842e47f5f9452a5060947bfc46ec350751334c354d90537d4b0975de8c03d4", + "services/agents-api/internal/engine/profile.go": "7413b845495944092d1a9693607f8ad7e9c5a2559aae0eed01ec3e7acc8dc697", + "services/agents-api/internal/engine/profile_test.go": "660caaaeb7a90119d26539467aa73f436ddfecd7efee12ddb36a95a3de90b154", + "services/agents-api/internal/execution/artifacts.go": "800877470a608eb554f9c3d6f0851c7c831b149291e7f9bc9ae9e041203e834c", + "services/agents-api/internal/execution/delivery.go": "ee370595d2a2be3ac4ac35e4b057d3ad22dabbec1df93ee4489c364bd67adf7a", + "services/agents-api/internal/execution/directory_preparation.go": "f8e66e5c1b710cc9d6171c2ce98d85950204d6fd7975da77d7ae4455055ad477", + "services/agents-api/internal/execution/directory_preparation_test.go": "139d818f0a94d1281fca1834620cb6c1f1551f0636b5cc355410ca9ea1f613b9", + "services/agents-api/internal/execution/dispatcher.go": "f5a3f6d62f1a0d8a9c723dcdd504d5d221a245c65980722f541e2f982ebc4e64", + "services/agents-api/internal/execution/engine_profile.go": "798f31df914d11337393d5c0fb255949634c0fa7fad2f32a703e903fae8fb9ae", + "services/agents-api/internal/execution/engine_profile_test.go": "052def67d6a89cabebf6387005988a2f7479dce83efc5c8059a7776f41c4fb02", + "services/agents-api/internal/execution/environment.go": "a611c3261b5abcddcb07dbe7723f03fd7380bbefc93bb070fa3a1b27e4a1352e", + "services/agents-api/internal/execution/environment_admission.go": "41478c2ee59877ba2ed0f1126b1fcea8c28262fb594e7829fefab56812b5bd32", + "services/agents-api/internal/execution/environment_connections.go": "c8f1c1ce100b4042e1339088363204ba30236b60d24609821415848bc7c3f64b", + "services/agents-api/internal/execution/environment_directory.go": "61f34d658e754b7ba7ea72db8a1a048c1f702245747946909bab4ffc9287f548", + "services/agents-api/internal/execution/environment_file_write.go": "addea97d1071210c004b0d8a6cdf66610f11fb838dff8a7db5708bb2a707c84d", + "services/agents-api/internal/execution/environment_placement.go": "1259e5ffd2572af381ddca3c121f23cfb2ad79c34248fae7797a0eea8c160d01", + "services/agents-api/internal/execution/environment_placement_test.go": "c514c91a9ed44289b14fdb53881ad354eb532298e5a7f51cb2c2dfe978885a05", + "services/agents-api/internal/execution/environment_test.go": "953fc693311807aeda46191f6ec423c7e75c41399af7b25aca870f7db56a366f", + "services/agents-api/internal/execution/finish.go": "6937ecb906fc6f9955c564678c94505aa74d9b8e42da6903e6939811e5a57248", + "services/agents-api/internal/execution/functions.go": "d2d171ae34686f0ba9bb062dc6f67aea61666c7cb5c030455ddd2eebe0aa871e", + "services/agents-api/internal/execution/functions_test.go": "b52c7eebc71531b428ff0e4edbeefc288a8267ba23fdf01d0ab603b03c18b5aa", + "services/agents-api/internal/execution/input_text.go": "e2626c60f6b172e7ee9bd3769a6ba37d856c8bf354ec973caa6fc12bbb4dda33", + "services/agents-api/internal/execution/journal.go": "ed94df2807c24c46fa3c76d444e74eeaed0c5a1b079eb989421a1c67b402a194", + "services/agents-api/internal/execution/journal_test.go": "aaaefe27272774747a39592d54c8f0ca7e82189303a86a69580fd2faac4c2ffb", + "services/agents-api/internal/execution/mcode_profile_test.go": "1be2cc70e2dccaa5d468e99965da774b2d23987012d0e1d770e8a992743b2dd3", + "services/agents-api/internal/execution/mcp.go": "6fc4e451c6f0f61c52d3169e0a47524dab1faa66ed6d207759b3b769e1b89baa", + "services/agents-api/internal/execution/mcp_credentials.go": "438a34caeed81e84cde51d0c2e9a0aa18642e6d41305edca854ae41db0f62f57", + "services/agents-api/internal/execution/mcp_credentials_test.go": "47dfa40e4636d81cd215bd4f7bf552e18b5ffd83a3ab47a127813b44577009c9", + "services/agents-api/internal/execution/mcp_support.go": "1b97f8f9d04213915d6602790b32a7753df9e4ee43d874b261450d928ea4c4b3", + "services/agents-api/internal/execution/mcp_support_test.go": "c6fd993a39f9e507631ffcef37718fc368ba2505316746ce61b0cc1bc8c8bc8d", + "services/agents-api/internal/execution/mcp_test.go": "1265f4a9c4988347aaacc5c8632ee7bf47bd284ae11a354db70fdb8a9332cf9d", + "services/agents-api/internal/execution/model_execution.go": "d62463be7854d3afac750f181ed1ed7ba7b7e33b493c6964c5c346b0bf9c324a", + "services/agents-api/internal/execution/model_execution_test.go": "00bae29f8a0999ca9348cdafe7c7dcb17121b01fff7e8c8a658662d3b1ed6376", + "services/agents-api/internal/execution/policy.go": "91131c31f3d79d4dd7eb6a0317d66c58a652b6f0a2a560ce1cc434da0f6a91aa", + "services/agents-api/internal/execution/preparation.go": "803055a8b9ca7f4e53f2a681f8e3f706d69e0c6290c06653570a282dd43fc9a0", + "services/agents-api/internal/execution/prepared_dispatch.go": "6d8085ff105e5c00ee185ee7b1fe705b814c2ce56a5cd0a5275d59d54ca36f0c", + "services/agents-api/internal/execution/recovery_test.go": "5e1a40c4527529dba7b354be781cd8de1ca6102bb34b8438481af92b45459d54", + "services/agents-api/internal/execution/request.go": "31b7771faf5b28b153a7896ae015feb9932b2d4dab52b8dc183e96e993151547", + "services/agents-api/internal/execution/runtime_connections.go": "27a00cc04fddcb6321b2a8bd38343920ae1d4de3f53239fc7bca198dde2322a8", + "services/agents-api/internal/execution/runtime_initialization.go": "43a81e930428497a2cdd2e97460f297586f429a62a4b7036c6f1f1bcebe3c358", + "services/agents-api/internal/execution/runtime_initialization_real_test.go": "710109ff148d4b8147d4ffe95e88f514cee23b479f5317357f060b9d9826efcc", + "services/agents-api/internal/execution/runtime_lifecycle.go": "153d3ee2a793e2bd87f9051c6075df891cd6c52eed6e53713f489fc6f78de74d", + "services/agents-api/internal/execution/runtime_pending.go": "af56161addac7addedd5dec13e3145e28c037be54bd40baec7fa4178617a0ce2", + "services/agents-api/internal/execution/runtime_provider_selection.go": "d80b03f739dccc18598f3806bbe2031600199da5d3f23f69fe373add08b65c3a", + "services/agents-api/internal/execution/runtime_provider_selection_test.go": "3f2966f2e0e80e2fe5c355cc05bb609ae35a94c505b8192306325213b5d0caa0", + "services/agents-api/internal/execution/runtime_setup.go": "14750ea7db7a7972e9b818ff2d258c47ef0a5cef75f0bf60454134a216bcae8b", + "services/agents-api/internal/execution/support.go": "3dc2231e3f0ee4e065e8f4facc37c5f32696cf54f67087dfbcb167eff1270a10", + "services/agents-api/internal/execution/worker.go": "a0d2ae008936c409fe1091bedfb53627c49aa9d961c4514365b4b9aaac3f7c9f", + "services/agents-api/internal/execution/worker_device.go": "d6e079f32471a4aba1f1862a9fb6b0ccb54bc0a2a2af61f1d40d6f78a52b9df3", + "services/agents-api/internal/execution/worker_schedule.go": "290715115c03804b61f33a87925533684edc85bb4c8ec3625545da7a6ee73850", + "services/agents-api/internal/executor/codex/config.go": "cdb0ceb4fc6d779bbbf51b542ef1d78fb5b37a35c8aed5fbd992816596b29e47", + "services/agents-api/internal/executor/codex/credentials.go": "61b39c3aa88267094a1b72e289e5cca491087f674205a0b87d435e4c221e52fc", + "services/agents-api/internal/executor/codex/credentials_test.go": "6d60ba0cf5ef2af065f1640c5cd94bcb0e65b8b5e560e071db707253b5cbefe5", + "services/agents-api/internal/executor/codex/harness.go": "624fe6ec2d322443955b30658f7ef7dd28cb79c194998509c472bb4c2f4bf78a", + "services/agents-api/internal/executor/codex/harness_credentials.go": "85186e818b322996c775d660398de2c1501f6eacf0734a803fd3a7a7b7a34d46", + "services/agents-api/internal/executor/codex/harness_credentials_test.go": "f6eede3a00578d7ffb50d0af12f876bb07ba9993e5bce5514b3170602e9cc042", + "services/agents-api/internal/executor/codex/harness_test.go": "1487d7ed23ea550adfb1374e2d7852768e311ecb908f318ec4d7b1c0027b7678", + "services/agents-api/internal/executor/codex/lifecycle.go": "0343e126a9e7be5ac36fb8804c4b2f929345c182e5a4424ea227be10a4758111", + "services/agents-api/internal/executor/codex/lifecycle_test.go": "83383686eec008afd4da111066fb6532a11d34a1e0b91949990e72ea9a5f7208", + "services/agents-api/internal/executor/codex/messages.go": "2da0dc76e17e14b8b819456454a4efd3b752b431407022bc72eb8cf02bbc0b13", + "services/agents-api/internal/executor/codex/registry.go": "6e4217af880b3c588c96919af9ffcb30e5e392d7e01466b29dcd33288bdd618f", + "services/agents-api/internal/executor/codex/registry_test.go": "18edf8a21585f8eeef0c65f4690304708759198f342a6d6bd9b88fa922d1b385", + "services/agents-api/internal/executor/codex/socket.go": "1b46de6f106242c8f510dcdec5f6be60245451dc1d0c5c2d12c49a7e1ecf0159", + "services/agents-api/internal/identity/principal.go": "148a9cd43c761ac6b85f274ff21bb3339a6d34b863190c5934985f5673e06b4e", + "services/agents-api/internal/identity/subject.go": "70fcafc803f4f4dc9e567a994cf192b3a91578796498b7662b68a867339b9cde", + "services/agents-api/internal/items/command_output.go": "6b486bc923c283be8ef2a35d4faed891918e9b6749e4af7671e093a6fea8b2e0", + "services/agents-api/internal/items/command_output_test.go": "d76febe3c57ff418be336c35e4725a1fe2044078244383c89b0b7650f4d834b5", + "services/agents-api/internal/items/inputs.go": "bc4607fc3f1e2aa311b8369bd2fe320c98fae7a2945c5a32f70d901e163074c2", + "services/agents-api/internal/items/messages.go": "5f560a7c680033ef62eefb77c1ce8f7341dd045de71f5f671d01fd6d50e24901", + "services/agents-api/internal/items/messages_test.go": "618cd803609b7148275a8ceb2866cd0523e935ec8b3eb275e68d80320d4b1466", + "services/agents-api/internal/items/tools.go": "cf43b69e07d4ab4b49878f8102aefc1d4331b8d77af3d2b97140568a573e17c0", + "services/agents-api/internal/items/tools_test.go": "a44505cdcb05223aa5495fc94855558aaf2c42a6400b16beeea6e69fcd0a5582", + "services/agents-api/internal/runtime/gateway.go": "996baa23672065f496810079ad1991904c80fd6608565ff5ac652df0402ca0f0", + "services/agents-api/internal/sandbox/docker/bootstrap.go": "08911b80b9e05c4bd5fa275c17cd8e9e54d075c5046075ee2f2127c5932fcd66", + "services/agents-api/internal/sandbox/docker/command.go": "30e598f513cfae59c2dd6d13e5c0905b19337ca5c45f9c420c9665498f29cc02", + "services/agents-api/internal/sandbox/docker/provider.go": "1eff16518452d76facd38d5a4f4700edd90e8ac4f298f2498b6573761a36dc3b", + "services/agents-api/internal/sandbox/docker/provider_test.go": "a99064228eed615f5a426ceafde588954aa092d5586666a3c0b9042a0df989ff", + "services/agents-api/internal/sandbox/docker/recovery_test.go": "612570539134b59aac7751ad352a0588cb81eed7aba56498003409750e8f1b96", + "services/agents-api/internal/sandbox/e2b/bootstrap.go": "07513161c7a4d177df0eac7aefbede19c2a7793febda601fbd37ce41afe29b62", + "services/agents-api/internal/sandbox/e2b/command.go": "72961826953002c80095be35787c9bb2533c867648b69783d51aef429523fe06", + "services/agents-api/internal/sandbox/e2b/command_input.go": "ce1baebab027e601ac4a5bc36efa115609930ef25289531df0fc5dab78af0d6e", + "services/agents-api/internal/sandbox/e2b/command_input_test.go": "684aa2caeb5f3bb41375758aec58ba2fd093c8b9b80c2a04d8d06fce6b228ed2", + "services/agents-api/internal/sandbox/e2b/control.go": "381b63f8242a5d76b413827f80d5140b8c31fc0f05756dc3bd821276f68f5621", + "services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE": "b4ef1bf811cb4095229fb86b574199e467c78f0ac4078cf8be62189e1fbd0818", + "services/agents-api/internal/sandbox/e2b/envdprocess/README.md": "366515f17e0c3ecb6f0a8837aee5cecff1cf49f4a136a2868f91d59cfaf17c46", + "services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go": "e13af3ccd50582e3c8cec9c07efa27269a5140a6118554c3531671bfce03f106", + "services/agents-api/internal/sandbox/e2b/envdprocess/process.proto": "8edd9358c7dbfcad96796b3f0ed8d14c262b8b14d6bc7d5e84d468941511b8e0", + "services/agents-api/internal/sandbox/e2b/provider.go": "6cdb1f26e2d5b00edc1bc89993a635734e1c576de7c97cd6fd2f6cb4ebda5557", + "services/agents-api/internal/sandbox/e2b/provider_real_test.go": "b5ec18c9b05435ad966efea0609d141f84eb9a2e4cd584f21db3015d6293504e", + "services/agents-api/internal/sandbox/provider.go": "79a3078aa995f68af0d38a86032e80b2770f642c5e047a9087b68b11e8a828a8", + "services/agents-api/internal/store/agents.go": "58e36c252a5c0033d54155c7a9358c476fd8fe4e4323bbe301b4399f743ce469", + "services/agents-api/internal/store/agents_delete.go": "044f77cc4589d1cf4c6749c93806edb70c3f5648603b75dee1855c4e1a882c65", + "services/agents-api/internal/store/agents_delete_public_test.go": "e756a9b5239e3e5e6d296391623856a9d4d326982ce1d6ace5ae1ee90ed9a26e", + "services/agents-api/internal/store/agents_list.go": "c6ae4ade605affb15c79b73a84e1af2c04d244266b304298872763628c3901a3", + "services/agents-api/internal/store/agents_list_test.go": "9100b0d8387712aae8817e00b7c00db7e61feec370807a9824d2aa81a9ae6728", + "services/agents-api/internal/store/agents_test.go": "7376fcba5dab13a87ef8028947e97fa3d7f3ca6f5f0764fea3daa48b23bb8f08", + "services/agents-api/internal/store/agents_update.go": "83bbfdf4287d96650f754f0b697e9fe34843588915d921642a77600a7443c07c", + "services/agents-api/internal/store/agents_update_public_test.go": "1829cf7ca95a33a89a4a81f9b4516188c494fa0cf1b63254e62290af217c1dad", + "services/agents-api/internal/store/agents_update_test.go": "909d8046f3f3a82d374ead37c1f8586d7c231a0d2ed3a9aab9f02735a888f7b8", + "services/agents-api/internal/store/artifact_capture.go": "20b0837cda58c1a4d234a6a6ee5d87931fb39d07fce7858e4937011070cd4f4b", + "services/agents-api/internal/store/artifact_lifecycle.go": "7cffcaf7c6653f22576368b47f65682f8caf18c269247742920c884b4621d49f", + "services/agents-api/internal/store/claude_execution_test.go": "7073fd1234ee69ee7a827c7f05a293c091e5b5929e73e239e1065d096329d042", + "services/agents-api/internal/store/claude_mcp_test.go": "ea264481e669db541a2eda836c44246706a2b84a34e7fecf64fcaecfe2d3e3d1", + "services/agents-api/internal/store/command_output_test.go": "83e153ffc2a6a1bfcac53dd9514d0f9d575f5146773ad877cec3be679386af9d", + "services/agents-api/internal/store/credential_status_migration_test.go": "7c2430fe4b8fcab8645122f688b164864059c2b7d265d1a76055cec58c198dd1", + "services/agents-api/internal/store/devices.go": "7e8a1dffb8c7df5c880b1db0023ceddd1d2bdc858fba9a867dac968ffa83b501", + "services/agents-api/internal/store/devices_test.go": "355c18153a8587b728082d232a2cc17e31baa903b4aa31f6f186819be978d621", + "services/agents-api/internal/store/dispatch_test.go": "b1a2969bd1bfe1ec84c9d7d367690995ea6224563af8e6a395a673e15f25b27c", + "services/agents-api/internal/store/environment_admission_test.go": "0288525becf8b7fd13eda7371b8e619279dd1885352be70ce3a2ebfa2f3b8af5", + "services/agents-api/internal/store/environment_claim_worker_test.go": "4fa4de45d1849cbd5991bc4503cb2d53e57f57237d6378fefeb216f1fbeb4bb5", + "services/agents-api/internal/store/environment_connection_events_test.go": "e64b06410d547f25dcd4909354d8ad9bc43f1f94c3541a68e009172e01f7f01e", + "services/agents-api/internal/store/environment_connection_migration_test.go": "c5ac8db09355593875a4a324b1601880292640c73da2819af044f6b9dfb9eca9", + "services/agents-api/internal/store/environment_connection_recovery.go": "8a9b962d42528adeed7166871feb317b07d5647a2bd127e547179b3f0461bc75", + "services/agents-api/internal/store/environment_connection_recovery_test.go": "aaf5dcc446b7978fa0b63c719ce868d13f35df8f59f848dde01428b2958953a3", + "services/agents-api/internal/store/environment_connection_worker_test.go": "05750ef94f5bcfdb29a9e1f990b34b0999ed1684daa1a421d6b6708e96a8e15c", + "services/agents-api/internal/store/environment_connections.go": "835ff596bc26814ed6a0ecc1dfc3d5f07d9be3f3ac60a14436d2b492d70df487", + "services/agents-api/internal/store/environment_connections_test.go": "096c764d56b6290b533ee60e288b6266b589eab64b04883d671a78a98b80c826", + "services/agents-api/internal/store/environment_device_test.go": "bcd284fa768aa7299ce119a5e82d806721b9c3f27ef81bca2c8713fce379e242", + "services/agents-api/internal/store/environment_directory_active_test.go": "a9f43eea9f6a75c0888fbc70e03292aa1c0ff6647dd89475ce2f9857f02e18be", + "services/agents-api/internal/store/environment_directory_native_test.go": "8f1d5501d4a4a451f79623fe12d70a738b0ab2c80c0528cf3cad0962d3e15824", + "services/agents-api/internal/store/environment_directory_test.go": "d9081e97bf7703d9ca81672fb9ee2de0ba815e9cc51cca06295f6c28d6143646", + "services/agents-api/internal/store/environment_executor_command_test.go": "ff956cd812fd0ce30633306aa909cdabc2569e5a0e2ec9acccec26caaeaf57da", + "services/agents-api/internal/store/environment_executor_credentials.go": "5c73d3e488bd6702612478b8cc5eab1bc56bdce9ce0b711dd8ff6d052ced55ec", + "services/agents-api/internal/store/environment_executor_credentials_test.go": "dea5e4d1697a4828b40fa5273e8f3c5a8edf2390a3b72a67f62fd60ef4ec1c61", + "services/agents-api/internal/store/environment_expiry_dispatch_test.go": "735bfa35dd7a7cf5e81a0afdb4b64c2bacde457d2d46146da15744dc7abbc65e", + "services/agents-api/internal/store/environment_expiry_worker_test.go": "ee559d9afe2c8e122256383edb108f55205cc260d34cd3623bc76efc11976abb", + "services/agents-api/internal/store/environment_file_write_migration_test.go": "c6a6168051263a05ad26ae39c26de1777894a52fba110de23acaf53b9da78f19", + "services/agents-api/internal/store/environment_file_writes.go": "83ee35002dad59e411ee8eff150ce6d9a8a95d27470b078c0afb44889657e425", + "services/agents-api/internal/store/environment_file_writes_test.go": "10266b68d00005b68c14f78a9b7104cbeaeb4ef5b5c50067614af33676b0fa72", + "services/agents-api/internal/store/environment_files_native_test.go": "1be3a8beebdcf9c3a3384faed7522298b7ac24f3e2e8eac4474590cb021a2221", + "services/agents-api/internal/store/environment_initial_input_test.go": "a10bb3f934f400cb89dad58acbce18e97a882ac11b0b0a169d4d8825a28dfcf1", + "services/agents-api/internal/store/environment_initial_migration_test.go": "541b4a52e269f48b7ed51aef75cd4edfbbcb2a9b089e4d105adeb739d12ba111", + "services/agents-api/internal/store/environment_initial_public_test.go": "ba2a354f0483e46b42f25e48ac77b6ded3dbbb9ea685ba5f616947d5b90d645d", + "services/agents-api/internal/store/environment_input_activity.go": "5b79daa5146e3887cb52796055c66a5196e4e0d2c9dac313d53b8b316ca45dd2", + "services/agents-api/internal/store/environment_input_activity_test.go": "a84e24b25bac3a72eac0cdf59a7b05dbe71da4361c3866edb73fa2fb88159c08", + "services/agents-api/internal/store/environment_input_claim_test.go": "d74f09f32969412669d7e40b560ff58fe149a5d05a2386184e8186121957e3bd", + "services/agents-api/internal/store/environment_input_expiry.go": "d888725822e7392da1661421fdf3887be72f8d78aeec5baab9253065fac43f0b", + "services/agents-api/internal/store/environment_input_expiry_test.go": "922739b912f7b5ec23371e040d54f92fb0ba828a041f9f9b03c022c5ae2d8d64", + "services/agents-api/internal/store/environment_input_migration_test.go": "091c137e58c0e775a3d8ea51170fa99b9fe21154198cac4cc902f9e0def6b0b0", + "services/agents-api/internal/store/environment_input_settlement_test.go": "aee92da5d2adca7d0cd63a9fd2fd03efa90aadae6cfee0cf3a63d11ef0d81bd2", + "services/agents-api/internal/store/environment_inputs.go": "bea504ea56b7b7ca8513ab6ed2c9ab715c378ea2cac06356136200e22e07de8c", + "services/agents-api/internal/store/environment_inputs_test.go": "cec3f2da337fbc5507b1b5cb8e6c6284ae6ed110eac2a65e484c90ed58ff39fe", + "services/agents-api/internal/store/environment_retrieve_public_test.go": "5ee92871b037ea197848fcb431e8457be2ecedc7347d6774c1441a70edec3a76", + "services/agents-api/internal/store/environment_setup.go": "65a08929dbc3d1b16127c3439dd41e01e76ed3fe6326bf15eaba776580d8c5e4", + "services/agents-api/internal/store/environment_setup_test.go": "bcc0b3065ba4f16749bd689f7baf27d8c3a41a8ac6401c347e61a64316d67525", + "services/agents-api/internal/store/environment_skills.go": "7d332137504ff515643bb1f82d337224eca95b63056e2a740bf7d338e59ed426", + "services/agents-api/internal/store/environment_skills_test.go": "2807ce716e3a25e520875cb191f2f7452a6769030ec532c89ac1bf19b88fcd88", + "services/agents-api/internal/store/environment_steering_order_test.go": "d2f7fc48e715fbbfa912bb016ef863ed9844ef2b852eea9a2e5474f48f286215", + "services/agents-api/internal/store/environment_templates.go": "924f41920f8f4844a03cea889ab68dff6d32cd769cb720133a42db2e717bc17f", + "services/agents-api/internal/store/environment_templates_test.go": "a6abf9ba7303ea41af86148b3a611ec0313158b3a2855473a0bccaba732f52f2", + "services/agents-api/internal/store/environment_work_test.go": "892b8ce4232026ccc9a013b404178b8b99a3d737b2607c96baf0b5f4334264d5", + "services/agents-api/internal/store/environment_worker_helpers_test.go": "f1f79d963d5c54f8639237da991a1e82334637367813649d52f9696f4ccaef7f", + "services/agents-api/internal/store/environment_worker_test.go": "877de3d7d2075cf72d31f274dd33a3a23185e08dde39984ba3b904fdc9806e60", + "services/agents-api/internal/store/environments.go": "84a402a04940c3fb7c615f515db13120e96685aade4dc708b99f8dc591616f24", + "services/agents-api/internal/store/environments_migration_test.go": "35bb1e1451ed1ec4e5ec621cf444fd13e640f10c8fe832cd41c26704576f3b5a", + "services/agents-api/internal/store/environments_test.go": "99a9e0f3f86f8d73d4aea51cd069d0f14ea0bf5b292345e54411198b810ae5e7", + "services/agents-api/internal/store/execution_events_test.go": "efb016abfb69fe16922a26a2eaee7d40442daad73f55e7e6de5e98710fca6539", + "services/agents-api/internal/store/execution_lease.go": "b5d0a0f7a2e292f525c95ef7ca1d9055e826ad38b688c4daf3b08d223c7c6bd4", + "services/agents-api/internal/store/execution_lease_cleanup_test.go": "2823da44f582662f1114f330dd6dc7d9d7614485a9cdeb9c7dbf83258154980b", + "services/agents-api/internal/store/execution_lease_test.go": "d6094359f8549466bbdd4672605421cdbe15aa861402f0d99193060b3ed274e2", + "services/agents-api/internal/store/execution_messages_test.go": "15a2da3767b385038d6f2a51de074fbbff836ad2bb4919781e83cc8d6b9db065", + "services/agents-api/internal/store/execution_tools_test.go": "f94cf3a42328fad863a0e5b43db355752534957564cf1290e976d219aa4c8a4a", + "services/agents-api/internal/store/executor_credential_target.go": "fac78671c682a4b3b73fb7eec6bc1b28a720a94aff1d0371c2f0dc6d851970c9", + "services/agents-api/internal/store/executor_launcher_helpers_test.go": "d9da6f1b11ee1d71d11901db8e54674a0710986c8e5331fe45324fcd1a32754c", + "services/agents-api/internal/store/executor_launcher_test.go": "4e6f892b147e44d103acb4935118eca00f9275993e87712fe46ed79eb7c9009e", + "services/agents-api/internal/store/executor_principals_migration_test.go": "0dc60d292121adab55b4389f0a7d5383d5c32dc87d592eea1c46d5490bcc5126", + "services/agents-api/internal/store/executor_principals_test.go": "d36e7235f497f41e1bf49f3d3ddbfa098d3cc4a5d829532aed981635468df1a0", + "services/agents-api/internal/store/executor_registration_public_test.go": "fa11c125053ae69022c303aa1639878edd076628a17a09cef52a0c182936a5b2", + "services/agents-api/internal/store/export_test.go": "ab37683860992cb44adbef5c64f3ffd5765b104de0af742a665632d6d40c06c4", + "services/agents-api/internal/store/function_calls.go": "e1a1d6be5add7f4166f3e551b34554251c4117172c5690445301c59bc8a1cd2c", + "services/agents-api/internal/store/function_calls_test.go": "272a63cb40fc94fe13e2ba61ebc48b8bf94aa8ebe95de3399eb2057616783c90", + "services/agents-api/internal/store/function_execution_native_test.go": "f656b41c5762b60aa18b0ba98d88f900be3601f6d37148933d9fad05f6c2efcb", + "services/agents-api/internal/store/function_execution_test.go": "5e4decde549643649281c5d22ebb224787eabfa90e4aef26e496359cbd51992f", + "services/agents-api/internal/store/function_input_execution_test.go": "d24ece315d0bb38b2b69e39a302efbd6cb390a8aa2ee74c2f3c2652535c33017", + "services/agents-api/internal/store/function_inputs.go": "c474ac4a78ee687d67e178a5c9a3b18cbdc33c01052999053b62866591b44567", + "services/agents-api/internal/store/function_inputs_public_test.go": "47aa84e005550986474aea219850b79330ad1e2328c6ea72307a89b616e0a88d", + "services/agents-api/internal/store/function_inputs_test.go": "180291b85559f3967d415cca0134956762663044bdc6262a3ca130300b5699d8", + "services/agents-api/internal/store/function_item_events_test.go": "5d33afd89a8cd59e70e8f7848f61d338da2c58bbc664f435a5870806791b07e4", + "services/agents-api/internal/store/function_model_test.go": "e3c2543092058f48e05e218cb7da849beb6c408f124ceffc95b2255b634c0deb", + "services/agents-api/internal/store/function_public_native_test.go": "cd7fb81b976aeab14b12d3267ff56ae4fc850263944f0d8c503b534a41a1819d", + "services/agents-api/internal/store/function_results.go": "3a6e936df98bd4f36436ba4ba2e30cf22afc5e357819305160e70a16e10a9cf8", + "services/agents-api/internal/store/function_state.go": "fb09bd848657b5708fcbd34f2a383740f30322c65b92c758381d45cc2481947e", + "services/agents-api/internal/store/function_state_public_test.go": "3db99359d1de8fc62c537c66c2a3bf032e319e9127397edf3e05dd9ffaf6a52a", + "services/agents-api/internal/store/function_state_test.go": "c95d9792bb209b2f151d3fc7a95d1549aaaa8b3b7b1b76cd9a9bd9cc2e1b5e83", + "services/agents-api/internal/store/function_stream_native_test.go": "eaa45d7573163fc1aa2ae89a670cf5a1308dbfbe3753bb1cfb80fe37f063c128", + "services/agents-api/internal/store/function_worker_test.go": "5ba5fc4fba4a9eee5719276dd0200516d36304d24dd00f0ae6137001442b6d37", + "services/agents-api/internal/store/harness_authorization_test.go": "5728e3a0509bcb1d460387cf232e5a87a72c1099f0cbe17e9183474da73d86fc", + "services/agents-api/internal/store/harness_onboarding_test.go": "33384f3c95456dc1d78125264a78687b5c66102e15ba48a5cf183de8a04a7feb", + "services/agents-api/internal/store/initial_files.go": "f9784a12072013ea7f0234394f932134afcf827d7fb283f7d6858d450c0a85ef", + "services/agents-api/internal/store/initial_files_http_test.go": "c8696f63a30dc0cda2e2ef42b506d7a0eaae959313d014334533aea4dfaf50b6", + "services/agents-api/internal/store/initial_files_test.go": "e65fea1fb9ecb460c785e0a1d0aea8b4abdb1ba5f6db2e7bdc2ca49e757d8ca4", + "services/agents-api/internal/store/input_batches_test.go": "75b824cc46c08efa8c640cc437175088054ca133c32168e7abd7c38dd2eb1e61", + "services/agents-api/internal/store/item_events.go": "3dd8bb40213bb0dcd3ff7a0eb41843ea0bd36140df8ed722d31958a5667f1ef1", + "services/agents-api/internal/store/item_order_migration_test.go": "055d985b5286dd1ecfc8e5ef90e93142adb79f77bfeb516b07083fdf48e5b366", + "services/agents-api/internal/store/item_order_test.go": "c87c538d71a1083590a08320d873759748cb56f28286934f86ca91ddd55b2883", + "services/agents-api/internal/store/item_projection.go": "85664119969e4f76d37b693936fcf4ee4ea7e3a48895bc76b686caea74151550", + "services/agents-api/internal/store/item_reads.go": "1690ee48e353cc71379e0a47db6aefff45902df3252a4027bee083842b23b80e", + "services/agents-api/internal/store/item_reads_test.go": "ea5dbdfc1a0fbfb5688a2e9a6dfa0b38316764012389101d384824c8fa954cf0", + "services/agents-api/internal/store/json_object.go": "54fd5992723d9962681ebdfc444763d4b93ab9fdf64db8ced5b1ecfb2b757f62", + "services/agents-api/internal/store/local_artifact_export_test.go": "0f8d0cea710215b3e479086e7d8dcabfb2e38e38fc35c912944d4c17966255dd", + "services/agents-api/internal/store/local_environment_devices.go": "4c35e1d80e5763a9bcf447d7622f5146f02759e12928e27746e298b9f82ecf43", + "services/agents-api/internal/store/local_environment_devices_test.go": "909fc0364683a7d5a95a348934ce0c8f532338a8a6b80c31ec6648495e180516", + "services/agents-api/internal/store/local_environment_file_write_test.go": "fbb47f76df8d0b33235f909d43c8fe779d36f5b8e6f859d82f983b1461252c3a", + "services/agents-api/internal/store/local_environment_worker_test.go": "275345b414b7d4a63085799f1c77335ff0097cc86c71e6a14b4f3b148d5826b4", + "services/agents-api/internal/store/mcode_public_native_test.go": "f889bc0db3cf5f4d6c6f2b809c55ebbe21e0f8c1ddeee4258b6969fe93a82780", + "services/agents-api/internal/store/mcp_credentials.go": "6f916e25af9244b73ce04c23a0a2bdecec6eabf05a0e0e179b2cca8729605346", + "services/agents-api/internal/store/mcp_credentials_test.go": "82d90698331ebc5527bc797db42b946b77c9a64600149d43e03a5db396ed6531", + "services/agents-api/internal/store/native_daemon_test.go": "0e40f42cbdffef1b0b7f102bb6d7ebc66cc3663e3e5af52b06e0855581ce152a", + "services/agents-api/internal/store/native_daemon_workspace_directory_test.go": "9fd011a4af944445f44eb9dec337b9e51db878857952bc9c8af78870d47ecadf", + "services/agents-api/internal/store/native_daemon_workspace_read_test.go": "5a264adb7d7cb482fad9053f794b7425e235b3d6de3c6b385a9095884b76201a", + "services/agents-api/internal/store/native_environment_adapter_helpers_test.go": "ac98d5ce5dd4955ff7bc832d6e99f9e419dad7461cbf0b9844ed49f946530333", + "services/agents-api/internal/store/native_environment_adapter_test.go": "76e3549c2ec3c0530188a892f42294d01cb3047a8c797aa79542f1ab2b7bb787", + "services/agents-api/internal/store/native_environment_test.go": "9056b8ae057f07f739234fd9f0cd524746b522a5f69080022eca589647a62f90", + "services/agents-api/internal/store/native_executor_directory_test.go": "48e090c9f7b78da0d7b9db6dac1880fd554c30a33e75799cba928c08a9218e53", + "services/agents-api/internal/store/native_harness_artifact_test.go": "78bddaa1b173d450ad346c7ffde6cbc467312bd6d2c92e3a3751ba62406097e5", + "services/agents-api/internal/store/native_harness_directory_test.go": "50de6473955f216d8dea831beba5e0337ee42c8d680281539c524d811bc1a06a", + "services/agents-api/internal/store/native_harness_read_test.go": "91276e51273637fba6e27b4dd677e44b7553eb76ca0be9d789b6fa48bed94fdf", + "services/agents-api/internal/store/native_harness_write_test.go": "b83c12b131266cb88c4c685b3c879e31852c5a6088c46035ee175c4090852d72", + "services/agents-api/internal/store/native_placement_test.go": "9ea7ee8404861bdf39938cd362acc3e7611bb62c8048fe27040480cb3dcb30b3", + "services/agents-api/internal/store/native_preparation_helpers_test.go": "2ce87f38e3507e148b6eaa45fcf871d47318f18c321c49b60a5cbe94a785f68f", + "services/agents-api/internal/store/native_public_execution_test.go": "fc3457ac2d991e5e7dabea0bbb266e78cff126436ccbf323f1d5c3bf183ed3b3", + "services/agents-api/internal/store/native_raw_files_cancel_test.go": "88ce4b31d9ed6b3674730b41c568d602ea1eb2aaf71fbc065060a15aeb066a24", + "services/agents-api/internal/store/native_recovery_test.go": "e314ee55c0ec870da1aba5a9e906175c26d596c2252b6c53dd16b063db3d1731", + "services/agents-api/internal/store/native_relay_test.go": "660a6c0a3232a64b77242b711da0b6e08e194d4f5dfc836239bce9917c71a494", + "services/agents-api/internal/store/native_shared_files_test.go": "736ce887ac7e719b3d62116245807da5541f428ece8418428e8fdeb1e9398dfb", + "services/agents-api/internal/store/native_workspace_preparation_test.go": "cab0d1ee4232cdeb9e65b721efb184c28f5fe45f78cc80a16d5e8e3b84d394c3", + "services/agents-api/internal/store/no_environment_test.go": "cb8cea9e1f4091c91b24046b9066a7b387fcabccb880e40940f5f97c8f01dbc9", + "services/agents-api/internal/store/prepared_dispatch_failure_test.go": "155ad6164c2dcd4e25a496bae3d09093fe32eef36aeb0e1f4ecd3e520caf0a03", + "services/agents-api/internal/store/prepared_dispatch_native_test.go": "c8fad393cc79ee67fb82886b4b90de64b70135dc9cdd37dfd7215ea884c1c3fb", + "services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go": "ccbf4096dd5d5c927439fe0c2c5751fe3138d98b49643eb1f19947a05827dc36", + "services/agents-api/internal/store/prepared_dispatch_test.go": "f29c9f1da7a18bbfe63246f06eb1f7a4baf496e8af6a70bb51ae59ac89900e57", + "services/agents-api/internal/store/project_scopes.go": "5bfd7f6bac7479020a5d0c16afcccb2f5c399ea1f89a0e8183a13f794c58b805", + "services/agents-api/internal/store/project_scopes_test.go": "796f4f175db6e98ebe83b90d854ed071fd70aa2d686dbacee8c33bd4b20a9e50", + "services/agents-api/internal/store/public_execution_test.go": "30b438ecb24af854e34e73b403e6ce7607423363d7461cd84f8d6ba6ab0701cb", + "services/agents-api/internal/store/public_harness_profile_test.go": "134dd0538725310b0c534334d405b4c81f9db5c77f1eef59ac4e31a195155b6f", + "services/agents-api/internal/store/remote_mcp_credentials_test.go": "c31685d9587138e6171885fd544a8ad24d8181cf5bf00f765ee3ce8fe02b9c9a", + "services/agents-api/internal/store/remote_mcp_test.go": "0c31cc3ce051b799463034f84f71383f62f61c21acd81dfc7f1f4946f07e4a16", + "services/agents-api/internal/store/runtime_allocation_state.go": "bc17b6698edcc75c0874078fb6942f028fb784e12ce61cf1d33c9e59f2638211", + "services/agents-api/internal/store/runtime_allocations.go": "15d4a05c3a7967dbd0e4f578ee91d2a8ad38c3d65f8320b09160bff5769679bf", + "services/agents-api/internal/store/runtime_allocations_test.go": "0660be1097516a89c2a3ab68f0e37f5697a0c150e70960d9368efbf7981b929c", + "services/agents-api/internal/store/runtime_connection_test.go": "acd796e3c048f79212f6cd516a7e2c2d3ce7ce7a0b967d1655da8f6e65391d1a", + "services/agents-api/internal/store/runtime_environment_terminal.go": "bce74bbe117d962242c7f7a86ac70f7e121f18cf7f5338ccb8f894736daf9df2", + "services/agents-api/internal/store/runtime_environment_terminal_test.go": "030f61dd0d26a78d8105b9c2ddd6a7149929e1483ee7f9db146180199b43298f", + "services/agents-api/internal/store/runtime_initialization.go": "c5db894a77545bd3ed4cbfe4badddf6aba15714654431d8d1cb06da7f7915396", + "services/agents-api/internal/store/runtime_initialization_test.go": "90c5e15dbc718e586cf24701fae05467b3de0db470abc795db5eb10881c5c73a", + "services/agents-api/internal/store/runtime_input_admission_test.go": "bd5106a092f9bc57e69c3ae0eb608b3bfacfee632c00d0cb7e2db08be2c4e958", + "services/agents-api/internal/store/runtime_lifecycle_test.go": "709ccbb32441d5cbb8561de6ccebf68c06ecce4e7339c4f4ee448a94071dd44c", + "services/agents-api/internal/store/runtime_pending_test.go": "8714f33baaef592fae016e2a67cfcc61146a0cd34896ad5b7e1d570cf4f8aa42", + "services/agents-api/internal/store/scheduling.go": "4e4fbd44e03464cccbf9740e2adfeb6b4da67079f495d45a054d420140e7a4b1", + "services/agents-api/internal/store/self_hosted_cancel_public_test.go": "c57ca55b46b47a2e533314639aa60ea36fa7609e4158f89da40a144d41bd4706", + "services/agents-api/internal/store/self_hosted_functions_public_test.go": "1c0f61adbe5d29f81ba4bef8936181695cb226528e01ae791ac72585c9d245e2", + "services/agents-api/internal/store/self_hosted_initial_public_test.go": "9d16472f94229bbb008099cce62263ad62cb99bdb4e6c706a3e1779776bcfcb3", + "services/agents-api/internal/store/self_hosted_public_cancel_native_test.go": "77f7305ca2c93c9b3417ad9807623b359e9d11b5863b267ff4a38803af19b7c5", + "services/agents-api/internal/store/self_hosted_public_fixture_test.go": "e38c45ee90509c209cba6f3bfdf51d04b71741bcadd80160c13200fa34493ae0", + "services/agents-api/internal/store/self_hosted_public_functions_native_test.go": "8f983b54e26062ae6136a088467c7c2f25e8de9b0881da6c5ec2447c9c4f6dd3", + "services/agents-api/internal/store/self_hosted_public_helpers_test.go": "e9b46f47ce1fd548e093cfbceca8db6f7c7d0836e9da97004c19260de389e463", + "services/agents-api/internal/store/self_hosted_public_native_test.go": "d253c40f8f007a6d4876ecb645f31b8a45ddebfc1c1416d15cd55134a5f6d3a1", + "services/agents-api/internal/store/self_hosted_public_steering_native_test.go": "779967411ba5b068c7cea6c1e423a142dab2f3239cbd90c724ffbdc0e1b49eaa", + "services/agents-api/internal/store/self_hosted_steering_public_test.go": "b069d39f1edb6b7c7370a298951fe4a2e12c5923881665a6d62df085fd0041ef", + "services/agents-api/internal/store/session_agent_filter_public_test.go": "fdfdbbaa07af00cf0ae6c2c7a6e38cda6bab23543e75a4139764eaeae1f35c83", + "services/agents-api/internal/store/session_agent_filter_test.go": "cd072123d1b79548fd08ac747e353932ab3cee84fe71e146b606009ffbc1f65e", + "services/agents-api/internal/store/session_artifacts.go": "4b5618965ea184e85e440ce3cdcb80898ac25821d769c1e347925c4da633f1fb", + "services/agents-api/internal/store/session_artifacts_test.go": "0535a8e0200fcff8935dee9e5de9afbc4eed5fcc05f526d1e1b5606117aa49d8", + "services/agents-api/internal/store/session_configuration_test.go": "51104afa2a4076efae5922f7f972366eb4f2f20fbd0af53e16ce15c32988fa20", + "services/agents-api/internal/store/session_creation_identity.go": "f45e501103b92ab88eb91f02a1b9c0b3c09e6ed31444fb327d03a084a7d2a589", + "services/agents-api/internal/store/session_creation_identity_test.go": "bc9d6a443f7d1bbd446ee9165d9356e2b69251373de2dec7c6a10360dcc47d31", + "services/agents-api/internal/store/session_creation_stream.go": "2479f7b9cf21ba510d1e20101cfe6c130990e69395182c5ded38ab6283f654df", + "services/agents-api/internal/store/session_creation_stream_test.go": "a3f3f6097e8740bc7a3ad5bced3cd9f29ce17037a54caae657857daacb8ce2b8", + "services/agents-api/internal/store/session_creator.go": "59373f566dd26d64fe1cb1e122757bd1aa50ab03ebb8bba81d1d5e38f527cf4f", + "services/agents-api/internal/store/session_creator_test.go": "929cc7bf1f245ba4826b17da270dd16547e209511076913de565e207046773f2", + "services/agents-api/internal/store/session_deletion.go": "08ccb5aa1359cdb3d3f43c270eb6fe12b643e28eceb2d25bab755723108d9f1d", + "services/agents-api/internal/store/session_deletion_execution_test.go": "1106c804fe9498fc24ffbae3a360a6d6e9ff9a9d87cca26be4299a44ee2205c5", + "services/agents-api/internal/store/session_deletion_public_test.go": "47fdf7a28746d16cbde93c72d855e132911ea8921b9bea37f7b887d603a3f8be", + "services/agents-api/internal/store/session_deletion_test.go": "f7085bbc9d5810c614341c0f705bf655e744ce0b59eb3bce09f72be29268469f", + "services/agents-api/internal/store/session_environment_snapshot.go": "407b4e11ac054e1eccde450071e89efd751a55b740509b5b2efce5d77b0c9ab7", + "services/agents-api/internal/store/session_environment_snapshot_test.go": "4557745b3a6e715b6d8506287401756f7e4c99eef11857005a508bfe0885e368", + "services/agents-api/internal/store/session_events.go": "e9efac9c1a6450670e1319ac6f834289e07ca1bf60511dfcc2b3a80c3b990ab0", + "services/agents-api/internal/store/session_events_test.go": "daacb0237634b3269f29f0f4d07e1cb191ff2b898fa98fe35c9f32d747e3fe9b", + "services/agents-api/internal/store/session_initial_input.go": "26f3298bf3c34eaa226b3ded8650b6e8432d9727d44c5a2d52667bf5fc1e46ba", + "services/agents-api/internal/store/session_initial_input_test.go": "92d331cc0e17ab3a73776355b53a5c2aa707af4e56b2b09a77fb495a5cc15fe3", + "services/agents-api/internal/store/session_initial_public_test.go": "1b6b9a2e6c206786ff3f2d4c9ad41fdfddfa70d6b3d0144cb18e489fde208de3", + "services/agents-api/internal/store/session_metadata.go": "32e57c317c38ade9c9a1a380c2fea0698e7d7008f90022fedbe9a5066584c773", + "services/agents-api/internal/store/session_metadata_test.go": "c464207273975e63337d9f6fc9b5ac4f435c25db82fcf020cdb6917be99d545c", + "services/agents-api/internal/store/session_model_execution.go": "80f4ffd713beb39ffbbd4f29c90cb90174c63666d7da656db22023efe71620da", + "services/agents-api/internal/store/session_model_execution_http_test.go": "11718a19a2a5cd43229eeb05e5d813a73541c5e15a76d21dad602dac506c0be5", + "services/agents-api/internal/store/session_model_execution_test.go": "e6d7e4ec699a7c4e4cd57fe37006ee51f8f78435e092c18d69f38f4c591588d4", + "services/agents-api/internal/store/session_reference_retry_public_test.go": "276f0c6257ee58d5828dd02e44df6ea468b5301d6e12b5dde19476d2c41f0df3", + "services/agents-api/internal/store/session_transaction.go": "ce551fb75efba6a3ae0db37446ae5322529bb2c2d648ce60101f1c4bd08b00ff", + "services/agents-api/internal/store/sessions.go": "75b0916a183e624a0fe01b01838f60bc734daacefef5c5d8b9e2e622985598dd", + "services/agents-api/internal/store/sessions_test.go": "1e8c40add842ba9d8dc3e46ba73e02f5e08b550dd204fa053732d4eac4a54c52", + "services/agents-api/internal/store/source_file_writer.go": "07aef58cd94802a43e491018a893329eb2d420358d07910b3fded7764699f709", + "services/agents-api/internal/store/source_files.go": "ae056725fc20a520f80443b33cf7f89af56d8af8eec1cc8842e361f4f4a8d65d", + "services/agents-api/internal/store/source_files_list_test.go": "990bca3fce855fc45de6c767be32e539eaa51f7047315b0f103577664969d533", + "services/agents-api/internal/store/source_files_test.go": "d3916c6b53b15cac784c7a40cf69d53a00096b941cae743aeedce03fb61e4b10", + "services/agents-api/internal/store/steering_receipts_test.go": "cd10ca2e4c1f8f40db97a412a0493c2f3f32d30fb638cf4cfb39e6a8f60c7c09", + "services/agents-api/internal/store/subagent_dispatch_test.go": "7646c899ec445df5923b5a1b215ad5e06c2a32db1e0fb474b6f5afdf843f2a1d", + "services/agents-api/internal/store/subagent_identities.go": "e64eb82bad5c0382436efc8937dd36de3e51e2ffe1e31dfce4e002ab5f139f06", + "services/agents-api/internal/store/subagent_identities_test.go": "b84e85f06c090238997d7db66b2fc013e9169aec5eb89d03b35c0620d69b811e", + "services/agents-api/internal/store/token_usage.go": "a7eb84eacd2efda447c4f2324200b01ac962a3b36b479864466219e40fac8276", + "services/agents-api/internal/store/token_usage_integration_test.go": "5a7606e34d8c5a6eda775e2230980088b6d4ca51bbcd5ad8304dd5a2ebe76ae7", + "services/agents-api/internal/store/token_usage_test.go": "93b26f5a5a1e807010c0d3483176c65a21b72519c6c29756fa9efa2185f7ae15", + "services/agents-api/internal/store/turn_completion.go": "184021ffcb4abc0afe2847f467f318deb2061249a2b4b8f3cf308e38c1ea9555", + "services/agents-api/internal/store/turn_events.go": "1a9fc9d1c5b51f0d19c5cca16597f0e082356a7e4082a7206eb90715292fa4f8", + "services/agents-api/internal/store/turn_events_test.go": "1f4742f3521246db46030123b51c64e914ce9b07958261d8b01023d3e67319aa", + "services/agents-api/internal/store/turn_inputs.go": "8ec218e0e683456f2728da3600b4d19471adc5a36f9d294d718a4534f552b44b", + "services/agents-api/internal/store/turn_inputs_test.go": "4a4d16eff74a550137efb42c959e5a7f61a0fa2df2b6f9c611381cf8940798cc", + "services/agents-api/internal/store/turn_reads.go": "6c7a72baaf65b301d8b144d5e20f0dca35cadbf429ed98bc4c1cc4e81aa9726f", + "services/agents-api/internal/store/turn_reads_test.go": "e71e4a21c77ce8be8a5a42d0f1d38630638e5652e9d26b66daf649abc9b7ae60", + "services/agents-api/internal/store/turns.go": "d04c05b59209320293d02551f665d4b3efecdcf76a95b8ffacba7847a70e2a92", + "services/agents-api/internal/store/turns_test.go": "a35053fdc029ceb3ce18c423d7e7cb66478881dafd33a6ab07d10d03862c5f5a", + "services/agents-api/internal/store/vault_credentials.go": "44aff12d7d66c108e0223363b769bbaac820c61c7e2266fabf1c489160b1a7c4", + "services/agents-api/internal/store/vault_credentials_delete.go": "2184676a0bd9732b4e43d1d14e98d6c4a01dd0698636ae068ff9d5edfcd509d1", + "services/agents-api/internal/store/vault_credentials_delete_test.go": "8412676d5fe98769845fc0f06a2f73677a32052d142f1087c41938af834652e0", + "services/agents-api/internal/store/vault_credentials_list.go": "2a2a1504de746e480670ceda4a3b959f9e0bde6612026127b9a46c18162ea158", + "services/agents-api/internal/store/vault_credentials_list_test.go": "6b885921e3627e77211bf8a60914575fd80fb93063d0c684bfc2f77cafa7db94", + "services/agents-api/internal/store/vault_credentials_test.go": "ff7e0f0b206e25f8f362a35ad4fa28b957bac64418220d22e785cbfe1c763272", + "services/agents-api/internal/store/vault_credentials_update.go": "14bb7c955e801cbbc9a9c7c7c8146507d4717bd9a30ddbe8b291bdb775bbae2f", + "services/agents-api/internal/store/vault_credentials_update_test.go": "f0b41d235a4f89a3c6f32531b5b3f5d745b8aa3a36e94b05e739d2e7e1277b18", + "services/agents-api/internal/store/vault_status_migration_test.go": "59850e8d365824ab1b4f4abaf3f3847a2730f3c2169d87174c0afc936dea1ce2", + "services/agents-api/internal/store/vaults.go": "2389694c010a382353955d5b648e54966bd6b5af9241357fe45393f18c2419b0", + "services/agents-api/internal/store/vaults_delete.go": "358276e635a5a1dbfadbc1c15ecaf84a693ee4579d9b1a69d84866450429caa9", + "services/agents-api/internal/store/vaults_delete_test.go": "b30759817fedc5254bc22b2b601eeccb1e04d85638583f29a9c5a0348749b7df", + "services/agents-api/internal/store/vaults_list.go": "61a189618ab389fb347527193c7d1c8fb0194edc7ec9f54186fe704e6e3cc24d", + "services/agents-api/internal/store/vaults_list_test.go": "7025ba65ec0e4086a94933ac2b2673c971e3ac2bb342be82a501de87b89ef22f", + "services/agents-api/internal/store/vaults_test.go": "af9b97e8c8130e5a8c3749458057844875e8090a327142059e60a822cf7c9f4c", + "services/agents-api/internal/store/worker_lease_loss_test.go": "acd434474321bc770abbe2bd42994ce3dab89de3931eacda5e4239a0c6d524b8", + "services/agents-api/migrations/000001_sessions.sql": "87e5fa395b209cf672b28d2baa1e91a7b8d53a140517a511fa87c1ac890368e0", + "services/agents-api/migrations/000002_session_configuration.sql": "b89d174e4de8448efe821878c3cf609c48b8fda6e96afb0703392e84a457faaa", + "services/agents-api/migrations/000003_turns.sql": "b5acef0061c67d59db8b5a8b85c6bd41b8955dc2058a0c680690cdeb3e44895d", + "services/agents-api/migrations/000004_input_batches.sql": "634bb431c899711631d0ab71c8b6b6bb7706a6491d0dbfb77c8fe68abe546950", + "services/agents-api/migrations/000005_devices.sql": "2d9047b977777aa0025168a352e3d8c97adbe513fa8599e6d11129680879142a", + "services/agents-api/migrations/000006_native_sessions.sql": "bfb2dda52d421f10e3c0b410087f4726c0e39a01d432237935e18a3ed46bd3d6", + "services/agents-api/migrations/000007_turn_events.sql": "04ca80b3899cf6e72afed2dd014323c43e74653836a30eaae00683bb1d25b49f", + "services/agents-api/migrations/000008_session_items.sql": "9eb349f78f3dbea89d4a74be61cc83f813ccaacd4c99b51096b21924ce38c868", + "services/agents-api/migrations/000009_execution_queue.sql": "fbc088d0d5498ef35d77d2c1768e5e2473b82b13119343dd36e75f352cf6aa21", + "services/agents-api/migrations/000010_token_usage.sql": "1be25abb75031b5dd52c513b635191f25d45b185440b5dd7601c9faaca0a8e11", + "services/agents-api/migrations/000011_item_order.sql": "ca1fc9577d3ef221b687bc50c934539dff61150dbfb1dfe8b4ace78e0c698552", + "services/agents-api/migrations/000012_session_events.sql": "3ebab5ce6391bde2612e68697be52bcc657c72fbd0d619a1073f8907804677c2", + "services/agents-api/migrations/000013_function_calls.sql": "00a4b9ffb313913aeac9faff7036eaf860f999d5f3669d161efcc48d2992955a", + "services/agents-api/migrations/000014_function_inputs.sql": "adfd4f9ad6f8a2927ba416248483b82bfbe3e80e96e2c121a922135c4ceb3bc2", + "services/agents-api/migrations/000015_retire_item_backfill.sql": "d7da76c01b25d0d55edc93f45971bdc06855c45e10ebe832e293e38290e64431", + "services/agents-api/migrations/000016_agents.sql": "cd22df06354f557e4bfaea57d72faa51c86438b2da8ceece3c78855c8b64aeec", + "services/agents-api/migrations/000017_session_creation_identity.sql": "c2d9d0aac322b12e14e3c015cfcc99e529a6ea71244ac4b536fb58bb5e3dfb6d", + "services/agents-api/migrations/000018_session_agent_filter.sql": "365e3272cdbba8b3ff7e9d670bb5740fc9546725c7072ae83e3bf36bb97861ee", + "services/agents-api/migrations/000019_session_deletion.sql": "39f5705e19f1411b804ba8a31a38cf48f737ba7c981d453520b33808e2ac2b3f", + "services/agents-api/migrations/000020_environments.sql": "ee422d75e1299f978697ba8539b9f71962b8e3465a064f199f998a640e00bf1f", + "services/agents-api/migrations/000021_environment_executor_credentials.sql": "1c2f0475e4a1aeba34ae6d9701907be19a16ba4b860481d9eeaac345ad293e34", + "services/agents-api/migrations/000022_environment_input_reservations.sql": "522614ba43e6f1c589afd08f3e23141eaf3470767d0841bd3cc3bae20b780cf6", + "services/agents-api/migrations/000023_environment_input_expiry_index.sql": "e7188005cbde9e4770296d9e5a2fa912bbb2f5bbee71c33851624a2ef9c5ea3e", + "services/agents-api/migrations/000024_execution_project_scopes.sql": "9dcc8cceeb85765179ab01d506430bcd0ab7f5b99a3f709ccadbf149c209f27b", + "services/agents-api/migrations/000025_session_creators.sql": "e6ace5cc1517a93d8f2f7a9a79e1ea788f1c40d749999f118b94d82ff8d16ab5", + "services/agents-api/migrations/000026_executor_principals.sql": "8707b1c462832fc329c0d00ae7a17c3df1c8ae2b2263a063c4d8f22d3045c1d7", + "services/agents-api/migrations/000027_environment_connections.sql": "de5b0e634d32cdd19520644e0055b491ab5215341207b36c4745dcd7083ca086", + "services/agents-api/migrations/000028_environment_input_activity.sql": "2db018a82900685bec2a70e7aae298a7e9c67e75768d3f0c1902f7cd70367c88", + "services/agents-api/migrations/000029_environment_initial_input.sql": "9add7c0e18531defb2b7a6630a674cbc71c24aa4b2a5e7916477431e4d8ccbe1", + "services/agents-api/migrations/000030_vaults.sql": "cc36182806b7ea9e31e99a24c8f8f85aa3bc90cda1a2109d70a855babec1f339", + "services/agents-api/migrations/000031_vault_credentials.sql": "6cda2a64791a818928b48d57eab245ff97c3c35dc3453a0fb436188eecbf235c", + "services/agents-api/migrations/000032_vault_status.sql": "e253a98e5e0c84caf0664fa489ef65b07dd494d07af1b86964d3abe38ceb8ee1", + "services/agents-api/migrations/000033_credential_status.sql": "df3cae1bb8705891b0839e74f1812a8bfa245804055ea5695e06b4784cb4a6c5", + "services/agents-api/migrations/000034_subagent_identities.sql": "20a313d5a097c2c534e37fb83323ddc11cd9ae0c4d4d35e9527624fc545beb58", + "services/agents-api/migrations/000035_local_environment_devices.sql": "3616955188ed8569d149f6a3c7bd912a5fdc1490cf98713745be3f1b28f460d5", + "services/agents-api/migrations/000036_environment_file_writes.sql": "c74849c6c108fa9538ac13028f3a2e471e23a40b86df88507877e44dcf9434b7", + "services/agents-api/migrations/000037_source_files.sql": "a41f5bf8da2bd05214008576d5bafb13bcf6ba2af5f7a51043f337212403b9b9", + "services/agents-api/migrations/000038_runtime_allocations.sql": "5d7850d0fe0b8ac19b3905a78e661e2b45f66f31ba4f9acdbc626412f15e7a9e", + "services/agents-api/migrations/000039_environment_input_failure.sql": "26e293c5f3cbc5a814f0ed2c073fcfe61ceadc83362e944fe1d136d06bad5d8c", + "services/agents-api/migrations/000040_session_artifacts.sql": "04a1955769dfe1178c23bdc1267e2a2da03897c76f0a1144a923dea2a8c48013", + "services/agents-api/migrations/000041_source_files_list.sql": "0ff89549c4726e60cd4372d96450e931ac79cc5e74e77eaf32cc46e71b186d7f", + "services/agents-api/migrations/000042_environment_templates.sql": "152a0adf46c6d59c04b1f6b131f7454d0ce23c26003c897c6fd1de5e2d7b035e", + "services/agents-api/migrations/000043_environment_initial_files.sql": "b9e496284137dde698e46a24104fb0b987cfa6adfcaa4682afbdac2cd9ff693d", + "services/agents-api/migrations/000044_environment_setup.sql": "67f4f3a0c92a5ecbedfda5eb4a36385a24876879eed16b90725892e57bc0b4ce", + "services/agents-api/migrations/000045_environment_skills.sql": "cf20f5df6a19c2b9ca2a573098d499036026cf0142d023bd2a4b29d6387a1b8c", + "services/agents-api/migrations/000046_session_model_execution.sql": "092ae45a54da7147d1e18fbcaeee955cbb496974c830368d9e89902403d7a377", + "services/agents-api/migrations/migrations.go": "c85742b2bf14111e14e1948e846ddf6ef37e0cd6f174ff5f1bdd85ba34a08e7f", + "services/agents-api/sqlc.yaml": "e959acc830de8934258aadcf039613f246f99e3e1d6a61e2d434d4646ce0a74d", + "services/agents-api/tests/container_server.py": "4fe3052e7b487036d6c386292905c2802eaad6f29eed1c86cb7ca33a344859be", + "services/agents-api/tests/e2b_native_isolation.py": "f34b0b8bf2c58de08e162b2a5addc330ca05bc6a95217428b5ebb965eecd7df0", + "services/agents-api/tests/fixtures/credentials.go": "2b3d9b4e62a08d79eb5d6d1fc98c20a20f31a19bc40f99056b6e5dfc8fadf1ce", + "services/agents-api/tests/fixtures/items.go": "ddc865b5cdeae54f6ba913a1bd26dd6e6b800c18e4da9de06b9c0ec79db292b7", + "services/agents-api/tests/fixtures/main.go": "c7c99cba138d7eba7202c8817a520fdcb5634540a4998a44ab76c9b7b584935e", + "services/agents-api/tests/fixtures/vaults.go": "aabd87fb1e8a415e6eb745bd08877400f4749afcfba78bff7fc699e344598601", + "services/agents-api/tests/native/README.md": "c05b5f8bc10da26569b4bd109112474a5553bdf305966890dd9a429444b9467d", + "services/agents-api/tests/native/directory/README.md": "cbc860e517be38db6a5500f051806aa77a1038ea38eea3075b890f62c20e8419", + "services/agents-api/tests/native/directory/probe.rs": "ad54841d6a4f0f57612c80fe22f60dcd27ba0e1491f2d1b097ffeae1c9757808", + "services/agents-api/tests/native/environment_model_probe.py": "e39dba231e3a3b4c770bbd8209c1673f76197b6b070614049ced08c67af0a258", + "services/agents-api/tests/native/raw_files/.gitattributes": "f3ed2a3cc878474ef09273325e3548a6a22444712f7123f70f776573457e5a28", + "services/agents-api/tests/native/raw_files/README.md": "aa8a311bb4d4f2aa2a7fb03310355da0c082f7efab64cf5ba068aae3dc1d7603", + "services/agents-api/tests/native/raw_files/client-dependency.patch": "76df7fcb970ca67bd1f40b2d98e5a2f8ec24564f2e772a69a7e9e9b7ad7f2cc9", + "services/agents-api/tests/native/raw_files/source.json": "15fccdeaaa7cc8ce28c3ff7d4bb32cc7d25d015a9faaf3e5db82fc86f9070185", + "services/agents-api/tests/native/raw_files_probe.rs": "de227317c4058c5a2c9b78b46ac68593178501d5fea01f0f382b286fc832f23c", + "services/agents-api/tests/native/raw_manager/.gitattributes": "7943cd6175021a7788c79f348f373e797d93b8d3360aebddbf15e82d73e301d6", + "services/agents-api/tests/native/raw_manager/README.md": "2a009cfc1981d160f6388dabea409ceaed80851d9d16e6448e391ee5a9cc82db", + "services/agents-api/tests/native/raw_manager/owner.rs": "609af91aa100bc4117c0b8cc8eddf9ff7b44ac62f30245d715b374fe20ed5648", + "services/agents-api/tests/native/raw_manager/prepare.py": "24493bbc7bd01baee6218830dc858078aaa32bd4cd5490c66414786bc2b85a18", + "services/agents-api/tests/native/raw_manager/probe.rs": "45a14e5edf5338ddbf2a7a027860dd365d00c353e35cd6637d583efa6b525deb", + "services/agents-api/tests/native/raw_manager/source.json": "092db927945a4fff31ca98f024f0430329dfa21478606f412ad03cd6f9eef59b", + "services/agents-api/tests/native/relay_probe.rs": "88c6f738b0350b0a34cce3ead8a9c63916b82774e76161c984a88903add8d4a1", + "services/agents-api/tests/native/retirement/README.md": "7f234bd574cbe066e92d1f632d1dad2e9b4c0e26b4ce19eb7359133348edeaf4", + "services/agents-api/tests/native/retirement/gate.rs": "dafc1f9af93f5e9d15c0420b7451d0ec27d85f5c0b85d347b17dcfc8c95cabe5", + "services/agents-api/tests/native/retirement/operator_retirement.py": "ee0dec54410e4ff4af03d002678924632705fc476bde406d210d960803c5818f", + "services/agents-api/tests/native/retirement/placement.py": "f2bc682a609f0d9696d74ff907decc4d9a620d08baa1c31b0c71eef0f10438d4", + "services/agents-api/tests/native/retirement/placement_test.rs": "65362ddf7e40fbb4b6a303dbe1c28a094460df164fe891270b6a05262a0798d6", + "services/agents-api/tests/native/retirement/processor_test.rs": "33bf5cf8eac4767bd8d90c7317d3d7ea1545c9ac5d15ecacf4709f2bc9980524", + "services/agents-api/tests/native/retirement/qualification.patch": "1d6d990cd026f9299ac4e4eef401280e1b5ef6a98806c90115e14f86af00d32b", + "services/agents-api/tests/native/retirement/source.json": "4313c56a356b0daf2e7eab2f643752044d7bf3828c3f9a5bf55fa420a3a8a2c8", + "services/agents-api/tests/native/shared_files/cancellation.rs": "0d8bd2ce489c54b3c467b3b7ea2f1e6ccedc6eb9a28420ed46b5dfdab2c7b47c", + "services/agents-api/tests/native/shared_files/configuration.rs": "45f373a225fccb8d74d6f4c6c017c9b0e37287323fc50a78bea5d1647557f482", + "services/agents-api/tests/native/shared_files/files.rs": "e8ddf3024dba712256fe555363773380cee057947755ba5917e1d8d0010ea17d", + "services/agents-api/tests/native/shared_files/observations.rs": "e7fbdcb0be409a6827088fc734aaf4b76eea425f2f697bb097a738019a5049e5", + "services/agents-api/tests/native/shared_files/probe.rs": "bf96841f708b75341b50ece67e234fb47aae71655ef5644de6edb01f5c5dec1c", + "services/agents-api/tests/native/shared_files/raw_runtime.rs": "90f9e5c1b72524fc5061e6f0bf174a6ff1dc08732c7636663eb948053bf2dbc9", + "services/agents-api/tests/native/shared_files/runtime.rs": "af33d924aae6076ac2fca6a1b481bc578f134cea21d3f02b8a77681df230314c", + "services/agents-api/tests/native/shared_files_probe.rs": "9390424fc97811c49844ef09a1ac225fdc42695d8b485f8765af84b368ef778a", + "services/agents-api/tests/native/write/README.md": "c88a15a8e1a44bd851af7786f8d26eb3004a10aed6f185f80a09cdcd6fa2adff", + "services/agents-api/tests/native/write/probe.py": "9798b297f183e069654ee39c69a6acd8877026ae39707505d2f2e679d4d4a801", + "services/agents-api/tests/official_agent_delete.py": "68978f155344d43ededa15ead21d2358fea418e4f680674b2460f5ce742ab494", + "services/agents-api/tests/official_agent_list.py": "b7c559bca74b1c22549257aa16fe14aefd39eead084eb86de30a9977c57141af", + "services/agents-api/tests/official_agent_reference_retry.py": "4ea9159a63f9587b9465f5a17d6ca052aaf785f53c4071c929904c4777a29804", + "services/agents-api/tests/official_agent_references.py": "ebc501b4af147dd7aa280bf65dda0774ebaea6f202bdd6ede8efce352dc6b797", + "services/agents-api/tests/official_agent_update.py": "185fe7028c5434ad819ee5641866789581b219e920d5bf1f9f302f1d42ee3f9e", + "services/agents-api/tests/official_agents.py": "3be6a781effee0e7486ddbd7fc187fbc21897fdfc45fc887ed11434609dfcc08", + "services/agents-api/tests/official_auth.py": "630c093a19dcccd3a64aae7b2b009e72fb1b5dedfcf3a9185afa2b870723536b", + "services/agents-api/tests/official_client.py": "b4f4182e2cd543d9e8e68b8f8efce4f2d44133ce780dc64cf1d9579806a57829", + "services/agents-api/tests/official_credential_delete.py": "0404b767a077fe881fb283587649a8e837dc1a8c888fd8c73c6188b43e2343cd", + "services/agents-api/tests/official_credential_list.py": "1dc00a0c09178b3211b02150f1acc82e034c1a1ee8a787d8e6163fe412ff3325", + "services/agents-api/tests/official_credential_rotation.py": "4b1ac8457247a880f31b83dc2cd1b91641c94a5745b5cec624d501d0c52ac1b8", + "services/agents-api/tests/official_credentials.py": "1776558d4320712d297db49213bb511ee31d4f8e81bbd63c1b27694a7fcadb79", + "services/agents-api/tests/official_e2b_v1.py": "be4a8e5f8e5ef5f4bbdeade7fd48d5804a10eb59c5d80283afe91948314dab57", + "services/agents-api/tests/official_environment_activity.py": "ea20b4c6de48d5f2fbd86f7026ccfda33fe65c4896db985427bb735f7c50b30b", + "services/agents-api/tests/official_environment_events.py": "2117198aff01f34c29464930a6f9123a3114abb034406b8dfbc07965b3430770", + "services/agents-api/tests/official_environment_files.py": "ba2e07e3c29cbfbcc38ceb0024db3c068af9f0c6acdd515ec9683adf405351b1", + "services/agents-api/tests/official_environment_files_create.py": "38bdabef3336836ce48d670475508a453d325d545676c244a6b2c29c893d8db5", + "services/agents-api/tests/official_environment_files_native.py": "438d2486092119f9f93b6b0c36ce4d38ca85cfa01c2fe62cf0ed286b9bbc8f3b", + "services/agents-api/tests/official_environment_initial_failure.py": "5f87564b4ecf7057c28d0ecd65c95fa7a7d567719f840e7ad192ce05d056d4f6", + "services/agents-api/tests/official_environment_initial_files.py": "bc54f8f7c751a9956f206d1bf1052c509348ebf6dc1029d81d2fcc6d597bc597", + "services/agents-api/tests/official_environment_retrieve.py": "74f006c47ecf9eb78a40036b0f686a74908d84c4d65c56a0223d756b5efc6050", + "services/agents-api/tests/official_environment_setup.py": "65a893debdb1de875b1aa23ecf9e5df93a14dd25a285943d7d09ff10bd667617", + "services/agents-api/tests/official_environment_skills.py": "b7033d6e36ddddca6160c87bb62898af9080e0908b803406f223209932c5778b", + "services/agents-api/tests/official_environment_templates.py": "dbb376a511bb43e80af3e6306a3259e96979bde783c92daac272380335bc4417", + "services/agents-api/tests/official_execution.py": "36e4029d839c4b6c9d41847c9e4924c68a77669d2dd002a2cba01d90d06882a4", + "services/agents-api/tests/official_function_inputs.py": "742457515bafb59c79af0f1f26c443218fdcb48c64f91b9dbd01d76f947b8c25", + "services/agents-api/tests/official_function_state.py": "9d93e07408afc53b9d6b5f04ec53aa02a16baf470caa76fa3d46114a28f4bb8a", + "services/agents-api/tests/official_function_stream.py": "4e5252bbf61705ead3a51b6e7c12c6a172098f850676842ba59cc7bcb09c59c6", + "services/agents-api/tests/official_functions.py": "e15cb83c5c0179f7c286c535488a9f1aa4883a92b47bc65a116dca2cd7bd8528", + "services/agents-api/tests/official_hosted_functions_native.py": "ce1ddc3627a9c88a2f38a1b77eca6674c9c3b3b6c64e9b2313bf058d04b4189c", + "services/agents-api/tests/official_items.py": "d480f6ba654433975b2b063d9936a8c79291203ba97d6f2e9fea55f82b6c7ec7", + "services/agents-api/tests/official_mcode_native.py": "e28ff013b4c04de5d346ea265eac3dc7a7983ce67aa499c6ccbacc6e73a819bd", + "services/agents-api/tests/official_mcp.py": "8c3ba1c05eee88c35bc1807b5c31fc2716be167343097c39f04876549c729e07", + "services/agents-api/tests/official_mcp_credentials.py": "cbe92285ad8e9be5f0a43df98d7d17d69c0745e2cbfad7ef85002b55d2697519", + "services/agents-api/tests/official_self_hosted.py": "0e9fe697e5837bf91a10b3fdb2989a6e0e54e973492ff7367917e19aeae3c275", + "services/agents-api/tests/official_self_hosted_cancel.py": "b58bcffcf0605b78a8f68ca37a5ffbdde7e8c92ddc0aa1db6bdfa2b8374202d1", + "services/agents-api/tests/official_self_hosted_cancel_native.py": "a5fa896138a68fa83f3e3c50f547701d3feef840e7f55d0fb83967d1cdee419e", + "services/agents-api/tests/official_self_hosted_creation.py": "adae4eaabb42d7e434e5a346244b8175f76f0af666bb189de8401945ebf34d0e", + "services/agents-api/tests/official_self_hosted_functions.py": "4373f28ec341efb11df3248833d92dc34497e6fa26d1f0c38693333aa54d0a44", + "services/agents-api/tests/official_self_hosted_functions_native.py": "f70a9ccf4619beae5a635683f2d0645020135e0ae7aa5cbb668e4a78d24eb6eb", + "services/agents-api/tests/official_self_hosted_initial.py": "e1e5655bf363eb75f232f9f647e2253007ec98cc4b1d49e57ca84f71246ebbd9", + "services/agents-api/tests/official_self_hosted_steering.py": "6106d1335cabf253f923fcc2d02737bc8f959f71ef202459dd806eb89a9bcda6", + "services/agents-api/tests/official_self_hosted_steering_native.py": "a4a50fd663929e1bbcad05f77643bd2ec17cc4960e95012a896dde66fcd2bdb6", + "services/agents-api/tests/official_session_agent_filter.py": "b3b6ceb24a61ab14e4ea9b53c0e8bcdf2d1c0f719a376680d02f45efc1d1f25e", + "services/agents-api/tests/official_session_artifacts.py": "6686268515bce5a68235978eebdb22dff654229f48fa3bcd1f1eeca236b33e1c", + "services/agents-api/tests/official_session_creation_stream.py": "73757461b5bcf33be50c83ed3c4dfd8382ca27c579fd6339884baacaa29f3024", + "services/agents-api/tests/official_session_creators.py": "a4308a3828bcaca7b12353ee9de9f723e61ce16355e3e74d040e3b1a73c6ebff", + "services/agents-api/tests/official_session_delete.py": "8cb8581c290dda7c415af385ae9d16fef51da7de9000f44578292244c3bf0b82", + "services/agents-api/tests/official_session_initial_input.py": "0d407a667e3a4c324247d5f2a1e5c084f1c61f5413b5c0d1ed4efa49c2a60b91", + "services/agents-api/tests/official_session_metadata.py": "72e100c8dd765f190868c7e25c50c0905573461f147e5446e441de9b6a9947cf", + "services/agents-api/tests/official_session_requests.py": "e9db3211f5bda807da9e79b314d920fdb6086cf55fa478ca7a086c58b3e03876", + "services/agents-api/tests/official_source_file_list.py": "905a2a7ac165c6d3228bc4b3c7bff95e9a9420acada2f27ba9f9c2333820d93a", + "services/agents-api/tests/official_source_files.py": "8f291739b5489076b91e08f3204da0b15c2f385a1ac6937f8a8e22abe3047426", + "services/agents-api/tests/official_vault_delete.py": "a3644978f857e93a4c2259751cfae695a80dabe9d08c640ae752971867363f0f", + "services/agents-api/tests/official_vault_list.py": "59230e62feaecbf58acd3e8a9fd870e0a88afa289e5420b612158cd264b060b1", + "services/agents-api/tests/official_vaults.py": "335dcb6341a6d19996467f380eac3ab072ef88e02dbddba740fee68ed4e38e24", + "services/agents-api/tests/requirements.txt": "c189f9508164a727d37665dd09645f36a1ede5e71cc794f618d554550d7cb920" + } +} diff --git a/provenance/verification.md b/provenance/verification.md new file mode 100644 index 000000000..8c5912cb9 --- /dev/null +++ b/provenance/verification.md @@ -0,0 +1,66 @@ +# Import acceptance + +Verified on 2026-09-21 against source snapshot +`72ab4d37d49245f15b63d34f5741780e540bcec0`. The destination comparison base is +the empty bootstrap commit `6973875`. + +## Environment + +Checks ran from a standalone copy on Linux amd64 (`zju_a100_2`), with Go +1.25.13, Node 22.22.0, pnpm 10.30.3, Rust 1.95.0, Python 3.10 and PostgreSQL 16. +Only the destination repository was present in the build directory. A dedicated +PostgreSQL container and database `parsar_agents_api_core_import_tests` were used; +no product database or existing deployment was involved. Dependencies used the +existing SSH reverse network path. Credentials remained outside Git and logs. + +The official Python client was installed into a task-local virtual environment +from `contracts/agents-api/upstream.json` (commit +`d7c41efee1b0802b79f3f88a678ef2052b06e9ce`). + +## Results + +| Command | Result | +| --- | --- | +| `python3 scripts/verify-source-copy.py` | Passed: all 1,287 imported paths present, 1,282 byte-identical; only the five documented packaging adaptations differ | +| `make check` | Passed, exit 0: sqlc regeneration, daemon/shared/API/client Go tests, real PostgreSQL tests, standalone API build, Claude SDK tests/package, MiniMax companion checks, Rust format/tests/Clippy and Codex Harness packaging checks | +| `make build-daemon` | Passed using a task-local absolute output directory | +| `go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12` | Passed, exit 0 | +| `AGENTS_API_SERVER_BIN=.../agents-api python services/agents-api/tests/official_client.py` | Passed, exit 0, using the pinned SDK and dedicated database | +| `AGENTS_API_IMAGE=parsar-core-import:72ab4d37 PARSAR_OFFICIAL_SDK_PYTHON=.../sdk/bin/python make check-agents-api-container` | Passed, exit 0: build standalone image and repeat the official-client suite inside read-only containers | + +The official-client suites exercise SDK and raw HTTP responses, generated/upstream +schemas, persistence, service restart, retries, pagination, principal/project +isolation, Agent/Session/Turn lifecycle, vaults/credentials and explicit unsupported +options. They do not establish live model or provider qualification. + +The first database attempt used a name outside the test safety allowlist and was +correctly rejected. The final run uses the required `parsar_agents_api_*_tests` +name; no safety check was removed. The task-local Python environment used pip 25.2 +with PySocks after its bundled pip failed through the SOCKS proxy. No system +toolchain or shared proxy configuration was changed. + +## Evidence and limits + +Private full logs are retained under +`~/.parsar/tests/parsar-core-import-20260921/` on the validation host and copied to +`~/.parsar/parsar-core-import/` on the development host. Their SHA-256 digests are: + +| Log | SHA-256 | +| --- | --- | +| `make-check.log` | `3777c991688d65c36f81c141f2a3b0b8d1084b2670f787bd405c3af5fd1f2d5d` | +| `official-client.log` | `29133bacfda0ff6c59b748726882557f9a56dea3744eab92927b8f8e3cf9969c` | +| `container-check.log` | `6ffb0814b617844d1b30db3e0dc21d6ba3f565137c7d384e029281cc00370ea9` | + +Live paid-model execution, E2B provisioning and opt-in native Harness builds were +not rerun for this source-only import. The MiniMax packaged-native-tools test is +opt-in and skipped without its native prerequisites. Existing implementation, +fixtures and historical acceptance evidence are retained unchanged; this import +does not claim to close their documented protocol gaps. + +The full import whitespace check reports existing whitespace in three Codex +patch files and the vendored E2B process proto. Those bytes are deliberately +preserved and verified against the source manifest; the new scaffolding passes +the whitespace check. + +The source Parsar checkout and its Core copy remain unchanged. No mx service was +reconfigured, stopped or deployed. diff --git a/scripts/build-agents-api-image.sh b/scripts/build-agents-api-image.sh new file mode 100755 index 000000000..941b3362e --- /dev/null +++ b/scripts/build-agents-api-image.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +image="${AGENTS_API_IMAGE:-agents-api:dev}" +if [[ "$runtime_root" != /* ]]; then + printf 'PARSAR_HOME must be absolute: %s\n' "$runtime_root" >&2 + exit 1 +fi +mkdir -p "$runtime_root/cache/agents-api-builds" +image_context="$(mktemp -d "$runtime_root/cache/agents-api-builds/image.XXXXXX")" +trap 'rm -rf "$image_context"' EXIT + +# Reuse the source boundary; never send the repository or runtime keys to Docker. +GOOS=linux GOARCH=amd64 AGENTS_API_BUILD_DIR="$image_context" \ + "$repo_root/scripts/build-agents-api.sh" +cp "$repo_root/services/agents-api/Dockerfile" "$image_context/Dockerfile" +docker build --platform linux/amd64 --tag "$image" "$image_context" diff --git a/scripts/build-agents-api-release.sh b/scripts/build-agents-api-release.sh new file mode 100755 index 000000000..12edf9a41 --- /dev/null +++ b/scripts/build-agents-api-release.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_API_RELEASE_DIR:-$runtime_root/build/agents-api-release}" +export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" +export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" +python3 - "$HOME/.parsar" "$runtime_root" "$output_dir" "$GOCACHE" "$GOMODCACHE" <<'PY' +import pathlib +import sys + +if sys.version_info < (3, 9): + sys.exit("Agents API releases require Python 3.9 or newer") +base = pathlib.Path(sys.argv[1]).resolve() +for value in sys.argv[2:]: + path = pathlib.Path(value) + if not path.is_absolute() or not path.resolve().is_relative_to(base): + sys.exit("Agents API release directories must be absolute and under ~/.parsar") +PY + +require_clean_source() { + local source_status + source_status="$(git -C "$repo_root" status --porcelain --untracked-files=all)" + if [[ -n "$source_status" ]]; then + printf 'Agents API releases require a clean, committed source tree\n' >&2 + exit 1 + fi +} +require_clean_source +source_revision="$(git -C "$repo_root" rev-parse HEAD)" +source_tree="$(git -C "$repo_root" rev-parse "$source_revision^{tree}")" +source_epoch="$(git -C "$repo_root" show -s --format=%ct "$source_revision")" +archive_name="agents-api-$source_revision-linux-amd64.tar.gz" +runtime_image="${AGENTS_API_RELEASE_RUNTIME_IMAGE:-}" +if [[ -n "$runtime_image" ]]; then + if [[ ! "$runtime_image" =~ ^sha256:[0-9a-f]{64}$ ]]; then + printf 'Hosted releases require a qualified immutable Runtime image ID (sha256:...)\n' >&2 + exit 1 + fi + archive_name="agents-api-docker-$source_revision-linux-amd64.tar.gz" +fi + +mkdir -p "$output_dir" +release_context="$(mktemp -d "$output_dir/.staging.XXXXXX")" +trap 'rm -rf "$release_context"' EXIT +mkdir -p "$release_context/source" "$release_context/package/bin" "$release_context/go-tmp" +export GOTMPDIR="$release_context/go-tmp" +# Build committed bytes so ignored files or concurrent edits cannot change provenance. +git -C "$repo_root" archive "$source_revision" | tar -C "$release_context/source" -xf - +export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOTOOLCHAIN=local +go_version="$(go env GOVERSION)" +required_go="$(awk '$1 == "go" { print "go" $2; exit }' "$release_context/source/go.mod")" +if [[ "$go_version" != "$required_go" ]]; then + printf 'Agents API release requires %s; found %s\n' "$required_go" "$go_version" >&2 + exit 1 +fi +AGENTS_API_BUILD_DIR="$release_context/package/bin" \ + "$release_context/source/scripts/build-agents-api.sh" +require_clean_source +if [[ "$(git -C "$repo_root" rev-parse HEAD)" != "$source_revision" ]]; then + printf 'Agents API source changed during release build\n' >&2 + exit 1 +fi + +python3 - "$release_context" "$source_revision" "$source_tree" "$source_epoch" "$go_version" "$archive_name" "$runtime_image" <<'PY' +import gzip +import hashlib +import json +import pathlib +import subprocess +import sys +import tarfile + +root = pathlib.Path(sys.argv[1]) +revision, tree, epoch, go_version, archive_name, runtime_image = sys.argv[2:] +source, package = root / "source", root / "package" +binaries = ["agents-api", "agents-api-migrate", "agents-api-device", "agents-api-environment-key"] + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +readme = (source / "services/agents-api/RELEASE.md").read_text(encoding="utf-8") +if "@SOURCE_REVISION@" not in readme: + sys.exit("Agents API RELEASE.md must link to @SOURCE_REVISION@") +readme = readme.replace("@SOURCE_REVISION@", revision).replace("@ARCHIVE_NAME@", archive_name.removesuffix(".tar.gz")) +(package / "README.md").write_text(readme, encoding="utf-8") +(package / "LICENSE").write_bytes((source / "LICENSE").read_bytes()) +manifest = { + "artifact": "agents-api", + "source": {"commit": revision, "tree": tree, "commit_timestamp": int(epoch)}, + "platform": {"os": "linux", "architecture": "amd64", "goamd64": "v1"}, + "go_version": go_version, + "upstream_protocol": json.loads((source / "contracts/agents-api/upstream.json").read_text(encoding="utf-8")), + "binaries": {"bin/" + name: {"sha256": sha256(package / "bin" / name)} for name in binaries}, +} +extra_members = [] +if runtime_image: + inspected = json.loads(subprocess.check_output(["docker", "image", "inspect", runtime_image], text=True))[0] + if inspected["Id"] != runtime_image or inspected["Os"] != "linux" or inspected["Architecture"] != "amd64": + sys.exit("Hosted releases require the selected Linux amd64 Runtime image") + (package / "runtime").mkdir() + image_path = package / "runtime/image.tar" + subprocess.run(["docker", "image", "save", "--output", str(image_path), runtime_image], check=True) + (package / "runtime/seccomp.json").write_bytes((source / "services/agents-api/deploy/codex/seccomp.json").read_bytes()) + guide = (source / "services/agents-api/HOSTED-RELEASE.md").read_text(encoding="utf-8") + (package / "HOSTED.md").write_text(guide.replace("@RUNTIME_IMAGE@", runtime_image).replace("@SOURCE_REVISION@", revision), encoding="utf-8") + extra_members = ["HOSTED.md", "runtime/image.tar", "runtime/seccomp.json"] + manifest["runtime"] = { + "image_id": runtime_image, + "platform": {"os": inspected["Os"], "architecture": inspected["Architecture"]}, + "files": {name: {"sha256": sha256(package / name)} for name in extra_members}, + } +(package / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +members = sorted(["bin/" + name for name in binaries] + ["LICENSE", "README.md", "manifest.json"] + extra_members) +(package / "SHA256SUMS").write_text("".join(sha256(package / name) + " " + name + "\n" for name in members), encoding="utf-8") +members = sorted(members + ["SHA256SUMS"]) +prefix = archive_name.removesuffix(".tar.gz") +archive = root / archive_name +with archive.open("wb") as output: + with gzip.GzipFile(filename="", mode="wb", fileobj=output, mtime=0, compresslevel=9) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as bundle: + for name in members: + path = package / name + info = tarfile.TarInfo(prefix + "/" + name) + info.size = path.stat().st_size + info.mode = 0o755 if name.startswith("bin/") else 0o644 + info.mtime = int(epoch) + with path.open("rb") as contents: + bundle.addfile(info, contents) +(root / (archive_name + ".sha256")).write_text(sha256(archive) + " " + archive_name + "\n", encoding="utf-8") +PY + +mv -f "$release_context/$archive_name" "$release_context/$archive_name.sha256" "$output_dir/" +printf 'Standalone Agents API release: %s/%s\n' "$output_dir" "$archive_name" diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh new file mode 100755 index 000000000..f24443d0e --- /dev/null +++ b/scripts/build-agents-api.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_API_BUILD_DIR:-$runtime_root/build/agents-api}" +for directory in "$runtime_root" "$output_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Agents API build directories must be absolute: %s\n' "$directory" >&2 + exit 1 + fi +done + +mkdir -p "$runtime_root/cache/agents-api-builds" +build_context="$(mktemp -d "$runtime_root/cache/agents-api-builds/source.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT + +# This is the release source boundary. Product and other application sources +# must remain physically absent, even when building from the full monorepo. +tar -C "$repo_root" -cf - \ + go.mod go.sum \ + contracts/agents-api/v1 \ + internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ + internal/agentskill internal/obs/log services/agents-api \ + | tar -C "$build_context" -xf - + +( + cd "$build_context" + export GOWORK=off CGO_ENABLED=0 + for command in server migrate device environment-key; do + artifact="agents-api-$command" + if [[ "$command" == server ]]; then artifact=agents-api; fi + go build -mod=readonly -trimpath -buildvcs=false \ + -o "$build_context/bin/$artifact" "./services/agents-api/cmd/$command" + done +) + +# Publish only after every command builds successfully. +mkdir -p "$output_dir" +for artifact in agents-api agents-api-migrate agents-api-device agents-api-environment-key; do + mv -f "$build_context/bin/$artifact" "$output_dir/$artifact" +done +printf 'Standalone Agents API binaries: %s\n' "$output_dir" diff --git a/scripts/build-agents-executor.sh b/scripts/build-agents-executor.sh new file mode 100755 index 000000000..5595b16e3 --- /dev/null +++ b/scripts/build-agents-executor.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Executor build directories must be absolute\n' >&2 + exit 1 + fi +done +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + printf 'The executor build currently supports Linux x86_64\n' >&2 + exit 1 +fi + +export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/executor-cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/executor-target}" +for directory in "$CARGO_HOME" "$CARGO_TARGET_DIR"; do + if [[ "$directory" != /* ]]; then + printf 'Executor Cargo directories must be absolute\n' >&2 + exit 1 + fi +done +mkdir -p "$runtime_root/cache/executor-builds" +build_context="$(mktemp -d "$runtime_root/cache/executor-builds/source.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT +for file in Cargo.toml Cargo.lock rust-toolchain.toml; do + cp "$repo_root/packages/codex-executor/$file" "$build_context/$file" +done +cp -R "$repo_root/packages/codex-executor/src" "$build_context/src" +( + cd "$build_context" + cargo build --locked --release +) +mkdir -p "$output_dir" +for binary in agents-api-codex-executor agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do + cp "$CARGO_TARGET_DIR/release/$binary" "$output_dir/$binary.tmp" + mv -f "$output_dir/$binary.tmp" "$output_dir/$binary" +done +printf 'Standalone Codex executor and workspace helpers: %s\n' "$output_dir" diff --git a/scripts/build-agents-harness.sh b/scripts/build-agents-harness.sh new file mode 100755 index 000000000..d21c783fb --- /dev/null +++ b/scripts/build-agents-harness.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package="$repo_root/packages/codex-harness" +runtime_root="$HOME/.parsar" +output_dir="${AGENTS_HARNESS_BUILD_DIR:-$runtime_root/build/agents-harness}" +native_source="${AGENTS_HARNESS_NATIVE_SOURCE:?Set AGENTS_HARNESS_NATIVE_SOURCE to the pinned upstream Git checkout}" +mode="${1:-build}" +if [[ "$mode" != build && "$mode" != check ]]; then + printf 'Expected build or check mode\n' >&2 + exit 1 +fi +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + printf 'The private harness supports Linux x86_64\n' >&2 + exit 1 +fi +export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/agents-harness-cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/agents-harness-target}" +export TMPDIR="$runtime_root/cache/agents-harness-tmp" +export RUSTUP_TOOLCHAIN="${RUSTUP_TOOLCHAIN:-1.95.0}" +rustc_version="$(rustc --version)" +if [[ "$rustc_version" != 'rustc 1.95.0 '* ]]; then + printf 'The private harness requires rustc 1.95.0\n' >&2 + exit 1 +fi +python3 "$package/prepare.py" --check --check-path "$output_dir" \ + --check-path "$CARGO_HOME" --check-path "$CARGO_TARGET_DIR" \ + --check-path "$TMPDIR" --check-path "$runtime_root/cache/agents-harness-builds" +mkdir -p "$runtime_root/cache/agents-harness-builds" "$TMPDIR" +build_context="$(mktemp -d "$runtime_root/cache/agents-harness-builds/source.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT +python3 "$package/prepare.py" --source "$native_source" --output "$build_context/upstream" +cd "$build_context/upstream/codex-rs" +if [[ "$mode" == check ]]; then + rustfmt --check --edition 2024 app-server/parsar-harness/*.rs exec-server/src/bounded_file_read*.rs + cargo test --locked -p codex-exec-server --lib bounded_file_read + cargo clippy --locked -p codex-exec-server --lib --tests -- -D warnings + cargo test --locked -p codex-app-server --bin parsar-codex-harness + cargo clippy --locked -p codex-app-server --bin parsar-codex-harness -- -D warnings + exit 0 +fi +cargo build --locked --release -p codex-app-server --bin parsar-codex-harness +mkdir -p "$output_dir" +cp "$CARGO_TARGET_DIR/release/parsar-codex-harness" "$output_dir/parsar-codex-harness.tmp" +mv -f "$output_dir/parsar-codex-harness.tmp" "$output_dir/parsar-codex-harness" +python3 - "$build_context/upstream/preparation.json" "$output_dir" <<'PY' +import hashlib +import json +import os +import pathlib +import subprocess +import sys + +record = json.loads(pathlib.Path(sys.argv[1]).read_text()) +output = pathlib.Path(sys.argv[2]) +record["artifact_sha256"] = hashlib.sha256((output / "parsar-codex-harness").read_bytes()).hexdigest() +record["rustc"] = subprocess.check_output(["rustc", "--version"], text=True).strip() +record["cargo"] = subprocess.check_output(["cargo", "--version"], text=True).strip() +record["build_profile"] = "release" +record["profile_overrides"] = {key: value for key, value in os.environ.items() if key.startswith("CARGO_PROFILE_RELEASE_")} +pending = output / "provenance.json.tmp" +pending.write_text(json.dumps(record, indent=2) + "\n") +pending.replace(output / "provenance.json") +PY +printf 'Private Codex harness: %s\n' "$output_dir/parsar-codex-harness" diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh new file mode 100755 index 000000000..533b98c80 --- /dev/null +++ b/scripts/build-agents-runtime.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/agents-runtime}" +# Extract the official @openai/codex@0.153.4-linux-x64 npm package here. +package_dir="${AGENTS_RUNTIME_CODEX_PACKAGE:?Set AGENTS_RUNTIME_CODEX_PACKAGE to the extracted pinned platform package}" +helpers_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output_dir" "$package_dir" "$helpers_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Runtime build directories must be absolute: %s\n' "$directory" >&2 + exit 1 + fi +done +python3 - "$package_dir/package.json" <<'PY' +import json, sys +package = json.load(open(sys.argv[1])) +assert package['name'] == '@openai/codex' and package['version'] == '0.153.4-linux-x64', 'Expected pinned official Linux x64 package' +PY +native_dir="$package_dir/vendor/x86_64-unknown-linux-musl" +for executable in "$native_dir/bin/codex" "$helpers_dir/agents-api-codex-directory" "$helpers_dir/agents-api-codex-write" "$helpers_dir/agents-api-workspace-export"; do + test -x "$executable" || { printf 'Missing executable: %s\n' "$executable" >&2; exit 1; } +done +test -d "$native_dir/codex-resources" +mkdir -p "$runtime_root/cache/agents-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/bundle.XXXXXX")" +trap 'rm -rf "$context"' EXIT +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ + -o "$context/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +) +cp "$helpers_dir/agents-api-codex-directory" "$helpers_dir/agents-api-codex-write" "$helpers_dir/agents-api-workspace-export" "$context/" +cp "$native_dir/bin/codex" "$context/codex" +cp -R "$native_dir/codex-resources" "$context/codex-resources" +cp "$repo_root/services/agents-api/deploy/codex/tool-env.py" "$context/tool-env.py" +cp "$repo_root/services/agents-api/deploy/codex/requirements.toml" "$context/requirements.toml" +cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" +cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" +# Preserve the previous bundle if compilation or validation failed. +mkdir -p "$output_dir" +cp -R "$context/." "$output_dir/" +printf 'Runtime image context: %s\n' "$output_dir" +printf 'Build with: docker build --platform linux/amd64 -t agents-runtime:local %q\n' "$output_dir" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh new file mode 100755 index 000000000..943a6bb18 --- /dev/null +++ b/scripts/build-claude-runtime.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/claude-runtime}" +sdk_dir="${CLAUDE_SDK_BUILD_DIR:-$runtime_root/build/claude-sdk-runtime}" +helpers_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output_dir" "$sdk_dir" "$helpers_dir"; do + [[ "$directory" == /* ]] || { printf 'Expected absolute build directory: %s\n' "$directory" >&2; exit 1; } +done +mkdir -p "$runtime_root/cache/agents-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/claude.XXXXXX")" +trap 'rm -rf "$context"' EXIT +archive=claude-sdk-runtime-linux-x64-glibc.tar.gz +(cd "$sdk_dir" && sha256sum -c "$archive.sha256") +mkdir "$context/claude-sdk" +tar -xzf "$sdk_dir/$archive" -C "$context/claude-sdk" +node "$repo_root/scripts/check-claude-sdk-runtime.mjs" "$context/claude-sdk" +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ + -o "$context/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +) +for helper in agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do + test -x "$helpers_dir/$helper" + cp "$helpers_dir/$helper" "$context/" +done +cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" +cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" +mkdir -p "$output_dir" +cp -R "$context/." "$output_dir/" +printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-claude-sdk-runtime.sh b/scripts/build-claude-sdk-runtime.sh new file mode 100755 index 000000000..08010747b --- /dev/null +++ b/scripts/build-claude-sdk-runtime.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${CLAUDE_SDK_BUILD_DIR:-$runtime_root/build/claude-sdk-runtime}" +for directory in "$runtime_root" "$output_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Claude SDK build directories must be absolute: %s\n' "$directory" >&2 + exit 1 + fi +done + +node -e 'if (Number(process.versions.node.split(".")[0]) < 20) throw new Error("Claude SDK runtime builds require Node 20 or newer")' +mkdir -p "$runtime_root/cache/claude-sdk-builds" +build_context="$(mktemp -d "$runtime_root/cache/claude-sdk-builds/runtime.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT +cd "$repo_root" +# Validate source manifests before deploy derives its dedicated frozen lockfile. +test -f pnpm-lock.yaml +pnpm install --frozen-lockfile +pnpm --filter @parsar/claude-sdk-adapter build --outDir "$build_context/compiled" +# This package has registry dependencies only. Keep injection local to export; +# the ordinary workspace and product installs retain their current settings. +pnpm --config.inject-workspace-packages=true --config.extend-node-path=false \ + --filter @parsar/claude-sdk-adapter \ + deploy --prod "$build_context/runtime" +# Discard any incremental checkout output copied by the package exporter. +rm -rf "$build_context/runtime/dist" +mv "$build_context/compiled" "$build_context/runtime/dist" +node scripts/check-claude-sdk-runtime.mjs "$build_context/runtime" + +platform="$(node -p 'process.platform + "-" + process.arch + (process.platform === "linux" ? (process.report.getReport().header.glibcVersionRuntime ? "-glibc" : "-musl") : "")')" +archive="claude-sdk-runtime-$platform.tar.gz" +tar -C "$build_context/runtime" -czf "$build_context/$archive" . +node - "$build_context/$archive" "$archive" > "$build_context/$archive.sha256" <<'JS' +const { createHash } = require("node:crypto"); +const { readFileSync } = require("node:fs"); +console.log(createHash("sha256").update(readFileSync(process.argv[2])).digest("hex") + " " + process.argv[3]); +JS +mkdir -p "$output_dir" +mv -f "$build_context/$archive" "$build_context/$archive.sha256" "$output_dir/" +printf 'Claude SDK runtime: %s/%s\n' "$output_dir" "$archive" diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh new file mode 100644 index 000000000..e0c0a4776 --- /dev/null +++ b/scripts/build-mcode-harness.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package="$repo_root/packages/mcode-harness" +source="${MCODE_NATIVE_SOURCE:?Set MCODE_NATIVE_SOURCE to the pinned upstream checkout}" +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + printf 'Build the MiniMax Code Runtime artifact on Linux x86_64\n' >&2 + exit 1 +fi +root="$HOME/.parsar/build" +mkdir -p "$root" +context="$(mktemp -d "$root/mcode-build.XXXXXX")" +trap 'rm -rf "$context"' EXIT +revision="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' "$package/source.json")" +if [[ "$(git -C "$source" rev-parse HEAD)" != "$revision" ]]; then + printf 'MiniMax Code source revision does not match source.json\n' >&2 + exit 1 +fi +mkdir "$context/upstream" +git -C "$source" archive "$revision" | tar -x -C "$context/upstream" +printf '%s\n' "$revision" > "$context/upstream/.parsar-source-revision" +cp "$package/"*.mjs "$package/"*.ts "$package/"*.json "$context/" +( + cd "$context" + npm ci --no-audit --no-fund + MCODE_SOURCE="$context/upstream" node build.mjs + MCODE_SOURCE="$context/upstream" node build-sandbox.mjs + node dist/worker.mjs /workspace --describe > dist/tools.json + npm prune --omit=dev --no-audit --no-fund +) +artifact="$context/artifact" +mkdir "$artifact" +cp -R "$context/dist" "$context/node_modules" "$artifact/" +cp "$package/launch.mjs" "$package/bridge.mjs" "$package/check.mjs" "$package/tool-executor.mjs" "$package/source.json" "$artifact/" +cp "$context/upstream/LICENSE" "$artifact/UPSTREAM_LICENSE" +cp "$context/upstream/third_party/sandbox-runtime/LICENSE" "$artifact/SANDBOX_LICENSE" +cp "$context/upstream/third_party/pi-mono/LICENSE" "$artifact/PI_LICENSE" +python3 - "$artifact" "$package" <<'PY' +import hashlib,json,pathlib,sys +artifact,package=map(pathlib.Path,sys.argv[1:]) +pin=json.loads((package/'source.json').read_text()) +pin['files']={str(p.relative_to(artifact)):hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(artifact.rglob('*')) if p.is_file()} +(artifact/'provenance.json').write_text(json.dumps(pin,indent=2)+'\n') +PY +destination="$root/mcode-harness-$(date +%Y%m%d%H%M%S)" +mv "$artifact" "$destination" +printf 'MiniMax Code Runtime artifact: %s\n' "$destination" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh new file mode 100644 index 000000000..54ededaad --- /dev/null +++ b/scripts/build-mcode-runtime.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/mcode-runtime}" +native="${MCODE_CLI_DIR:?Set MCODE_CLI_DIR to the installed published package directory}" +companion="${MCODE_HARNESS_BUILD_DIR:?Set MCODE_HARNESS_BUILD_DIR to the built companion}" +helpers="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output" "$native" "$companion" "$helpers"; do + [[ "$directory" == /* ]] || { printf 'Absolute build directories are required\n' >&2; exit 1; } +done +test -f "$companion/provenance.json" +test "$(node "$native/cli.js" --version)" = 0.4.12 +mkdir -p "$runtime_root/cache/agents-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/mcode.XXXXXX")" +trap 'rm -rf "$context"' EXIT +mkdir "$context/mcode" "$context/mcode-harness" +cp -RL "$native/." "$context/mcode/" +cp -R "$companion/." "$context/mcode-harness/" +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ + -o "$context/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +) +for helper in agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do + test -x "$helpers/$helper" + cp "$helpers/$helper" "$context/" +done +cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" +cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" +mkdir -p "$output" +cp -R "$context/." "$output/" +printf 'MiniMax Code Runtime image context: %s\n' "$output" diff --git a/scripts/check-agents-executor.sh b/scripts/check-agents-executor.sh new file mode 100755 index 000000000..9eacf8623 --- /dev/null +++ b/scripts/check-agents-executor.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +if [[ "$runtime_root" != /* ]]; then + printf 'Executor runtime directory must be absolute\n' >&2 + exit 1 +fi +export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/executor-cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/executor-target}" +for directory in "$CARGO_HOME" "$CARGO_TARGET_DIR"; do + if [[ "$directory" != /* ]]; then + printf 'Executor Cargo directories must be absolute\n' >&2 + exit 1 + fi +done +cd "$repo_root/packages/codex-executor" +cargo fmt --all -- --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings diff --git a/scripts/check-agents-harness.sh b/scripts/check-agents-harness.sh new file mode 100755 index 000000000..5837393fa --- /dev/null +++ b/scripts/check-agents-harness.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package="$repo_root/packages/codex-harness" +export PYTHONDONTWRITEBYTECODE=1 +python3 "$package/prepare.py" --check +python3 "$package/prepare_test.py" +bash -n "$repo_root/scripts/build-agents-harness.sh" "$repo_root/scripts/check-agents-harness.sh" diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs new file mode 100644 index 000000000..4f4803635 --- /dev/null +++ b/scripts/check-claude-sdk-runtime.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { access, readFile, readdir, realpath } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; + +const root = await realpath(process.argv[2]); +async function checkLinks(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + const rel = relative(root, await realpath(path)); + assert(rel !== ".." && !rel.startsWith(".." + sep), "Runtime dependency escaped the exported directory"); + } else if (entry.isDirectory()) await checkLinks(path); + } +} +await checkLinks(root); +await access(join(root, "pnpm-lock.yaml")); +const source = JSON.parse(await readFile(new URL("../packages/claude-sdk-adapter/package.json", import.meta.url), "utf8")); +const probe = spawnSync(process.execPath, [join(root, "dist/runtime_check.js"), join(root, "dist/main.js")], { + encoding: "utf8", timeout: 15000, killSignal: "SIGKILL", maxBuffer: 64 * 1024, cwd: root, +}); +assert.equal(probe.status, 0, "Exported runtime is unavailable"); +const report = JSON.parse(probe.stdout); +assert.equal(report.type, "runtime_ready"); +assert.equal(report.protocol, 1); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); +assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); +console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/scripts/check-sqlc.py b/scripts/check-sqlc.py new file mode 100644 index 000000000..c7203f5b1 --- /dev/null +++ b/scripts/check-sqlc.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Check generated Core queries against their current checked-out bytes.""" +from pathlib import Path +import subprocess + +root = Path(__file__).resolve().parent.parent +generated = root / "services/agents-api/internal/db/sqlc" + + +def snapshot(): + return {str(p.relative_to(generated)): p.read_bytes() for p in generated.rglob("*") if p.is_file()} + + +before = snapshot() +subprocess.run(["make", "sqlc-generate"], cwd=root, check=True) +if snapshot() != before: + raise SystemExit("Core sqlc generated files are out of date; commit make sqlc-generate output") diff --git a/scripts/verify-source-copy.py b/scripts/verify-source-copy.py new file mode 100644 index 000000000..96ba54e11 --- /dev/null +++ b/scripts/verify-source-copy.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Audit the initial source import against its recorded original hashes. + +This is an import acceptance command, not a gate on future Core development. +""" +import hashlib +import json +from pathlib import Path + +root = Path(__file__).resolve().parent.parent +manifest = json.loads((root / "provenance/source.json").read_text()) +adaptations = { + "go.mod": "Remove unused product dependencies without changing module identity.", + "go.sum": "Regenerate checksums for the standalone dependency closure.", + "pnpm-lock.yaml": "Keep only the standalone Claude adapter workspace and its dependencies.", + "services/agents-api/RELEASE.md": "Resolve new release revisions in parsar-core.", + "services/agents-api/HOSTED-RELEASE.md": "Resolve new release revisions in parsar-core.", +} +errors = [] +unchanged = 0 +for name, expected in manifest["files"].items(): + path = root / name + if not path.is_file(): + errors.append("missing: " + name) + elif hashlib.sha256(path.read_bytes()).hexdigest() == expected: + unchanged += 1 + elif name not in adaptations: + errors.append("unexpected modification: " + name) +for name in ("server", "apps/web", "apps/parsar", "packages/cli", "packages/opencode-plugin", "infra"): + if (root / name).exists(): + errors.append("product tree included: " + name) +if errors: + raise SystemExit("\n".join(errors)) +print(f"Source import verified: {len(manifest['files'])} files, {unchanged} unchanged") +print("Permitted packaging adaptations: " + ", ".join(adaptations)) diff --git a/services/agents-api/CONTAINER.md b/services/agents-api/CONTAINER.md new file mode 100644 index 000000000..3c1df843c --- /dev/null +++ b/services/agents-api/CONTAINER.md @@ -0,0 +1,105 @@ +# Standalone container + +This image packages the execution API, its embedded migrator and device operator +command. It needs a dedicated PostgreSQL database/account and an external daemon +for native execution. It contains no Parsar product service, frontend, product +migrations or harness. Supported protocol slices and execution limits remain as +listed in the [service guide](README.md) and [coverage](../../contracts/agents-api/README.md). +Container packaging does not imply complete protocol compatibility. + +## Build + +```bash +make docker-build-agents-api +# Optional local image name: +AGENTS_API_IMAGE=agents-api:local make docker-build-agents-api +``` + +The target needs Go, Docker and access to pinned Go modules and the base image. +It reuses the isolated binary build and sends only those executables and the image +recipe to Docker. Linux amd64 is the current runtime target; other architectures +and registry publication are not included. The pinned +[Distroless static runtime](https://github.com/GoogleContainerTools/distroless) +contains CA certificates and no shell or package manager. The default user is +UID/GID 65532. No model credentials or tenant keys belong in the image. + +## Configure and run + +Use a new private directory for deployment configuration. Create `api.env` with +`AGENTS_API_DATABASE_URL` pointing to the dedicated execution database and +`AGENTS_API_KEYS_FILE=/run/keys.json`. Create `keys.json` using the hashed tenant-key +format in [Standalone HTTP service](README.md#standalone-http-service). Keep both +files private, for example mode 0600 inside a mode 0700 directory. The database +hostname must be reachable from the container; container localhost is not the host. + +Run migrations explicitly before starting the service. They belong to this API +alone and must never target the product database: + +```bash +config_dir="$HOME/.parsar/agents-api-deployment" +docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --env-file "$config_dir/api.env" \ + agents-api:dev /usr/local/bin/agents-api-migrate +``` + +The following Linux example uses the non-root host UID to read its private key +file. Alternatively, grant the image's default UID read access and omit `--user`. +Do not run the example from a root shell. + +```bash +docker run --name agents-api --detach --read-only \ + --cap-drop=ALL --security-opt=no-new-privileges \ + --user "$(id -u):$(id -g)" \ + --publish 127.0.0.1:8091:8091 \ + --env-file "$config_dir/api.env" \ + --mount "type=bind,source=$config_dir/keys.json,target=/run/keys.json,readonly" \ + agents-api:dev +curl --fail http://127.0.0.1:8091/healthz +docker logs agents-api +``` + +The container listens on `:8091`; use a TLS reverse proxy for remote clients. +`/healthz` is liveness only. Database startup validation does not make it a +continuous readiness probe. Persistent API state is in PostgreSQL, so the image +needs no writable application volume. Send SIGTERM with `docker stop agents-api` +and start it again with `docker start agents-api`; never remove database storage +as part of replacing the API container. An execution worker currently permits one +active service per execution database; container replicas do not add HA/recovery. + +## Connect execution + +Set `AGENTS_API_DAEMON_WS_URL` in `api.env` to the API's externally reachable daemon +WebSocket URL, then start the container. Provision a device using this image with +`/usr/local/bin/agents-api-device` as the command and the arguments documented in +[Internal execution device connection](README.md#internal-execution-device-connection). +Pass the same private environment file. The operator command emits a secret profile; +redirect it into a new private file and transfer it securely to the executor. + +Install the daemon and native harness separately. Native history stays on that +executor; an API container restart must not be treated as a new native Session. +Model credentials belong in the executor's private configuration. API keys, device +credentials and Parsar user identities are separate. The daemon URL is not the +upstream `self_hosted.remote_url` protocol. Daemon distribution and product cutover +remain separate work. + +## Verify + +On Linux, with a non-root host user, a `parsar_agents_api_*_tests` database with API migrations applied +and the fixed official Python SDK installed: + +```bash +PARSAR_AGENTS_API_TEST_DATABASE_URL='postgres://.../parsar_agents_api_local_tests' \ + PARSAR_OFFICIAL_SDK_PYTHON=python3 make check-agents-api-container +``` + +This reuses the existing SDK/raw-HTTP/Go-client suite against read-only containers, +including authentication, tenant isolation and restart persistence. Its host +network is a test convenience. Real daemon/model acceptance is additional evidence; +synthetic or HTTP-only checks do not prove native execution or full compatibility. + +The image also includes `/usr/local/bin/agents-api-environment-key` for operator +issuance, rotation and revocation of exact-Environment executor credentials. Run it +with only the execution database configuration and the arguments in the +[native transport guide](README.md#native-executor-transport-prerequisite). Redirect +its secret stdout to a mode-0600 file under `~/.parsar/`; do not bake credentials +into the image or pass the broader caller key to an executor. diff --git a/services/agents-api/Dockerfile b/services/agents-api/Dockerfile new file mode 100644 index 000000000..e9eca83ec --- /dev/null +++ b/services/agents-api/Dockerfile @@ -0,0 +1,9 @@ +# Linux amd64 runtime. Build with make docker-build-agents-api. +# The context contains only the three independently built static executables. +FROM gcr.io/distroless/static-debian13:nonroot@sha256:e754765ad9e167b0677b41c617fd44afb7b9818a477f48f17bda08e12cfb98cb + +COPY --chmod=0555 agents-api agents-api-migrate agents-api-device agents-api-environment-key /usr/local/bin/ +ENV AGENTS_API_ADDR=:8091 +EXPOSE 8091 +USER 65532:65532 +CMD ["/usr/local/bin/agents-api"] diff --git a/services/agents-api/HOSTED-RELEASE.md b/services/agents-api/HOSTED-RELEASE.md new file mode 100644 index 000000000..56a35787b --- /dev/null +++ b/services/agents-api/HOSTED-RELEASE.md @@ -0,0 +1,186 @@ +# Docker-hosted Agents API + +This package runs Core on a Linux amd64 host and creates one colocated +Runtime per Session through Docker. Each Runtime contains the daemon, stock +Codex 0.153.4, local tools and workspace. Core, its PostgreSQL database and +operator secrets stay outside the Runtime. No source checkout, compiler, +Parsar service, product database, separate executor or manual daemon enrollment +is needed. Complete the common database, caller identity and private directory +setup in `README.md` first; do not start Core until the configuration below is ready. + +## Load the qualified Runtime + +The supported deployment profile was qualified on Docker 29.1.3 with local Linux +volumes and unprivileged user namespaces. The Docker engine must support volume +subpath mounts. Other hosts and security policies need their own isolation checks. +The Provider uses a non-root container, read-only root, dropped capabilities, +no-new-privileges, private PID namespace and bounded resources. Stock Codex uses +its inner bubblewrap sandbox with the bundled seccomp policy and container-specific +`apparmor=unconfined`; host-wide policy must remain unchanged. Docker availability +alone does not establish that this native sandbox can run safely. + +From the extracted package directory, after checking `SHA256SUMS`: + +```sh +export AGENTS_API_PACKAGE="$PWD" +docker image load --input "$AGENTS_API_PACKAGE/runtime/image.tar" +docker image inspect @RUNTIME_IMAGE@ --format '{{.Id}} {{.Os}}/{{.Architecture}}' +``` + +The result must be `@RUNTIME_IMAGE@ linux/amd64`. The image is selected by this +immutable ID; no registry pull or mutable tag is required. Package checksums +establish transferred bytes, not trust in their distributor or safety of another +image. Keep the package's `runtime/seccomp.json` available to Core. + +## Configure Core + +Run Core as an operator account with access to the explicit local Docker Unix +socket. Docker access is privileged host authority; keep Core and this socket +outside agent workspaces. The Provider never mounts it in a Runtime. Retain the +same Docker backend, provider UUID, database, caller identities and native volumes +across Core upgrades. Changing a backend needs a new provider UUID; keep the old +entry until its allocations are reclaimed. + +For a local deployment, the following uses the Docker bridge gateway so sandbox +connections can reach Core. Reserve port 8091 and restrict it to intended clients +and Runtime containers with the host firewall. For external access, terminate TLS +with your existing proxy and use its reachable HTTPS/WSS addresses instead. + +```sh +export AGENTS_API_ADDR=0.0.0.0:8091 +RUNTIME_GATEWAY="$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')" +export AGENTS_API_DAEMON_WS_URL="ws://$RUNTIME_GATEWAY:8091/api/v1/agent-daemon/ws" +export AGENTS_API_MANAGED_RUNTIMES_FILE="$PARSAR_HOME/managed-runtimes.json" +export AGENTS_API_EXECUTION_OPTIONS_FILE="$PARSAR_HOME/execution-options.json" +``` + +Create the private managed configuration with a fresh stable provider UUID. Replace +both occurrences of ``, the gateway and the absolute package path: + +```json +{ + "core_url": "http://:8091/api/v1", + "default_provider": "", + "docker": { + "": { + "host": "unix:///var/run/docker.sock", + "image": "@RUNTIME_IMAGE@", + "network": "bridge", + "seccomp_file": "/runtime/seccomp.json" + } + } +} +``` + +Create `execution-options.json` with your trusted native model configuration. +For a Responses-compatible model endpoint, the existing Codex adapter accepts: + +```json +{ + "codex_provider": { + "name": "Configured model provider", + "base_url": "https:///v1", + "bearer_token": "", + "wire_api": "responses" + } +} +``` + +Keep both files mode 0600 outside the extracted package. The model endpoint must +be reachable from the Runtime; a host loopback address is not the container's +host. Core copies these options into trusted execution preparation without storing +them in public Session configuration. Never put model credentials in Agent +instructions, public requests, image layers or a workspace. Configuration changes +require a Core restart. Leave remote executor URLs unset for this hosted profile. + +```sh +chmod 0600 "$AGENTS_API_KEYS_FILE" "$AGENTS_API_MANAGED_RUNTIMES_FILE" "$AGENTS_API_EXECUTION_OPTIONS_FILE" +"$AGENTS_API_BIN_DIR/agents-api-migrate" +"$AGENTS_API_BIN_DIR/agents-api" +``` + +Use your existing service supervisor for long-running operation. Migrations are +explicit. One Core execution worker owns each database; replicas do not provide +execution HA. `GET /healthz` checks liveness, not successful sandbox preparation. + +## Execute through the public client + +Use the pinned client described in `README.md`, with `OPENAI_BASE_URL` set to +`http://127.0.0.1:8091/v1` and `OPENAI_API_KEY` read from the private caller key. +The public model name must match the trusted endpoint's supported model: + +```python +import base64 +from openai import OpenAI + +client = OpenAI() +session = client.beta.agents.sessions.create( + agent={"model": ""}, + environment={"type": "openai_hosted"}, + input="Create /workspace/result.txt containing a short greeting.", +) +print(session.id, session.environment.id) +``` + +Core provisions the Runtime automatically. Retrieve the Session and list its Turns +and Items to observe execution; wait for the Turn to complete before a file upload. +A connected Environment confirms the daemon transport, not native readiness. +Keep the Session ID for all subsequent operations: + +```python +print(client.beta.agents.sessions.retrieve(session.id)) +print(client.beta.agents.sessions.turns.list(session.id)) +print(client.beta.agents.sessions.items.list(session.id)) +print(client.beta.agents.environments.files.list(session.environment.id, path="/workspace")) +client.beta.agents.environments.files.create( + session.environment.id, + type="inline", + path="/workspace/input.txt", + data=base64.b64encode(b"Read these exact bytes.").decode(), +) +with client.beta.agents.sessions.stream( + session.id, input="Read /workspace/input.txt with the native shell and quote it." +) as stream: + for event in stream: + print(event.type) +``` + +Cancellation of an accepted/running Turn uses the same public Session: + +```python +client.beta.agents.sessions.events.create( + session.id, events=[{"type": "agent.session.input.cancel"}] +) +``` + +Query until the Turn is terminal; a cancellation request alone is not completion. +Pre-Turn reservation cancellation remains a recorded gap. Reconnect clients after +Core restart and recover through Session/Turn/Items; SSE is live and does not replay +history. Preserve Runtime containers and both owned volumes for native history and +workspace continuation. Do not turn an uncertain interrupted execution into a new +request or delete native history to make a retry succeed. + +When finished, `client.beta.agents.sessions.delete(session.id)` requests owned +Runtime cleanup. Core must remain running with the original Provider configured +until its labelled container and volumes are gone. Public deletion acknowledgment +is not physical cleanup confirmation. Do not use broad Docker pruning. An empty +`default_provider` disables new hosted admission while keeping existing Session +controls and cleanup available. Back up the independent PostgreSQL database +(including large objects) and retained Runtime state together under an operator +recovery plan; the archive itself contains no deployment data. + +## Acceptance limits + +The qualified basic profile covers public creation, native execution, inline and +source-file copies/listing, cancellation, retained-history restart and owned +cleanup. Network access defaults to enabled; explicit disabled confines native +tools while the trusted harness retains model/Core connectivity. Restricted domains, +populated startup installations/templates, hosted MCP combinations, Artifacts and +complete protocol parity remain open. Files size and directory bounds are local +implementation limits, not verified upstream limits. + +User-managed deployment will reuse this Runtime, but installation/enrollment and +its official protocol mapping are separate work. It is not automatically official +`self_hosted`. This package does not install Docker/PostgreSQL/TLS/a supervisor, +publish an image, migrate product execution, add engines or introduce another +execution topology. See the [versioned coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/contracts/agents-api/README.md). diff --git a/services/agents-api/README.md b/services/agents-api/README.md new file mode 100644 index 000000000..6eebe57ec --- /dev/null +++ b/services/agents-api/README.md @@ -0,0 +1,712 @@ +# Agents API + +Independent execution service implementing part of the pinned OpenAI Agents API. +It owns reusable Agents, durable Sessions/Turns/Items, live events, function actions +and a daemon execution worker. Public execution supports qualified Codex, Claude Code +(`claude_sdk`) and MiniMax Code (`mcode`) profiles through the shared Runtime contract. +The three-harness Linux amd64 Docker V1 MVP and the separate +[E2B V1 deployment](deploy/e2b/README.md) qualification are accepted. +It builds and runs with its own PostgreSQL database and credentials; +Parsar's product service, frontend and database are not required. + +Use this guide to build, configure and connect a client. The +[protocol coverage](../../contracts/agents-api/README.md) lists supported operations, +engine limits, acceptance evidence and missing resources. The target remains the +complete pinned protocol; current workflows do not establish full compatibility. +Parsar product execution and its eventual public-client cutover are separate. + +## Reusable Agents + +Static-bearer Vault Credentials support creation, token replacement, deletion and +safe metadata retrieval/listing. Vault deletion atomically removes its Credentials. +Configure their independent encryption key and authenticated +Session use through the [credential guide](credentials.md). OAuth remains a +separate implementation gap. + +The pinned Python client can save configuration independently of execution: + +```python +agent = client.beta.agents.create(model="your-model", name="Example") +session = client.beta.agents.sessions.create( + agent_id=agent.id, environment={"type": "none"}, +) +``` + +These resources belong to the authenticated execution tenant. Saving configuration +does not launch an engine. Optional Session `agent` fields override the saved +configuration: omitted fields inherit, supplied objects/arrays replace whole fields. +Source and Session metadata stay separate; execution never looks up the source again. + +- Retrieve with `client.beta.agents.retrieve(agent.id)`; list saved resources with + `client.beta.agents.list(limit=20, order="desc")` and SDK auto-pagination. +- Update with `client.beta.agents.update(agent.id, instructions="New instructions")`. + Omitted fields remain unchanged. Metadata replaces all pairs; null/empty clears it. + Existing Sessions retain their configuration; new Sessions resolve the update. +- Delete with `client.beta.agents.delete(agent.id)`. Existing Sessions and history + remain available. New references fail; recorded creation retries recover their + accepted snapshot without consulting the deleted source. +- List Sessions with `client.beta.agents.sessions.list(agent_id=agent.id)`. Filtering + uses the immutable root ID, including inline Agents and history after source changes. + +See the [configuration and retry limits](../../contracts/agents-api/README.md#public-semantics) +before relying on optional settings or hosted error/default equivalence. + +## Build standalone binaries + +```bash +make build-agents-api +# Optional absolute output directory: +AGENTS_API_BUILD_DIR="$HOME/.parsar/build/agents-api-test" make build-agents-api +``` + +The default output is `${PARSAR_HOME:-$HOME/.parsar}/build/agents-api`: + +- `agents-api`: HTTP service and execution worker. +- `agents-api-migrate`: this service's embedded database migrations. +- `agents-api-device`: operator device provisioning and revocation. +- `agents-api-environment-key`: principal executor key issuance, rotation and revocation. + +Use these executables in place of the corresponding `go run` commands below. +The build needs Go and access to its pinned module dependencies; it does not need +Node, Docker, the product service or frontend. An isolated source context enforces +that boundary on every build. [Contributor rules](../../CONTRIBUTING.md#independent-build-artifacts) +define the allowed shared packages and required checks. Runtime database/key +configuration and a separately installed execution daemon are still required; +these binaries do not establish full protocol coverage. For a standalone Linux +container, see [Container deployment](CONTAINER.md). + +`make build-agents-api-release` packages the same four commands in a versioned +Linux amd64 archive, with source/protocol identity, checksums, a license and +[operator instructions](RELEASE.md). Build from a clean Git worktree with Go and +Python 3.9+; output defaults to `~/.parsar/build/agents-api-release` (or +`AGENTS_API_RELEASE_DIR`). The extracted API needs no source checkout or compiler. +For a Docker-hosted package, first qualify an immutable Linux amd64 Runtime built +with [the existing Runtime builder](deploy/codex/README.md), then run: + +```sh +AGENTS_API_RELEASE_RUNTIME_IMAGE=sha256: make build-agents-api-release +``` + +The resulting `agents-api-docker--linux-amd64.tar.gz` also contains the +Runtime image export, committed seccomp policy and [hosted guide](HOSTED-RELEASE.md). +Consumers load the included image and start the extracted Core; no source checkout +or compiler is needed. The builder records the selected image ID and file hashes; +the exact Core/Runtime combination still needs deployment acceptance. The ordinary +archive remains Docker-free. Database/Docker setup and publication remain separate. + +## Database ownership + +Use a dedicated PostgreSQL database and account, separate from the Parsar product. +This service does not import `server/internal` or apply product migrations. +Migrations are embedded and tracked in `agents_api_schema_version`. + +```bash +AGENTS_API_DATABASE_URL='postgres://.../agents_api' \ + go run ./services/agents-api/cmd/migrate +``` + +The public `Idempotency-Key` creation header is optional: omission creates a new +Session. A supplied key identifies the request within its authenticated tenant. +Inline retries use normalized effective configuration; new saved-Agent references +record caller intent independently of later source updates/deletion. Retries do +not admit initial input again. Every retry must match the original typed creator, +including across key rotation. Another principal using the same project/key gets +the local 409 conflict. Records with a known creator but no recorded request intent +retain resolved-snapshot behavior; records without a creator cannot be retried. +These retry policies are not verified hosted semantics. See the +[retry boundary](../../contracts/agents-api/README.md#public-semantics). + +The Store uses internal creation keys and preserves immutable engine/configuration, +native continuity and same-tenant device bindings. The public API applies schema +validation/defaults before storage. Internal bounds are 64 KiB for metadata and +512 KiB for configuration. Keep credentials out of both. Public metadata permits +at most 16 string pairs, 64-character keys and 512-character values; storage bounds +do not replace those rules. Tenant identity comes from authenticated credentials, +never metadata or a caller-supplied business identity. + +## Internal Turn persistence + +Validated message/cancel/function-result batches commit under a tenant-scoped +Session lock. Messages start a Turn when idle and steer active work. Retry keys +identify the whole ordered batch; an invalid event does not partially admit it. +Queued cancellation needs no live engine. Active cancellation awaits a native +outcome, and completion may win the race. Terminal states cannot be overwritten. + +A Session keeps its effective configuration, engine and device across Turns; +product Conversations, native Sessions, connections, processes and sandboxes are +different objects. Strict native resume requires retained history on that device. +The API owns durable public history and pending function decisions; adapters own +native translation and their harness owns the model/tool loop. + +The worker uses its database lease connection for execution writes. Lease loss +fences those writes; it does not prove native commands or side effects have stopped. +Restart conservatively fails previously claimed work and retains queued work. +Uncertain delivery is never blindly replayed. Function actions and live SSE are +available within the [current coverage](../../contracts/agents-api/README.md); +other pending interactions, process-loss recovery and environment lifecycle remain +incomplete. Durable acceptance is not an exactly-once side-effect guarantee. + +## Standalone HTTP service + +Run migrations first, then `go run ./services/agents-api/cmd/server`. The service +requires `AGENTS_API_DATABASE_URL` and `AGENTS_API_KEYS_FILE`; it does not read the +product database or accept product login cookies. The key file is a JSON array: + +```json +[{ + "tenant_id": "", + "organization_id": "", + "project_id": "", + "subject_kind": "service_account", + "subject_id": "", + "token_sha256": "" +}] +``` + +Use `subject_kind: "user"` for a user principal. IDs are explicit operator-assigned +execution identities, not inferred from Parsar users or existing Session records. +Each key authorizes one project; multiple keys and principals may share that +project's tenant UUID. Startup atomically verifies the immutable organization/project +to tenant mapping before serving traffic or starting execution. Conflicts abort +startup without committing a partial configuration. Removing keys leaves those +mappings intact. Existing key files must be updated explicitly; incomplete legacy +bindings are rejected. This does not assign ownership to historical Sessions. + +Provision random bearer keys and share plaintext only with authorized callers; +keep digests in the server file. Rotate or revoke by changing bindings and +restarting the service. Keep the same principal IDs when rotating a caller's key. +Optional `OpenAI-Organization` and `OpenAI-Project` headers must match its binding; +repeated or conflicting values fail authentication. These identities do not grant +product-user rights. New Sessions persist the authenticated creator kind/ID +atomically and never change them on retry. Project resource visibility and mutation +authorization are unchanged. Historical Sessions keep unknown creators and remain +readable; no key, metadata or product record can assign their ownership through a +retry. Retire older API writers before starting this deployment; mixed-version +creation is unsupported. Executor keys separately match this recorded creator before authorizing an +Environment connection; they do not inherit general caller API permissions. + +`AGENTS_API_ADDR` defaults to `127.0.0.1:8091`; use a TLS reverse proxy for remote +access. `AGENTS_API_ENGINE` defaults to `codex`; use `claude_sdk` for Claude Code +or `mcode` for MiniMax Code. Configure the corresponding qualified Runtime through +its [deployment guide](../../contracts/agents-api/README.md#public-engine-profiles). +It selects new Sessions independently of the requested +model. Existing Sessions retain their stored engine. + +The SDK base URL is `http://127.0.0.1:8091/v1`. Requests require a bearer key. +Agents and Vault routes also require `OpenAI-Beta: agents=v1` (set by their SDK +resources); general Files routes do not. Supported operations include: + +- Saved Agent create/retrieve/update/list/delete. +- Session create/retrieve/list/delete and metadata-only update. Creation supports inline + configuration or a saved `agent_id`, field replacements, optional initial text + and ordinary or streaming responses. +- Session event submission and live streaming, Turn retrieve/list and Items list. +- Environment retrieve for supported Codex self-hosted and three-harness Docker/E2B + profiles, bounded live file listing, and inline/source copies into qualified + local workspaces. Shared Artifacts support capture, list/retrieve/content and + deletion independently of the live Runtime after publication. +- Project-owned `user_data` source file upload/list, metadata/content retrieval and + deletion; see [source Files](../../contracts/agents-api/source-files.md). +- Vault create/retrieve/list/delete, project-scoped pagination and stored status + filtering; static-bearer Credential create/retrieve/list/token replacement/delete. + Public archive semantics and OAuth remain gaps. Already-delivered credentials + are not withdrawn by local deletion. Session attachments support + [authenticated HTTPS MCP](credentials.md#use-a-credential-in-a-session). + +Execution uses the selected +[engine profile](../../contracts/agents-api/README.md#public-engine-profiles), +including `none` and the Codex self-hosted idle-text profile described below. +Ordinary JSON requests have a 1 MiB body limit; file transfers use the separate +bounds in the Files contracts. Session lists support `after`, `limit` (1..100), +`order` (`asc`/`desc`) and optional immutable root `agent_id`. The local defaults +are 20 and descending order; exact hosted limits/error semantics remain unverified. +Metadata updates preserve omission, clear on null/empty and replace supplied pairs. + +Delete with `client.beta.agents.sessions.delete(session.id)`. Confirmation means +public removal: Session/history reads and new input become unavailable. Active +work receives a cancellation request; existing streams close on observing removal. +Already claimed work may still complete. Creation keys stay reserved; deletion +never affects other Sessions, saved Agents or their shared device. Internal records +are retained for execution settlement. Managed Docker/E2B deletion separately revokes +authority and reclaims owned compute/workspace/history; caller-managed compute +is not reclaimed by this service. Local repeated deletion returns 404 and creation-key reuse returns +409; exact hosted errors and overlapping stream timing are unverified. + +Codex command Items support live `agent.output.command_execution_output.delta` +events when emitted by the connected daemon. Queries retain accumulated drafts and +authoritative completion snapshots, including observed partial output after +cancellation. Native text conversion/output quotas apply; older peers may provide +only completion snapshots. Pinned native 0.153.4 may also omit early process +output from both notifications and its final aggregate; this remains an upstream +execution gap. Recover missed output with Items queries, not SSE replay. + +Non-text message input, Subagents and installations beyond hosted initial files, +env, ordered setup and npm/Python packages remain unsupported. Saving optional Agent configuration does not make +it executable. Unsupported requests fail explicitly. `/healthz` reports liveness only. + +## Managed hosted execution + +The basic `openai_hosted` profiles for Codex, Claude Code and MiniMax Code require +explicit operator configuration. Select the qualified native image using the +[engine profile guides](../../contracts/agents-api/README.md#public-engine-profiles), +then follow the [Docker setup](deploy/codex/README.md#standalone-operator-configuration) +or [E2B template/provider setup](deploy/e2b/README.md). +Core remains independently deployed with its own database. Public idle and initial +text Sessions share the existing preparation, execution, Files and recovery paths. +Networking defaults to enabled; disabled is also supported after setup completes. +Restricted domains, system packages and remaining unsupported startup installations +remain gaps. Initial inline/file_id files, confidential env, npm/Python packages, +ordered setup and [public Environment Templates](../../contracts/agents-api/environment-templates.md) +resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses +require separate integration and qualification. +Connected describes the authenticated Runtime connection, not native readiness. +Exact hosted failure/expiry semantics remain unverified. + +## Internal execution device connection + +The standalone service can accept existing daemon connections without a Parsar +workspace or product database. Enable its internal gateway by setting +`AGENTS_API_DAEMON_WS_URL=wss://your-service/api/v1/agent-daemon/ws` (use `ws` +for local development). This is separate from the official Agents API executor +contract; do not return this URL as a public `self_hosted` environment's remote URL. + +After migrations, an operator can provision a device for an execution tenant: + +```bash +umask 077 +mkdir -p ~/.parsar/parsar-daemon/agents-api +go run ./services/agents-api/cmd/device \ + --tenant '' --name 'local executor' \ + --url 'http://127.0.0.1:8091' \ + > ~/.parsar/parsar-daemon/agents-api/auth.json +parsar-daemon connect --profile agents-api +``` + +The command requires `AGENTS_API_DATABASE_URL` and emits a secret profile once. +Use a new profile rather than overwriting an existing device's credentials. When +provisioning remote compute, securely transfer this file to the same profile path +on the executor. The database stores only the credential digest. API keys and +device credentials are not interchangeable. To revoke a device: + +```bash +go run ./services/agents-api/cmd/device \ + --tenant '' --revoke '' +``` + +An existing connection is retired on its next heartbeat; new connections are +rejected immediately. The internal Store binds each Session to one same-tenant +device, preserves that assignment across retries/restarts, and refuses a silent +move to another device. Revoked bindings cannot be used for dispatch. Device +connections alone do not start a Turn. Submit text, cancellation or function results +through the official Session events endpoint; the worker assigns a same-tenant host and preserves that +binding. Managed Docker/E2B lifecycle is qualified within the three-harness V1 profiles; +additional provider qualification and full protocol semantics remain separate. See the [ownership rules](../../CONTRIBUTING.md#product-and-execution-service-separation). + +### Enable Claude SDK execution + +Build and extract the runtime archive into a fresh managed directory on a matching +executor host. The archive contains the compiled bridge and pinned production +SDK/MCP/native dependencies; Node is installed separately. Linux x64/glibc with +Node22 is the accepted platform. See the +[runtime artifact contract](../../CONTRIBUTING.md#private-claude-sdk-runtime-artifact) +for build outputs, version checks and platform restrictions. + +```bash +make build-claude-sdk-runtime +# After extracting the matching archive into this operator-chosen directory: +export PARSAR_CLAUDE_SDK_ENTRYPOINT="$HOME/.parsar/runtimes/claude-sdk/dist/main.js" +export PARSAR_CLAUDE_SDK_NODE="/absolute/path/to/node" +parsar-daemon connect --profile agents-api +``` + +Set `AGENTS_API_ENGINE=claude_sdk` on the API service. Configure provider access in +the daemon's private native SDK environment. Runtime readiness checks versions and +startup before daemon registration; it does not validate provider credentials. +SDK state stays under the daemon profile, independently of the replaceable bundle. +A ready SDK can start the daemon without a legacy CLI. Product `claude_code` remains +separate. Managed Node installation, runtime activation and registry publication +are not supplied by these commands. + +## Official client verification + +After preparing a dedicated test database, build the server and verify it with +the official client installed from the commit in `contracts/agents-api/upstream.json`: + +```bash +python -m pip install -r services/agents-api/tests/requirements.txt +make build-agents-api +AGENTS_API_SERVER_BIN="${PARSAR_HOME:-$HOME/.parsar}/build/agents-api/agents-api" \ + python services/agents-api/tests/official_client.py +``` + +The test uses `PARSAR_AGENTS_API_TEST_DATABASE_URL`, temporary service keys and +fresh tenant IDs. The suite checks upstream and generated response schemas, retries, +ordering, tenant isolation, unsupported options and reads after a process restart, +without a model provider. It also runs the +[official Go client integration](../../packages/agents-client/README.md), using two +fresh tenants, and validates its created Sessions through the Python SDK. + +## Checks + +```bash +PARSAR_AGENTS_API_TEST_DATABASE_URL='postgres://.../parsar_agents_api_local_tests' \ + make check-agents-api +``` + +Set `PARSAR_OFFICIAL_SDK_PYTHON` to the fixed SDK interpreter for the Store client +fixtures, and run the separate official-client command above as well. +`TestEnvironmentRetrievalOfficialClient` verifies public creation, scoped safe +Environment reads and retrieval after reopening without execution configuration. +`TestEnvironmentInitialFailureOfficialClient` verifies failed Session reads and +matching SDK/raw live failure events after privately provisioned initial input +expires, without fabricating a Turn or changing the Environment status. It is a +persistence prerequisite test. `TestSelfHostedInitialCreationOfficialClient` +separately exercises ordinary/streamed public initial creation through the Worker, +retry identity, disconnect survival and explicitly controlled deadline failure. +The test database must be named `parsar_agents_api_*_tests` and contain no product +workspace tables. Tests apply only this service's migrations and use new tenant +IDs without truncating tables. Missing test configuration skips DB tests locally; +the `agents-api` CI workflow always supplies its own PostgreSQL service. Run the +full `make check` before review as well. Product OpenAPI generation excludes this +service; its supported HTTP contract is generated separately. + +## Public text execution + +With the daemon gateway enabled, the service owns one worker per execution database +and processes up to four Turns concurrently. Other workers are rejected by a +PostgreSQL advisory lock. A disconnected host leaves unsent work queued; clients +may cancel it. Session/Turn/Items queries expose durable results. + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://127.0.0.1:8091/v1", api_key="") +session = client.beta.agents.sessions.create( + agent={"model": ""}, + environment={"type": "none"}, +) +client.beta.agents.sessions.events.create( + session.id, + events=[{"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Hello"}]} + ]}], + idempotency_key="first-message", +) +``` + +Configure model access in the engine host's native configuration. API tenant keys +and daemon credentials authenticate this service, not a model provider. Never put +provider secrets in Session metadata. This path does not enable Parsar Skill/SP +callbacks or bypass the pending product authorization work. + +Session creation also accepts `input="Hello"` to admit initial text atomically and +`stream=True` for created/live events. Open a GET event stream before submitting +later work, or use the official `sessions.stream` helper for one Turn. Function +handlers return results through the same public events endpoint. Recover missed +output with Session/Turn/Items queries; reconnecting SSE does not replay history. +See [creation streaming](../../contracts/agents-api/README.md#session-creation-streaming) +for retry behavior and unverified hosted timing. + +The [accepted workflows](../../contracts/agents-api/README.md#acceptance-evidence-and-remaining-scope) +include real MiniMax execution through built API/daemon/Codex and Claude SDK, +function success/error, cancellation and native continuation. Controlled fixtures +remain useful but do not replace real-provider acceptance for execution changes. + +## Upgrading archived Item history + +Migration 15 retires private journal-to-Item backfilling. It preserves existing +public Items and source journals, and refuses to apply if any Turn still has +`items_indexed=false`. Do not set this marker manually or replay native execution. + +For installations with pre-Items history: + +1. Back up the execution database and stop new execution/submission. Drain active + Turns before switching versions. Product storage is independent. +2. Run the previous service release `906069e` against the execution database with + its worker disabled (omit `AGENTS_API_DAEMON_WS_URL`). Using each tenant's API + credential, list every Session and request its Items once. The old service + prepares the complete index under the Session lock, even with `limit=1`. +3. Verify `SELECT count(*) FROM turns WHERE NOT items_indexed` returns zero. + A failed preparation must be resolved before upgrade; legacy journals cannot + recover fields they never recorded. Stop the previous service. +4. Upgrade the daemon first so it advertises `tool_observations`, then apply the + migrations and start the new service. No old/new service overlap is supported + across this migration. Devices without this capability are not dispatched. + +For step 2, use the pinned Python SDK and the usual private endpoint/key settings, +repeating with each operator-configured tenant identity: + +```python +from openai import OpenAI + +client = OpenAI() # OPENAI_BASE_URL and OPENAI_API_KEY +for session in client.beta.agents.sessions.list(): + client.beta.agents.sessions.items.list(session.id, limit=1) +``` + +Fresh installations and already indexed history need no backfill. Recovery reads +continue to use Session/Turn/Items; this procedure is an upgrade operation, not +an official SSE replay mechanism. + +## Native executor transport prerequisite + +The disabled-by-default Codex adapter supports executor registration, harness key +authorization and an opaque native Noise relay. Configured execution and an +executor origin enable public `self_hosted` Sessions on Codex. The current +profile requires an absolute `workspace_directory`, empty/default +`capability_directories`, with optional supported non-deferred functions and +service-origin HTTP MCP with optional attached static Bearer credentials. Initial +text is optional. + +To enable it alongside the existing daemon worker, set +`AGENTS_API_EXECUTOR_URL` to the externally reachable HTTPS origin. Apply the +execution migrations first and start the service once with configured caller +principals to establish its immutable project mappings. Operator database authority +can then issue a connect-only principal key before any Session exists: + +```bash +umask 077 +agents-api-environment-key --tenant "$TENANT_ID" \ + --organization "$ORGANIZATION_ID" --project "$PROJECT_ID" \ + --subject-kind service_account --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" \ + > "$HOME/.parsar/executor-key.json" +``` + +`KEY_ID` is a new canonical nonzero UUID chosen for management and retained by the +operator. Use `--subject-kind user` for a user principal. All identities must be +explicit and match an existing verified project mapping; issuance never creates +or remaps that association. `AGENTS_API_DATABASE_URL` points to the execution DB. +Optionally add `--environment "$ENVIRONMENT_ID"` at issuance to restrict this key +to one existing live Environment with the same recorded Session creator. + +JSON output contains `key_id`, `executor_token`, and `environment_id` only for a +restricted key. Stdout is its only delivery; the +[separate native launcher](../../packages/codex-executor/README.md) consumes this +private file directly. There is no chosen-token import or secret read-back. +Ordinary issuance rejects any existing management ID, including revoked IDs. +Lost output requires explicit `--rotate` with the same full principal and key ID; +`--revoke` invalidates the key without output. Neither operation accepts an +Environment override or changes the key's principal/restriction. Rotation of a +restricted key requires its live owning Session; revocation remains possible after +deletion. Replace the private file and restart executors after rotation. + +The database stores the current digest, immutable typed subject/project partition, +optional restriction, creation/issuance times and revocation marker. Authorization +requires the target Session's project and recorded creator to match. A principal +key can serve multiple matching Sessions; deleting one denies that target without +revoking access to the others. Unknown historical creators never authorize an +executor. Caller, daemon, harness and executor keys have distinct purposes. +Executor keys have no five-minute grant expiry. A restart preserves keys but +invalidates registrations and URL grants, requiring re-registration. Current-key +checks apply to requests and upgrades; authorization heartbeats close existing +pairs every five seconds with a four-second check budget. Connection closure does +not establish native process quiescence. + +**Principal-key cutover:** stop older registries and operator writers, apply +migration 26, and deploy the updated service, issuer and launcher together. Legacy +digests, Environment restrictions and issuance times remain, but keys are revoked +and their principals remain unknown. Their Environment UUIDs remain reserved as +management IDs; they cannot be claimed or rotated into principal keys. Explicitly +issue new keys with new IDs, replace old files and restart executors. Never infer +ownership from old keys or product data. Downgrade refuses to discard principal-key +identities and never undoes legacy revocation. The retired static executor-key +setting remains rejected; there is no old/new authentication fallback. + +Harness credentials are issued internally for an execution owner after checking +the current execution lease and exact tenant/Environment ownership. The registry +holds only bounded process-local digests. The owner retains its credential through +preparation and the transferred Run, then releases it; cancellation and service +shutdown also revoke access and close that credential's pair. Connection tickets +still expire after five minutes, independently of the active owner's lifetime. +See the [canonical ownership rules](../../CONTRIBUTING.md#environment-ownership-and-placement). + +**Transition from static harness keys:** stop the old registry, remove +`AGENTS_API_HARNESS_KEYS_FILE`, retire its secrets/files and restart. That setting +now fails startup rather than retaining a static fallback. With the daemon gateway +and executor URL configured, the service Worker issues and releases these credentials +for pending Environment inputs, selecting a capable tenant device once for an +unbound Session and retaining existing bindings. There is no public harness-key endpoint or user/service-account +identity equivalence. Caller, device, executor and harness credentials stay separate. + +Native routes live outside `/v1/agents`: `POST /cloud/environment/{id}/register` +uses the executor credential; `/connect` uses the harness credential and `/validate` +uses the executor credential. The returned WebSocket URLs carry separate connection +capabilities. Keep URLs and `harness_key_authorization` private; redact query +strings in external access logs. This native adapter does not expand the pinned +public SDK OpenAPI surface. HTTP is +allowed only on loopback for development. Production TLS termination remains an +operator responsibility and requires deployment validation. + +Authenticated socket observations now persist connection state and immutable +Environment event snapshots. These observations also back resource status reads, +without establishing native readiness. Registration replacement +and numbered callbacks fence old observations; a new Worker reconciles previous +process state before opening connections. Shutdown drains observations before +releasing execution ownership. Persistence failures close the registry and require +a service restart; review its lifecycle error logs rather than treating closure as +a successful write. See the [lifecycle rules](../../CONTRIBUTING.md#environment-ownership-and-placement). + +When the executor origin is configured, Session GET/list/metadata and live SSE can +expose `self_hosted` Sessions through a safe output projection. Waiting input requests `environment_connection` before any Turn; +connection arrival clears the action, and the existing Worker still verifies +native readiness before admission. No waiting input means no connection request. +The returned `remote_url` is the configured executor origin. Use that exact URL and +Environment ID with the [pinned caller-started launcher](../../packages/codex-executor/README.md). +Later idle text-message batches wait for durable preparation/admission before the +input endpoint returns 204, even if the executor is already connected. Set client +and proxy timeouts above five minutes; the service gives this response six minutes. +Explicit retry keys preserve the original input identity and deadline. A disconnected +HTTP observer does not cancel the reservation. Local expiry/cancellation errors are +409 `environment_input_expired` / `environment_input_cancelled`; ownership loss is +503 `execution_unavailable`. Exact hosted status/body parity remains unverified. +Initial text on ordinary or streamed creation commits its reservation and returns +the connection target promptly while offline. Connect using that target; the same +Worker prepares and starts the initial Turn. A disconnected creation stream does +not cancel the reservation. Initial expiry leaves a queryable failed Session with +a safe error and no Turn; exact hosted error/timing parity remains unverified. +Cancellation-only events use the existing durable receipt path, including idle +no-Turn requests and retries that never retarget later work. A new cancellation +still conflicts with pending pre-Turn input. HTTP 204 acknowledges admission; +observe completion through events/queries and process cessation separately. +Function definitions use the existing callback bridge. Submit result-only batches +with explicit Turn/call identities; they create no Turn or preparation and retain +the same identities on retries after completion or during later work. Pending +reservations still block new results. Observe native application through the +existing actions, Items and events; admission alone does not acknowledge application. +Message-only batches append to an existing active Turn or reserve idle work under +the same Session lock. Active input returns after durable admission and uses the +existing native steering receipts; retries keep their original Turn after completion +or during later work. No unlocked activity check can bypass idle preparation. +Mixed events, deferred functions, +nonempty capability directories, other placements, populated installation metadata +remain unavailable in this self-hosted profile. Public Environment Templates apply only +to hosted Sessions; Files coverage is recorded in the shared contract assessment. + +Retrieve the returned Environment with +`client.beta.agents.environments.retrieve(session.environment.id)`. This read uses +durable status and the owning live Session's project authorization, even when +execution/registry configuration is disabled. Its required `files`, `plugins` and +`skills` arrays are empty for the supported configuration, which has no API-managed +installations. They do not list caller-prepared or model-created workspace files, +or report native capability discovery. Unsupported stored installation configurations +are rejected rather than reported as empty. The response contains no credentials, +private configuration or file contents. See the +[resource boundary](../../CONTRIBUTING.md#environment-ownership-and-placement). + +The adapter checks its execution lease and visible Environment on requests and +five-second heartbeats; deleted ownership, shutdown or lost ownership closes +connections. Re-registering replaces an old socket without allowing its late close +to clear the replacement. A live credential can register again after replacement. +No command replay or native process termination is promised. + +Each Environment currently accepts one independent harness connection. Multiple +native commands, processes and file operations share it. A second attachment +receives 409 without evicting the incumbent; `/connect` refresh remains +non-disruptive. Five-minute, one-use harness grants bind both keys and sockets to +the current registration. At most 32 grants are retained per registration; +expired unused grants are pruned, and exhaustion returns 429. Expiration prevents +new attachment/validation without terminating an established pair. + +The relay forwards binary frames unchanged, bounded to the native 256 KiB limit. +A stalled write closes the pair after five seconds, without an application queue. +Either peer disconnecting closes both physical sockets and invalidates outstanding +harness grants. Native recovery may resume a retained Session/process; the service +does not restart commands. Multiplexing independent harnesses, durable native +backup, deployment TLS and arbitrary interrupted-work recovery remain unverified. + +The [native probe](tests/native/relay_probe.rs) exercises commands, a 128 KiB file, +refresh, one retained process across a controlled outage, and fresh file retention. +Build it as the `parsar_relay_probe` example in the pinned Codex Rust workspace +(`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`), with its matching lock/dependencies. +The release source's workspace version labels may need alignment to its manifests; +do not change third-party dependencies. Keep build/runtime files under `~/.parsar/`. +Run `TestNativeHarnessRelayPostgreSQLAndProcessRecovery` with the dedicated execution +test DB, `PARSAR_CODEX_BINARY` (0.153.4), `PARSAR_NATIVE_RELAY_PROBE` (that example) +and `PARSAR_EXECUTOR_PROOF_DIR` (private output directory). Without those native +prerequisites that test skips; the regular authorization/relay/Store checks still +run. This transport proof makes zero model calls; public model execution through +Environment remains a separate required acceptance workflow. + +The pinned Codex 0.153.4 CLI accepts registry API-key authentication on loopback but +protects OpenAI credentials from third-party production domains. The separately +named [upstream-library launcher](../../packages/codex-executor/README.md) provides +an explicit service-credential path. It keeps the stock guard intact and does not +establish the documented stock command on an arbitrary production domain. +See [Environment contracts and remaining work](../../contracts/agents-api/environments.md). + +### HTTP MCP execution + +MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}` +or a `self_hosted` Environment; Claude SDK supports HTTP MCP with +`environment:{"type":"none"}`. Inline or saved Agent tools may declare: + +```json +{ + "type": "mcp", + "server_label": "tickets", + "transport": {"type": "http", "server_url": "https://mcp.example.com/mcp"}, + "connection_origin": "service", + "allowed_tools": ["lookup_ticket"], + "required": false +} +``` + +An omitted/null `allowed_tools` permits all tools from that server; `[]` permits +none. Native discovery may still connect to a declared server. The daemon must +advertise `mcp_http_tools`; selection waits for a capable device. The native +harness owns MCP discovery, calls and results. Public `mcp_call` Items use original +server/tool names; recover missed live events through Session, Turn and Items reads. + +With Codex, set `required:true` to require initialization before the first native Turn. It +defaults to false and additionally requires the pinned daemon's `mcp_http_required` +capability. Native root thread creation and cold resume wait for required servers; +initialization failure stops execution without replacing retained history. Public +work can already be accepted or queued during this wait. Exact hosted creation +timing/errors and continuing MCP health monitoring remain unverified. + +On `environment:none`, both Codex and Claude SDK support tenant-owned `vault_ids` +for static-bearer HTTPS MCP; Codex also supports the `self_hosted` combination. +An explicit +`credential_id` selects an attached credential for the exact HTTPS URL; omission/null +selects a unique matching credential, or stays anonymous if none matches. Ambiguity +fails before Session creation. Selection is frozen privately; the public tool keeps +the caller's original credential field. See [credential setup and limits](credentials.md). +Authenticated execution additionally requires `mcp_http_bearer_auth`; missing keys +or failed authorization/decryption never fall back to anonymous execution. + +With `self_hosted`, commands use the registered executor while MCP connections +remain on the trusted service harness. This combination additionally +requires `mcp_http_remote_environment` and the existing remote preparation +capabilities; separate MCP/remote support on an older daemon does not imply this +combination. A selected Vault credential also requires `mcp_http_remote_bearer_auth`; +older peers with only separate MCP/remote/bearer capabilities cannot receive it. +Secrets enter only the service native process environment, not the executor or +public/native history. Unmatched attached Vaults may retain an anonymous selection. +Both native remote readiness and MCP configuration +checks run before thread creation/resume. + +Claude SDK requires a packaged runtime that reports `mcp_http_tools`; the SDK +version alone does not qualify an older bundle. Its current profile +requires `required:false`, connected servers and static inventories. Server labels +accept ASCII letters, digits, underscore and hyphen, except reserved `functions`; +selected tool names additionally accept dots. Declared HTTP MCP tools compose with +host functions; undeclared servers, built-ins and subagents remain disabled. +Authenticated requests also require `mcp_http_bearer_auth`. The existing Vault +selection rules apply, including implicit exact-URL matches and immutable private +bindings. Per-server/per-launch environment references keep bearer values out of +native argv and stored state. A Vault with no matching credential may stay anonymous. +Anonymous requests suppress native OAuth/credential injection with a blank +Authorization header, without deleting native state. Servers rejecting that header, normalized +name collisions, changing inventories and original MCP metadata fidelity remain +gaps. Items retain the observed native JSON, which may differ from the original +MCP envelope. See the [Claude SDK profile](../../CONTRIBUTING.md#claude-sdk-adapter-foundation). + +The current subset rejects OAuth, inline authorization, nonempty headers or +request metadata, URL userinfo/query/fragment, implicit/other origins, stdio +and engines other than Codex/Claude SDK. The Codex adapter also +rejects reserved native labels and stored native MCP credentials. It verifies +exact effective MCP configuration before starting/resuming a native thread, +excludes undeclared servers and disables native apps/plugins. This runs on trusted +service compute; it does not provide filesystem isolation or guard against +concurrent operator configuration mutation. These limits are implementation gaps, +not changes to the pinned official protocol. diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md new file mode 100644 index 000000000..0556566b4 --- /dev/null +++ b/services/agents-api/RELEASE.md @@ -0,0 +1,167 @@ +# Agents API distribution + +This Linux amd64 package contains the independent API, embedded migrator and two +operator commands. It needs PostgreSQL. The Docker variant also includes the +qualified colocated Runtime image, its seccomp policy and `HOSTED.md`; use that +guide after the common API setup below. The basic archive needs separately +installed execution software. +It does not need a source checkout, Go, Node, the Parsar product or its database. +The [coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/contracts/agents-api/README.md) +describes supported workflows and remaining protocol gaps. Packaging does not +establish complete OpenAI Agents API compatibility. + +## Verify and extract + +Verify the archive checksum supplied alongside the package, then extract into a +new directory under `~/.parsar/`. Keep deployment configuration outside the extracted +package so replacing binaries does not replace credentials or state. + +```sh +sha256sum -c @ARCHIVE_NAME@.tar.gz.sha256 +mkdir -p "$HOME/.parsar/releases" +tar -xzf @ARCHIVE_NAME@.tar.gz -C "$HOME/.parsar/releases" +cd "$HOME/.parsar/releases/@ARCHIVE_NAME@" +sha256sum -c SHA256SUMS +export AGENTS_API_BIN_DIR="$PWD/bin" +``` + +`manifest.json` records the source revision/tree, target platform, fixed upstream +protocol and binary hashes. The package also includes its license. Checksums +detect changed bytes; obtain the archive and checksum from a trusted distributor. + +## Start a new API installation + +Provision a dedicated PostgreSQL database and account. Use neither the product +database nor its migrations. The following local example assumes an unused port +8091. For remote clients, place the API behind TLS and set both advertised URLs to +the corresponding reachable service addresses. + +```sh +umask 077 +export PARSAR_HOME="$HOME/.parsar/agents-api-deployment" +mkdir -p "$PARSAR_HOME" +export AGENTS_API_DATABASE_URL='postgres://:@/' +export AGENTS_API_KEYS_FILE="$PARSAR_HOME/keys.json" +export AGENTS_API_ADDR=127.0.0.1:8091 +export AGENTS_API_ENGINE=codex +``` + +Create `keys.json` with explicit trusted identities. Generate a random caller key, +save it separately in a private `caller.key` file, and put only its SHA-256 digest +in the API configuration. A tenant is a canonical nonzero UUID; organization, +project and subject IDs must remain stable across key rotation. + +```json +[{ + "tenant_id": "", + "organization_id": "", + "project_id": "", + "subject_kind": "service_account", + "subject_id": "", + "token_sha256": "" +}] +``` + +For the Docker variant, continue in `HOSTED.md` now to configure the Runtime's +outward connection and provider before starting Core. For the basic archive, +set the separately installed software's reachable endpoints: + +```sh +export AGENTS_API_DAEMON_WS_URL=ws://127.0.0.1:8091/api/v1/agent-daemon/ws +export AGENTS_API_EXECUTOR_URL=http://127.0.0.1:8091 +``` + +Keep the configuration and key files mode 0600. Run migrations explicitly, then +start the API in the foreground or through your existing service supervisor: + +```sh +"$AGENTS_API_BIN_DIR/agents-api-migrate" +"$AGENTS_API_BIN_DIR/agents-api" +``` + +`GET /healthz` provides liveness. Successful startup establishes the immutable +project mappings required by the operator commands. One API execution worker owns +each database; starting replicas does not provide execution HA. Native history +belongs to the harness host and must survive API replacement. + +## Connect execution software + +Keep the API running. In a separate operator shell with the same private database +configuration, provision a new daemon profile and an executor principal key using +the IDs from `keys.json`. `KEY_ID` is a new canonical nonzero UUID retained for +future rotation/revocation. The executor key can be issued before any Session. + +```sh +umask 077 +mkdir -p "$PARSAR_HOME/parsar-daemon/agents-api" +"$AGENTS_API_BIN_DIR/agents-api-device" \ + --tenant "$TENANT_ID" --name 'Agents API harness' \ + --url http://127.0.0.1:8091 \ + > "$PARSAR_HOME/parsar-daemon/agents-api/auth.json" +"$AGENTS_API_BIN_DIR/agents-api-environment-key" \ + --tenant "$TENANT_ID" --organization "$ORGANIZATION_ID" \ + --project "$PROJECT_ID" --subject-kind service_account \ + --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" \ + > "$PARSAR_HOME/executor-key.json" +``` + +Use new private files; do not overwrite an existing device profile or key output. +Install the daemon, matching native Codex 0.153.4 resources and +[native executor launcher](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/packages/codex-executor/README.md) +separately. In the daemon's own service environment, configure native model access +and run `parsar-daemon connect --profile agents-api` with the provisioned profile. +Transfer the profile securely if the harness runs on another host. The harness +must not inherit the operator database or caller credentials. Provider credentials +belong in its private native configuration, outside caller executor compute. + +## Use the public client + +Install the official Python client at the commit in `manifest.json` (SDK 3.13.0). +In a client process, set `OPENAI_BASE_URL=http://127.0.0.1:8091/v1` and supply the +private caller key as `OPENAI_API_KEY`. Create an empty self-hosted Session: + +```python +from openai import OpenAI + +client = OpenAI() +session = client.beta.agents.sessions.create( + agent={"model": ""}, + environment={"type": "self_hosted", "workspace_directory": "/workspace"}, +) +print(session.id, session.environment.id, session.environment.remote_url) +``` + +On caller-controlled executor compute, prepare `/workspace` and connect the +separately installed launcher using the returned target. Transfer only its scoped +`executor-key.json`; do not transfer caller, database, daemon or model credentials. + +```sh +agents-api-codex-executor --remote "$REMOTE_URL" \ + --environment-id "$ENVIRONMENT_ID" --credentials "$HOME/.parsar/executor-key.json" \ + --codex-bin /opt/codex/bin/codex +``` + +A directory or key binding does not isolate files or same-user processes. Use an +appropriate separate runtime when isolation is required. HTTP is accepted only +for loopback development; a remote executor needs the reachable HTTPS target. + +In the same Python client, stream a Turn after connecting the executor: + +```python +with client.beta.agents.sessions.stream( + session.id, input="Run a command in the workspace and explain its result." +) as stream: + for event in stream: + print(event.type) +``` + +Keep the Session ID for later Turns. After an API restart, reconnect the client +and recover through Session, Turn and Items queries; SSE does not replay history. +Reuse the database, caller identities, daemon profile and native history. Do not +resubmit uncertain execution as new work. Graceful shutdown or connection closure +does not by itself prove all native descendants have exited. + +For key rotation, device revocation, existing-database upgrades and other supported +profiles, use the [versioned service guide](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/services/agents-api/README.md). +This package does not install PostgreSQL, daemons, harnesses, TLS or a supervisor, +and it does not switch Parsar's product execution path. diff --git a/services/agents-api/cmd/device/main.go b/services/agents-api/cmd/device/main.go new file mode 100644 index 000000000..c7bba8edd --- /dev/null +++ b/services/agents-api/cmd/device/main.go @@ -0,0 +1,72 @@ +// Command device provisions or revokes an execution device using operator DB access. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("execution device provisioning failed", "error", err) + os.Exit(1) + } +} + +func run() error { + tenant := flag.String("tenant", "", "execution tenant UUID") + name := flag.String("name", "", "device label") + serverURL := flag.String("url", "", "Agents API HTTP base URL") + revoke := flag.String("revoke", "", "revoke this device UUID instead of provisioning") + flag.Parse() + dsn := os.Getenv("AGENTS_API_DATABASE_URL") + if dsn == "" || *tenant == "" || flag.NArg() != 0 { + return errors.New("AGENTS_API_DATABASE_URL and --tenant are required") + } + if *revoke == "" { + u, err := url.Parse(*serverURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return errors.New("--url must be an absolute http(s) base URL without a path or credentials") + } + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return errors.New("invalid execution database configuration") + } + defer pool.Close() + s := store.New(pool) + if *revoke != "" { + return s.RevokeDevice(ctx, *tenant, *revoke) + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + credential := base64.RawURLEncoding.EncodeToString(secret) + registered, err := s.CreateDevice(ctx, *tenant, *name, device.HashCredential(credential)) + if err != nil { + return err + } + // Emit the existing daemon profile shape. Operators redirect this secret to a + // mode-0600 auth.json under ~/.parsar; it is never included in diagnostic logs. + return json.NewEncoder(os.Stdout).Encode(map[string]string{ + "server_url": strings.TrimRight(*serverURL, "/") + "/api/v1", "runtime_id": registered.ID, + "runner_credential": credential, "device_name": registered.Name, + }) +} diff --git a/services/agents-api/cmd/environment-key/main.go b/services/agents-api/cmd/environment-key/main.go new file mode 100644 index 000000000..95235b69e --- /dev/null +++ b/services/agents-api/cmd/environment-key/main.go @@ -0,0 +1,139 @@ +// Command environment-key manages executor principal credentials using operator DB authority. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "io" + "os" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if err := run(); err != nil && !errors.Is(err, flag.ErrHelp) { + log.Bg().Error("executor credential operation failed", "error", err) + os.Exit(1) + } +} + +type commandOptions struct { + principal identity.Principal + keyID string + environment string + rotate bool + revoke bool +} + +func parseOptions(args []string, helpOutput io.Writer) (commandOptions, error) { + var options commandOptions + flags := flag.NewFlagSet("agents-api-environment-key", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&options.principal.TenantID, "tenant", "", "execution tenant UUID with an existing project mapping") + flags.StringVar(&options.principal.OrganizationID, "organization", "", "execution organization ID") + flags.StringVar(&options.principal.ProjectID, "project", "", "execution project ID") + flags.StringVar(&options.principal.SubjectKind, "subject-kind", "", "executor principal kind: user or service_account") + flags.StringVar(&options.principal.SubjectID, "subject-id", "", "executor principal subject ID") + flags.StringVar(&options.keyID, "key-id", "", "canonical nonzero executor key UUID") + flags.StringVar(&options.environment, "environment", "", "optional existing Environment UUID restriction for issuance only") + flags.BoolVar(&options.rotate, "rotate", false, "explicitly replace the existing credential, including a revoked credential") + flags.BoolVar(&options.revoke, "revoke", false, "revoke the existing credential without issuing a secret") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(helpOutput) + flags.PrintDefaults() + return commandOptions{}, flag.ErrHelp + } + return commandOptions{}, errors.New("invalid executor credential arguments; use --help") + } + if flags.NArg() != 0 || (options.rotate && options.revoke) { + return commandOptions{}, errors.New("positional arguments are not accepted; --rotate and --revoke are mutually exclusive") + } + if options.principal.TenantID == "" || options.principal.OrganizationID == "" || + options.principal.ProjectID == "" || options.principal.SubjectKind == "" || + options.principal.SubjectID == "" || options.keyID == "" { + return commandOptions{}, errors.New("--tenant, --organization, --project, --subject-kind, --subject-id and --key-id are required") + } + if err := options.principal.Validate(); err != nil { + return commandOptions{}, err + } + if !canonicalUUID(options.keyID) { + return commandOptions{}, errors.New("--key-id must be a canonical nonzero UUID") + } + environmentSet := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "environment" { + environmentSet = true + } + }) + if environmentSet && (options.rotate || options.revoke) { + return commandOptions{}, errors.New("--environment is only accepted for issuance; rotation and revocation retain the stored restriction") + } + if environmentSet && !canonicalUUID(options.environment) { + return commandOptions{}, errors.New("--environment must be a canonical nonzero UUID") + } + return options, nil +} + +func canonicalUUID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} + +func run() error { + options, err := parseOptions(os.Args[1:], os.Stderr) + if err != nil { + return err + } + dsn := os.Getenv("AGENTS_API_DATABASE_URL") + if dsn == "" { + return errors.New("AGENTS_API_DATABASE_URL must point to a dedicated execution database") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return errors.New("invalid execution database configuration") + } + defer pool.Close() + s := store.New(pool) + if options.revoke { + return credentialOperationError(s.RevokeExecutorCredential(ctx, options.principal, options.keyID)) + } + var credential store.IssuedExecutorCredential + if options.rotate { + credential, err = s.RotateExecutorCredential(ctx, options.principal, options.keyID) + } else { + credential, err = s.IssueExecutorCredential(ctx, options.principal, options.keyID, options.environment) + } + if err != nil { + return credentialOperationError(err) + } + // Operators redirect stdout to a mode-0600 file under ~/.parsar; no read-back operation exists. + if err := json.NewEncoder(os.Stdout).Encode(credential); err != nil { + return errors.New("could not write issued executor credential; rotate explicitly to replace it") + } + return nil +} + +func credentialOperationError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, store.ErrExecutorCredentialExists): + return store.ErrExecutorCredentialExists + case errors.Is(err, store.ErrNotFound): + return errors.New("executor principal project mapping or authorized credential target not found") + case errors.Is(err, store.ErrInvalidInput): + return errors.New("invalid executor credential identity or target") + default: + return errors.New("executor credential database operation failed") + } +} diff --git a/services/agents-api/cmd/environment-key/main_test.go b/services/agents-api/cmd/environment-key/main_test.go new file mode 100644 index 000000000..30d6a547b --- /dev/null +++ b/services/agents-api/cmd/environment-key/main_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +const ( + testTenant = "471e90e1-d9b3-418b-b339-928284514ae1" + testKey = "581d17e2-f063-42dc-b979-3fbd1c7a052c" + testEnvironment = "5c9751a6-df59-4612-912e-55e6a7898c5a" +) + +func principalArgs() []string { + return []string{ + "--tenant", testTenant, + "--organization", "test-org", + "--project", "test-project", + "--subject-kind", "service_account", + "--subject-id", "test-executor", + "--key-id", testKey, + } +} + +func TestParseOptionsPrincipalCredentialOperations(t *testing.T) { + for _, test := range []struct { + name string + args []string + environment string + rotate bool + revoke bool + }{ + {name: "principal issuance"}, + {name: "exact issuance", args: []string{"--environment", testEnvironment}, environment: testEnvironment}, + {name: "rotation", args: []string{"--rotate"}, rotate: true}, + {name: "revocation", args: []string{"--revoke"}, revoke: true}, + } { + t.Run(test.name, func(t *testing.T) { + options, err := parseOptions(append(principalArgs(), test.args...), io.Discard) + if err != nil { + t.Fatal(err) + } + if options.principal.TenantID != testTenant || options.principal.OrganizationID != "test-org" || + options.principal.ProjectID != "test-project" || options.principal.SubjectKind != "service_account" || + options.principal.SubjectID != "test-executor" { + t.Fatalf("unexpected principal: %+v", options.principal) + } + if options.keyID != testKey || options.environment != test.environment || options.rotate != test.rotate || options.revoke != test.revoke { + t.Fatalf("unexpected operation: %+v", options) + } + }) + } + options, err := parseOptions(append(principalArgs(), "--subject-kind", "user"), io.Discard) + if err != nil || options.principal.SubjectKind != "user" { + t.Fatalf("user principal rejected: %v", err) + } +} + +func TestParseOptionsRequiresEveryIdentityFlag(t *testing.T) { + for _, name := range []string{"--tenant", "--organization", "--project", "--subject-kind", "--subject-id", "--key-id"} { + t.Run(name, func(t *testing.T) { + args := principalArgs() + for i := 0; i < len(args); i += 2 { + if args[i] == name { + args = append(args[:i], args[i+2:]...) + break + } + } + if _, err := parseOptions(args, io.Discard); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("missing %s was not clearly rejected: %v", name, err) + } + }) + } + if _, err := parseOptions([]string{"--tenant", testTenant, "--environment", testEnvironment}, io.Discard); err == nil { + t.Fatal("legacy exact-Environment arguments were accepted") + } +} + +func TestParseOptionsRejectsInvalidIdentityAndRestrictionChanges(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "conflicting operations", args: []string{"--rotate", "--revoke"}}, + {name: "rotation with restriction", args: []string{"--rotate", "--environment", testEnvironment}}, + {name: "revocation with restriction", args: []string{"--revoke", "--environment", testEnvironment}}, + {name: "rotation with empty restriction", args: []string{"--rotate", "--environment="}}, + {name: "revocation with empty restriction", args: []string{"--revoke", "--environment="}}, + {name: "empty issuance restriction", args: []string{"--environment="}}, + {name: "invalid environment", args: []string{"--environment", "invalid"}}, + {name: "noncanonical environment", args: []string{"--environment", strings.ToUpper(testEnvironment)}}, + {name: "zero environment", args: []string{"--environment", "00000000-0000-0000-0000-000000000000"}}, + {name: "invalid key", args: []string{"--key-id", "invalid"}}, + {name: "noncanonical key", args: []string{"--key-id", strings.ToUpper(testKey)}}, + {name: "zero key", args: []string{"--key-id", "00000000-0000-0000-0000-000000000000"}}, + {name: "noncanonical tenant", args: []string{"--tenant", strings.ToUpper(testTenant)}}, + {name: "empty organization", args: []string{"--organization", ""}}, + {name: "whitespace project", args: []string{"--project", " test-project"}}, + {name: "invalid subject kind", args: []string{"--subject-kind", "device"}}, + {name: "empty subject", args: []string{"--subject-id", ""}}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := parseOptions(append(principalArgs(), test.args...), io.Discard); err == nil { + t.Fatal("invalid arguments were accepted") + } + }) + } +} + +func TestParseOptionsRedactsValuesAndPreservesHelp(t *testing.T) { + const secret = "private-value-that-must-not-be-logged" + for _, args := range [][]string{ + {"--" + secret}, + {"--rotate=" + secret}, + {"--key-id", secret}, + {secret}, + } { + var output bytes.Buffer + _, err := parseOptions(append(principalArgs(), args...), &output) + if err == nil || strings.Contains(err.Error(), secret) || output.Len() != 0 { + t.Fatalf("invalid arguments were not safely rejected: %v; output: %q", err, output.String()) + } + } + var help bytes.Buffer + if _, err := parseOptions([]string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { + t.Fatalf("unexpected help result: %v", err) + } + if !strings.Contains(help.String(), "-key-id") || !strings.Contains(help.String(), "-subject-kind") { + t.Fatalf("help is missing principal credential arguments: %s", help.String()) + } +} + +func TestCredentialOperationErrorsDoNotExposeDatabaseValues(t *testing.T) { + const secret = "postgres://operator:private-password@database/execution" + for _, err := range []error{ + errors.New(secret), + fmt.Errorf("%w: %s", store.ErrInvalidInput, secret), + fmt.Errorf("%w: %s", store.ErrNotFound, secret), + fmt.Errorf("%w: %s", store.ErrExecutorCredentialExists, secret), + } { + redacted := credentialOperationError(err) + if redacted == nil || strings.Contains(redacted.Error(), secret) { + t.Fatalf("database failure was not redacted: %v", redacted) + } + } + if err := credentialOperationError(nil); err != nil { + t.Fatalf("successful revocation returned an error: %v", err) + } + if !errors.Is(credentialOperationError(store.ErrExecutorCredentialExists), store.ErrExecutorCredentialExists) { + t.Fatal("duplicate key guidance was lost") + } +} diff --git a/services/agents-api/cmd/migrate/main.go b/services/agents-api/cmd/migrate/main.go new file mode 100644 index 000000000..1df04bf72 --- /dev/null +++ b/services/agents-api/cmd/migrate/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "errors" + "os" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("agents-api migration failed", "error", err) + os.Exit(1) + } +} + +func run() error { + databaseURL := os.Getenv("AGENTS_API_DATABASE_URL") + if databaseURL == "" { + return errors.New("AGENTS_API_DATABASE_URL must point to a dedicated execution database") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + return migrations.Apply(ctx, databaseURL) +} diff --git a/services/agents-api/cmd/server/credential_cipher.go b/services/agents-api/cmd/server/credential_cipher.go new file mode 100644 index 000000000..1029d0360 --- /dev/null +++ b/services/agents-api/cmd/server/credential_cipher.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/base64" + "errors" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" +) + +func credentialCipher() (*credentialcrypto.Cipher, error) { + path := os.Getenv("AGENTS_API_CREDENTIAL_KEY_FILE") + if path == "" { + return nil, nil + } + content, err := os.ReadFile(path) + if err != nil { + return nil, errors.New("cannot read AGENTS_API_CREDENTIAL_KEY_FILE") + } + key, err := base64.StdEncoding.Strict().DecodeString(strings.TrimSpace(string(content))) + if err != nil || len(key) != 32 { + return nil, errors.New("AGENTS_API_CREDENTIAL_KEY_FILE must contain a base64-encoded random 32-byte key") + } + return credentialcrypto.New(key) +} diff --git a/services/agents-api/cmd/server/credential_cipher_test.go b/services/agents-api/cmd/server/credential_cipher_test.go new file mode 100644 index 000000000..80d331d92 --- /dev/null +++ b/services/agents-api/cmd/server/credential_cipher_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" +) + +func TestCredentialCipherConfiguration(t *testing.T) { + t.Setenv("AGENTS_API_CREDENTIAL_KEY_FILE", "") + t.Setenv("PARSAR_MASTER_KEY", "must-not-be-used") + if c, err := credentialCipher(); c != nil || err != nil { + t.Fatal("absent dedicated key must remain disabled", err) + } + path := filepath.Join(t.TempDir(), "credential.key") + t.Setenv("AGENTS_API_CREDENTIAL_KEY_FILE", path) + if _, err := credentialCipher(); err == nil { + t.Fatal("missing configured file accepted") + } + for _, content := range []string{"", "private-invalid-key", base64.StdEncoding.EncodeToString(make([]byte, 31))} { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, err := credentialCipher(); err == nil || strings.Contains(err.Error(), "private-invalid-key") { + t.Fatal("invalid configuration accepted or leaked") + } + } + key := bytes.Repeat([]byte{0x91}, 32) + if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(key)+"\n"), 0600); err != nil { + t.Fatal(err) + } + first, err := credentialCipher() + if err != nil { + t.Fatal(err) + } + binding := credentialcrypto.Binding{TenantID: "tenant", VaultID: "vault", CredentialID: "credential", AuthType: "static_bearer", Destination: "https://example.invalid/mcp"} + sealed, err := first.Seal([]byte("opaque storage test"), binding) + if err != nil { + t.Fatal(err) + } + reopened, err := credentialCipher() + if err != nil { + t.Fatal(err) + } + got, err := reopened.Open(sealed, binding) + if err != nil || string(got) != "opaque storage test" { + t.Fatal("persisted key did not recover ciphertext", err) + } +} diff --git a/services/agents-api/cmd/server/environment_connection_test.go b/services/agents-api/cmd/server/environment_connection_test.go new file mode 100644 index 000000000..2783a00f7 --- /dev/null +++ b/services/agents-api/cmd/server/environment_connection_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type connectionStore struct{ tenant, environment string } + +func (s connectionStore) GetEnvironment(ctx context.Context, tenant, environment string) (store.Environment, error) { + if err := ctx.Err(); err != nil { + return store.Environment{}, err + } + if tenant != s.tenant || environment != s.environment { + return store.Environment{}, store.ErrNotFound + } + return store.Environment{ID: environment}, nil +} + +func (connectionStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { + return "", store.ErrNotFound +} + +func TestEnvironmentConnectionUsesScopedOwnerCredentials(t *testing.T) { + if environmentConnection(nil) != nil { + t.Fatal("disabled registry enabled pending-input scheduling") + } + source := connectionStore{tenant: uuid.NewString(), environment: uuid.NewString()} + owned := true + registry, err := codex.New(codex.Config{Store: source, PublicURL: "https://executor.example/", + ReplaceConnection: func(context.Context, string, string, string) error { + t.Fatal("credential-only fixture replaced a connection") + return nil + }, + ObserveConnection: func(context.Context, string, string, string, int64, bool) error { + t.Fatal("credential-only fixture observed a connection") + return nil + }, + CheckOwnership: func(context.Context) error { + if !owned { + return errors.New("execution ownership lost") + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + defer registry.Close() + resolve := environmentConnection(registry) + session := store.Session{TenantID: source.tenant, Engine: "codex"} + environment := store.Environment{ID: source.environment} + owner, cancel := context.WithCancel(context.Background()) + defer cancel() + first, err := resolve(owner, session, environment) + if err != nil || first.URL != "https://executor.example" || first.Token == "" || first.Release == nil { + t.Fatal("connection did not carry the configured origin and owner credential", err) + } + defer first.Release() + second, err := resolve(owner, session, environment) + if err != nil || second.Token == first.Token { + t.Fatal("independent execution owners reused a credential", err) + } + defer second.Release() + status := func(token string) int { + req := httptest.NewRequest(http.MethodPost, "/cloud/environment/"+source.environment+"/connect", nil) + req.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + registry.Handler().ServeHTTP(response, req) + return response.Code + } + // Authenticated requests reach body validation; there is no executor fixture. + if status(first.Token) != http.StatusBadRequest || status(second.Token) != http.StatusBadRequest { + t.Fatal("issued credentials did not reach their native registry") + } + first.Release() + if status(first.Token) != http.StatusUnauthorized || status(second.Token) != http.StatusBadRequest { + t.Fatal("release did not retain the separate execution owner") + } + foreign := session + foreign.TenantID = uuid.NewString() + if _, err := resolve(owner, foreign, environment); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign tenant obtained Environment authority", err) + } + foreign = session + foreign.Engine = "claude_sdk" + if _, err := resolve(owner, foreign, environment); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal("Codex transport accepted another engine", err) + } + cancel() + if status(second.Token) != http.StatusUnauthorized { + t.Fatal("owner cancellation retained its credential") + } + owned = false + failed, err := resolve(context.Background(), session, environment) + if err == nil || failed.Token != "" || failed.Release != nil { + t.Fatal("lost ownership issued a usable connection") + } +} diff --git a/services/agents-api/cmd/server/execution_options.go b/services/agents-api/cmd/server/execution_options.go new file mode 100644 index 000000000..2618d9ce4 --- /dev/null +++ b/services/agents-api/cmd/server/execution_options.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Operator options use the existing transient adapter configuration path. They +// are not public Session configuration and are never persisted with its snapshot. +func executionOptions() (func(context.Context, store.Session) (map[string]any, error), error) { + file := os.Getenv("AGENTS_API_EXECUTION_OPTIONS_FILE") + if file == "" { + return nil, nil + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, errors.New("cannot read AGENTS_API_EXECUTION_OPTIONS_FILE") + } + var check map[string]any + if json.Unmarshal(raw, &check) != nil || check == nil { + return nil, errors.New("execution options must contain a JSON object") + } + defaultEngine := os.Getenv("AGENTS_API_ENGINE") + if defaultEngine == "" { + defaultEngine = "codex" + } + var byHarness map[string]json.RawMessage + if value, exists := check["by_harness"]; exists { + encoded, _ := json.Marshal(value) + if len(check) != 1 || json.Unmarshal(encoded, &byHarness) != nil || byHarness == nil { + return nil, errors.New("execution by_harness options must be an exclusive object") + } + for _, entry := range byHarness { + var object map[string]any + if json.Unmarshal(entry, &object) != nil || object == nil { + return nil, errors.New("execution harness options must be objects") + } + } + } + return func(_ context.Context, session store.Session) (map[string]any, error) { + selected := raw + if byHarness != nil { + var ok bool + selected, ok = byHarness[session.Engine] + if !ok { + return nil, errors.New("execution options are unavailable for the selected harness") + } + } else if session.Engine != "" && session.Engine != defaultEngine { + return nil, errors.New("configure separate execution options for the selected harness") + } + // Request assembly may extend the map; no Session may mutate another's options. + var options map[string]any + err := json.Unmarshal(selected, &options) + return options, err + }, nil +} diff --git a/services/agents-api/cmd/server/execution_options_test.go b/services/agents-api/cmd/server/execution_options_test.go new file mode 100644 index 000000000..9d7a769b2 --- /dev/null +++ b/services/agents-api/cmd/server/execution_options_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionOptionsStayTransientAndPerRequest(t *testing.T) { + t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", "") + if options, err := executionOptions(); options != nil || err != nil { + t.Fatal("implicit options", err) + } + file := filepath.Join(t.TempDir(), "options.json") + t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) + if err := os.WriteFile(file, []byte(`{"codex_provider":{"bearer_token":"synthetic-private-canary","wire_api":"responses"}}`), 0600); err != nil { + t.Fatal(err) + } + resolve, err := executionOptions() + if err != nil { + t.Fatal(err) + } + first, err := resolve(t.Context(), store.Session{ID: "first"}) + if err != nil { + t.Fatal(err) + } + first["codex_provider"].(map[string]any)["bearer_token"] = "modified" + next, err := resolve(t.Context(), store.Session{ID: "second"}) + if err != nil || next["codex_provider"].(map[string]any)["bearer_token"] != "synthetic-private-canary" { + t.Fatal("options shared mutable state", err) + } + for _, raw := range []string{`null`, `[]`, `{"secret":"synthetic-private-canary"`} { + if err := os.WriteFile(file, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := executionOptions(); err == nil || strings.Contains(err.Error(), "synthetic-private-canary") { + t.Fatal("invalid or sensitive options error") + } + } +} + +func TestExecutionOptionsSeparateHarnessCredentials(t *testing.T) { + file := filepath.Join(t.TempDir(), "options.json") + t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) + t.Setenv("AGENTS_API_ENGINE", "codex") + if err := os.WriteFile(file, []byte(`{"by_harness":{"codex":{"codex_provider":{"bearer_token":"codex-secret"}},"mcode":{"mcode_provider":{"api_key":"mcode-secret"}}}}`), 0600); err != nil { + t.Fatal(err) + } + resolve, err := executionOptions() + if err != nil { + t.Fatal(err) + } + options, err := resolve(t.Context(), store.Session{Engine: "mcode"}) + if err != nil || options["codex_provider"] != nil || options["mcode_provider"] == nil { + t.Fatalf("wrong credential partition: %v", err) + } + if _, err := resolve(t.Context(), store.Session{Engine: "claude_sdk"}); err == nil { + t.Fatal("missing harness fell back") + } + if err := os.WriteFile(file, []byte(`{"codex_provider":{"bearer_token":"codex-secret"}}`), 0600); err != nil { + t.Fatal(err) + } + resolve, err = executionOptions() + if err != nil { + t.Fatal(err) + } + if _, err := resolve(t.Context(), store.Session{Engine: "mcode"}); err == nil { + t.Fatal("legacy credentials crossed harness boundary") + } +} diff --git a/services/agents-api/cmd/server/executor.go b/services/agents-api/cmd/server/executor.go new file mode 100644 index 000000000..03a2c1222 --- /dev/null +++ b/services/agents-api/cmd/server/executor.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "errors" + "os" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func executorRegistry(s *store.Store, worker func() *execution.Worker, checkOwnership func(context.Context) error) (*codex.Registry, error) { + file, url := os.Getenv("AGENTS_API_EXECUTOR_KEYS_FILE"), os.Getenv("AGENTS_API_EXECUTOR_URL") + harnessFile := os.Getenv("AGENTS_API_HARNESS_KEYS_FILE") + if file == "" && url == "" && harnessFile == "" { + return nil, nil + } + if file != "" { + return nil, errors.New("AGENTS_API_EXECUTOR_KEYS_FILE is retired; issue durable credentials with agents-api-environment-key and remove the old setting") + } + if harnessFile != "" { + return nil, errors.New("AGENTS_API_HARNESS_KEYS_FILE is retired; harness credentials belong to internal execution ownership; remove the old setting") + } + if url == "" || checkOwnership == nil || worker == nil { + return nil, errors.New("executor registry requires URL and the daemon execution worker") + } + return codex.New(codex.Config{Store: s, CheckOwnership: checkOwnership, PublicURL: url, + ReplaceConnection: func(ctx context.Context, tenant, environment, generation string) error { + if worker() == nil { + return errors.New("execution worker is not initialized") + } + return worker().ReplaceEnvironmentConnection(ctx, tenant, environment, generation) + }, + ObserveConnection: func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + if worker() == nil { + return errors.New("execution worker is not initialized") + } + return worker().ObserveEnvironmentConnection(ctx, tenant, environment, generation, revision, connected) + }, + }) +} + +func environmentConnection(registry *codex.Registry) func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { + if registry == nil { + return nil + } + return func(ctx context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { + if session.Engine != "codex" { + return execution.EnvironmentConnection{}, store.ErrInvalidInput + } + token, release, err := registry.IssueHarnessCredential(ctx, session.TenantID, environment.ID) + if err != nil { + return execution.EnvironmentConnection{}, err + } + return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, nil + } +} diff --git a/services/agents-api/cmd/server/executor_test.go b/services/agents-api/cmd/server/executor_test.go new file mode 100644 index 000000000..69d765418 --- /dev/null +++ b/services/agents-api/cmd/server/executor_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutorRegistryIsExplicitAndRetiresStaticKeys(t *testing.T) { + t.Setenv("AGENTS_API_EXECUTOR_KEYS_FILE", "") + t.Setenv("AGENTS_API_HARNESS_KEYS_FILE", "") + t.Setenv("AGENTS_API_EXECUTOR_URL", "") + if r, err := executorRegistry(nil, nil, nil); err != nil || r != nil { + t.Fatal("default registry must remain disabled") + } + t.Setenv("AGENTS_API_EXECUTOR_URL", "https://executor.example") + if _, err := executorRegistry(nil, nil, nil); err == nil { + t.Fatal("registry enabled without execution owner") + } + s := store.New(nil) + checkOwnership := func(context.Context) error { + t.Fatal("constructor invoked ownership before the worker was initialized") + return nil + } + for _, setting := range []string{"AGENTS_API_EXECUTOR_KEYS_FILE", "AGENTS_API_HARNESS_KEYS_FILE"} { + t.Setenv(setting, "retired-private-file") + if _, err := executorRegistry(s, func() *execution.Worker { return nil }, checkOwnership); err == nil { + t.Fatal("retired key setting accepted", setting) + } + t.Setenv(setting, "") + } + registry, err := executorRegistry(s, func() *execution.Worker { return nil }, checkOwnership) + if err != nil { + t.Fatal(err) + } + if registry.PublicURL() != "https://executor.example" { + t.Fatal("public executor origin differs from configured registration origin", registry.PublicURL()) + } + registry.Close() +} + +func TestExecutorPublicOriginUsesRegistryValidation(t *testing.T) { + t.Setenv("AGENTS_API_EXECUTOR_KEYS_FILE", "") + t.Setenv("AGENTS_API_HARNESS_KEYS_FILE", "") + for _, origin := range []string{ + "https://token@executor.example", "https://executor.example?token=secret", "https://executor.example?", + "https://executor.example/#token", "https://executor.example/daemon", "http://executor.example", "", + } { + t.Setenv("AGENTS_API_EXECUTOR_URL", origin) + registry, err := executorRegistry(store.New(nil), func() *execution.Worker { return nil }, func(context.Context) error { return nil }) + if err == nil && registry != nil { + registry.Close() + t.Fatal("invalid public executor origin accepted", origin) + } + } + for origin, want := range map[string]string{ + "https://executor.example/": "https://executor.example", "http://127.0.0.1:8091/": "http://127.0.0.1:8091", + } { + t.Setenv("AGENTS_API_EXECUTOR_URL", origin) + registry, err := executorRegistry(store.New(nil), func() *execution.Worker { return nil }, func(context.Context) error { return nil }) + if err != nil { + t.Fatal(err) + } + if registry.PublicURL() != want { + t.Fatal("incorrect executor registration origin", registry.PublicURL(), want) + } + registry.Close() + } +} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go new file mode 100644 index 000000000..6963b5524 --- /dev/null +++ b/services/agents-api/cmd/server/main.go @@ -0,0 +1,183 @@ +// Package main runs the standalone Agents API service. +// +// @title Agents API +// @version 1 +// @description Supported single-Agent execution resources from the pinned openai-python beta/agents contract. Bearer keys bind an execution principal to one project; optional OpenAI-Organization and OpenAI-Project headers must match that binding. +// @license.name Apache 2.0 +// @license.url https://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath /v1 +// @schemes http https +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("agents-api startup failed", "error", err) + os.Exit(1) + } +} + +func run() error { + databaseURL, keysFile := os.Getenv("AGENTS_API_DATABASE_URL"), os.Getenv("AGENTS_API_KEYS_FILE") + if databaseURL == "" || keysFile == "" { + return errors.New("AGENTS_API_DATABASE_URL and AGENTS_API_KEYS_FILE are required") + } + content, err := os.ReadFile(keysFile) + if err != nil { + return errors.New("cannot read AGENTS_API_KEYS_FILE") + } + var keys []api.APIKey + if err := json.Unmarshal(content, &keys); err != nil { + return errors.New("AGENTS_API_KEYS_FILE must contain an array of API key bindings") + } + auth, err := api.NewAuthenticator(keys) + if err != nil { + return err + } + credentialKey, err := credentialCipher() + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return errors.New("invalid Agents API database configuration") + } + defer pool.Close() + ready, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := pool.Ping(ready); err != nil { + return errors.New("Agents API database connection failed") + } + engine := os.Getenv("AGENTS_API_ENGINE") + if engine == "" { + engine = "codex" + } + managed, closeManaged, err := managedRuntimes() + if err != nil { + return err + } + defer closeManaged() + transientOptions, err := executionOptions() + if err != nil { + return err + } + executionStore := store.NewWithCredentialCipher(pool, credentialKey) + if err := executionStore.EnsureProjectScopes(ready, auth.ProjectScopes()); err != nil { + return err + } + var workerDone chan error + var worker *execution.Worker + options := []api.Option{api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} + var daemonHandler http.Handler + var registry *gateway.Registry + var checkOwnership func(context.Context) error + if wsURL := os.Getenv("AGENTS_API_DAEMON_WS_URL"); wsURL != "" { + daemonHandler, registry, err = runtime.NewGateway(executionStore, wsURL) + if err != nil { + return err + } + defer runtime.CloseConnections(registry) + // Constructors do not invoke ownership checks; publish only after setup. + checkOwnership = func(ctx context.Context) error { + if worker == nil { + return errors.New("execution worker is not initialized") + } + return worker.CheckOwnership(ctx) + } + } + executor, err := executorRegistry(executionStore, func() *execution.Worker { return worker }, checkOwnership) + if err != nil { + return err + } + if executor != nil { + defer executor.Close() + options = append(options, api.WithEnvironmentRemoteURL(executor.PublicURL())) + } + if registry != nil { + dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, + EnvironmentConnection: environmentConnection(executor), ManagedRuntimes: managed, Options: transientOptions} + if executor != nil { + dispatcher.CloseEnvironmentConnections = executor.Close + } + worker, err = execution.StartWorker(ctx, dispatcher) + if err != nil { + return err + } + workerDone = make(chan error, 1) + go func() { workerDone <- worker.Run(ctx) }() + defer func() { + stop() + if workerDone != nil { + <-workerDone + } + }() + options = append(options, api.WithExecution(worker), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) + if managed != nil && (managed.DefaultProvider != "" || len(managed.EngineProviders) > 0) { + kinds := make([]string, 0, len(managed.EngineProviders)) + for kind := range managed.EngineProviders { + kinds = append(kinds, kind) + } + options = append(options, api.WithHostedEnvironments(), api.WithHarnesses(kinds)) + } + } + handler, err := api.NewHandler(executionStore, auth, engine, options...) + if err != nil { + return err + } + if daemonHandler != nil || executor != nil { + mux := http.NewServeMux() + if daemonHandler != nil { + mux.Handle("/api/v1/agent-daemon/", daemonHandler) + } + if executor != nil { + mux.Handle("/cloud/environment/", executor.Handler()) + } + mux.Handle("/", handler) + handler = mux + } + addr := os.Getenv("AGENTS_API_ADDR") + if addr == "" { + addr = "127.0.0.1:8091" + } + server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + done := make(chan error, 1) + go func() { done <- server.ListenAndServe() }() + select { + case err := <-done: + return err + case err := <-workerDone: + workerDone = nil + stop() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdown) + } +} diff --git a/services/agents-api/cmd/server/managed_runtimes.go b/services/agents-api/cmd/server/managed_runtimes.go new file mode 100644 index 000000000..cfeef37c1 --- /dev/null +++ b/services/agents-api/cmd/server/managed_runtimes.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + sandboxe2b "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/moby/moby/client" +) + +type managedRuntimeConfig struct { + CoreURL string `json:"core_url"` + EngineProviders map[string]string `json:"engine_providers"` + DefaultProvider string `json:"default_provider"` + Docker map[string]managedDockerConfig `json:"docker"` + E2B map[string]managedE2BConfig `json:"e2b"` +} + +type managedE2BConfig struct { + APIKeyFile string `json:"api_key_file"` + Template string `json:"template"` + LeaseSeconds int `json:"lease_seconds"` +} + +type managedDockerConfig struct { + Host string `json:"host"` + Image string `json:"image"` + Network string `json:"network"` + SeccompFile string `json:"seccomp_file"` + ExtraHosts []string `json:"extra_hosts"` + NestedSandbox bool `json:"nested_sandbox"` +} + +// Each provider key pins an explicit backend, independently of ambient +// DOCKER_HOST. Retained entries keep their cleanup backend when the default changes. +func managedRuntimes() (*execution.RuntimeProviders, func(), error) { + file := os.Getenv("AGENTS_API_MANAGED_RUNTIMES_FILE") + closeAll := func() {} + if file == "" { + return nil, closeAll, nil + } + if os.Getenv("AGENTS_API_DAEMON_WS_URL") == "" { + return nil, closeAll, errors.New("managed Runtime configuration requires AGENTS_API_DAEMON_WS_URL") + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, closeAll, errors.New("cannot read AGENTS_API_MANAGED_RUNTIMES_FILE") + } + var config managedRuntimeConfig + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF || len(config.Docker)+len(config.E2B) == 0 { + return nil, closeAll, errors.New("invalid managed Runtime configuration") + } + if config.DefaultProvider != "" { + _, dockerOK := config.Docker[config.DefaultProvider] + _, e2bOK := config.E2B[config.DefaultProvider] + if !dockerOK && !e2bOK { + return nil, closeAll, errors.New("managed default provider is not configured") + } + } + if config.EngineProviders == nil { + config.EngineProviders = map[string]string{} + } + defaultEngine := os.Getenv("AGENTS_API_ENGINE") + if defaultEngine == "" { + defaultEngine = "codex" + } + if config.DefaultProvider != "" && config.EngineProviders[defaultEngine] == "" { + config.EngineProviders[defaultEngine] = config.DefaultProvider + } + for kind, key := range config.EngineProviders { + _, qualified := (engine.Catalog{}).Lookup(kind) + _, dockerOK := config.Docker[key] + _, e2bOK := config.E2B[key] + if !qualified || key == "" || (!dockerOK && !e2bOK) { + return nil, closeAll, errors.New("invalid managed engine provider mapping") + } + } + var clients []*client.Client + closeAll = func() { + for _, c := range clients { + _ = c.Close() + } + } + result := &execution.RuntimeProviders{EngineProviders: config.EngineProviders, CoreURL: config.CoreURL, DefaultProvider: config.DefaultProvider, Providers: map[string]sandbox.Provider{}} + for key, entry := range config.E2B { + if _, duplicate := config.Docker[key]; duplicate { + return nil, closeAll, errors.New("managed provider keys must be unique") + } + keyBytes, err := os.ReadFile(entry.APIKeyFile) + if err != nil { + return nil, closeAll, errors.New("cannot read managed E2B API key file") + } + provider, err := sandboxe2b.New(sandboxe2b.Config{InstallationID: key, APIKey: strings.TrimSpace(string(keyBytes)), Template: entry.Template, LeaseSeconds: entry.LeaseSeconds}) + if err != nil { + return nil, closeAll, errors.New("invalid managed E2B provider configuration") + } + result.Providers[key] = provider + } + for key, entry := range config.Docker { + // V1 qualifies a local Docker daemon. Remote executor/provider transports are + // separate work; do not silently inherit a different backend from the shell. + if !strings.HasPrefix(entry.Host, "unix:///") || len(entry.Host) <= 8 { + closeAll() + return nil, func() {}, errors.New("managed Docker host must be an explicit unix socket") + } + seccomp, err := os.ReadFile(entry.SeccompFile) + if err != nil || !json.Valid(seccomp) { + closeAll() + return nil, func() {}, errors.New("cannot read managed Docker seccomp JSON") + } + c, err := client.New(client.WithHost(entry.Host)) + if err != nil { + closeAll() + return nil, func() {}, errors.New("invalid managed Docker endpoint") + } + clients = append(clients, c) + provider, err := sandboxdocker.New(c, sandboxdocker.Config{InstallationID: key, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox}) + if err != nil { + closeAll() + return nil, func() {}, errors.New("invalid managed Docker provider configuration") + } + result.Providers[key] = provider + } + return result, closeAll, nil +} diff --git a/services/agents-api/cmd/server/managed_runtimes_test.go b/services/agents-api/cmd/server/managed_runtimes_test.go new file mode 100644 index 000000000..361c94da4 --- /dev/null +++ b/services/agents-api/cmd/server/managed_runtimes_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestManagedRuntimeOperatorConfigurationIsExplicit(t *testing.T) { + t.Setenv("AGENTS_API_MANAGED_RUNTIMES_FILE", "") + if result, close, err := managedRuntimes(); err != nil || result != nil { + t.Fatal("implicit managed deployment", err) + } else { + close() + } + root := t.TempDir() + seccomp := filepath.Join(root, "seccomp.json") + file := filepath.Join(root, "providers.json") + if err := os.WriteFile(seccomp, []byte(`{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[]}`), 0600); err != nil { + t.Fatal(err) + } + key := uuid.NewString() + config := managedRuntimeConfig{CoreURL: "http://core.example/api/v1", DefaultProvider: key, Docker: map[string]managedDockerConfig{key: {Host: "unix:///var/run/docker.sock", Image: "sha256:" + strings.Repeat("a", 64), Network: "bridge", SeccompFile: seccomp}}} + write := func(c managedRuntimeConfig) { + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } + } + write(config) + t.Setenv("AGENTS_API_MANAGED_RUNTIMES_FILE", file) + t.Setenv("AGENTS_API_DAEMON_WS_URL", "") + if _, close, err := managedRuntimes(); err == nil { + close() + t.Fatal("managed configuration without authenticated daemon gateway accepted") + } + t.Setenv("AGENTS_API_DAEMON_WS_URL", "ws://core.example/api/v1/agent-daemon/ws") + t.Setenv("DOCKER_HOST", "not-a-valid-ambient-endpoint") + result, close, err := managedRuntimes() + if err != nil { + t.Fatal(err) + } + defer close() + if result.DefaultProvider != key || len(result.Providers) != 1 || result.Providers[key] == nil { + t.Fatal("provider identity lost") + } + for _, mutate := range []func(*managedRuntimeConfig){ + func(c *managedRuntimeConfig) { c.DefaultProvider = uuid.NewString() }, + func(c *managedRuntimeConfig) { e := c.Docker[key]; e.Host = "tcp://remote:2375"; c.Docker[key] = e }, + func(c *managedRuntimeConfig) { e := c.Docker[key]; e.Image = "latest"; c.Docker[key] = e }, + func(c *managedRuntimeConfig) { e := c.Docker[key]; e.Network = "host"; c.Docker[key] = e }, + } { + changed := config + changed.Docker = map[string]managedDockerConfig{key: config.Docker[key]} + mutate(&changed) + write(changed) + if _, close, err := managedRuntimes(); err == nil { + close() + t.Fatal("unqualified managed configuration accepted") + } + } + config.DefaultProvider = "" + write(config) + result, close, err = managedRuntimes() + if err != nil { + t.Fatal(err) + } + defer close() + if result.DefaultProvider != "" || len(result.Providers) != 1 { + t.Fatal("cleanup-only provider configuration lost") + } +} diff --git a/services/agents-api/credentials.md b/services/agents-api/credentials.md new file mode 100644 index 000000000..58d5d4cfb --- /dev/null +++ b/services/agents-api/credentials.md @@ -0,0 +1,221 @@ +# Vault credential storage + +The standalone service supports static-bearer Credential creation, token replacement, +deletion and safe metadata retrieval through the pinned official SDK. It stores tokens as authenticated +ciphertext in its own PostgreSQL database. There is no product-service dependency, +public secret-read endpoint. Resource creation, replacement and retrieval do not contact the +configured MCP destination; an attached Session can use it during execution. + +## Configure the storage key + +`AGENTS_API_CREDENTIAL_KEY_FILE` points to a file containing one base64-encoded, +random 32-byte key. Generate it once in private service configuration; the following +command refuses to replace an existing file: + +```sh +( + umask 077 + set -C + mkdir -p "$HOME/.parsar/agents-api" + openssl rand -base64 32 > "$HOME/.parsar/agents-api/credential.key" +) +export AGENTS_API_CREDENTIAL_KEY_FILE="$HOME/.parsar/agents-api/credential.key" +``` + +Keep the same key across service restarts and retain a protected backup separately +from database backups. The service reads it at startup; it never generates a +replacement or falls back to `PARSAR_MASTER_KEY`. Invalid configured files fail +startup with a safe error. If the setting is absent, other resources and Credential +metadata reads continue working, but Credential creation and replacement return local +`503 credential_storage_unavailable` before writing. +Session attachment/selection also uses safe metadata. If a selected credential +cannot be decrypted at dispatch, execution fails without contacting its MCP server +or falling back to anonymous authentication. + +Losing or replacing the key prevents decryption of existing credentials. Metadata +reads do not decrypt tokens and therefore do not prove that a key can recover them. +This release supports one retained key; storage-key rotation and re-encryption are not +implemented. Go's random-nonce GCM requires no more than 2^32 encryptions per key; +stop new credential writes before that bound until a supported storage-key rotation process +is available. Never treat editing the key file as rotation. + +## Public resource contract + +```python +credential = client.beta.agents.vaults.credentials.create( + vault.id, + name="Internal MCP", + auth={ + "type": "static_bearer", + "mcp_server_url": "https://mcp.example.com/endpoint", + "token": token_from_private_configuration, + }, +) +metadata = client.beta.agents.vaults.credentials.retrieve( + credential.id, vault_id=vault.id, +) +for metadata in client.beta.agents.vaults.credentials.list(vault.id, order="asc"): + print(metadata.id, metadata.name, metadata.auth.mcp_server_url) +``` + +These operations use ordinary project authentication and `OpenAI-Beta: agents=v1`. +Users and service accounts in the same project share access; a foreign project or +wrong owning Vault cannot retrieve the Credential. Parsar approval and personal +credential policies belong in the product client. + +Listing supports `after`, creation order (default `desc`), a limit defaulting to +20 and clamped to 1–100, and scalar or array `status` filters (`active`/`archived`, +both by default). Credential classification is private and independent of Vault +classification. Listing requires no encryption key and reads only safe metadata; +the parent and cursor must belong to the requested project and Vault. Synthetic +archived fixtures verify filtering, not a public archive operation. Archive behavior +and exact hosted query/concurrent-page semantics remain separate gaps. + +Required name is trimmed to 1–256 UTF-8 bytes. Required `auth` accepts +`static_bearer`, an HTTPS `mcp_server_url` and a string `token`. The token is +preserved as opaque data, including whitespace or an empty string. This does not +verify that it will authenticate to a destination. The local URL profile excludes +userinfo and fragments, preserves queries and performs no DNS or HTTP request. +Exact hosted empty-token and URL normalization rules remain unverified. + +The response contains `id`, `vault_id`, `name`, `object: vault.credential`, +`created_at`, `updated_at` and `auth`. Static auth contains only `type` and +`mcp_server_url`. There is no token, ciphertext or key information in the response. +The existing 1 MiB body bound is a local implementation limit. Creation makes a +fresh resource; hosted retry/idempotency semantics remain unverified. + +## Encryption boundary and remaining work + +The implementation uses standard-library AES-256-GCM with random nonces, without +custom nonce generation or password-derived keys. Ciphertext is a format version +byte followed by the standard AEAD nonce/ciphertext/tag payload. Authenticated data +contains a fixed domain/version and the tenant, Vault, Credential, auth type and +exact destination. A wrong key, modified payload or substituted binding fails +authentication. Names are public mutable metadata and are not part of this binding. +Resource SQL reads select no secret ciphertext. The key and request token exist in +trusted service memory; this protects stored secrets, not a compromised service host. + +Credential archive behavior, OAuth refresh, restricted-key scopes and revocation +semantics remain separate gaps. The foreign key preserves Vault ownership and +atomically removes all dependent Credentials when their Vault is deleted. The full +protocol target is unchanged. + +## Use a credential in a Session + +Attach the owning Vault and declare the same exact HTTPS destination: + +```python +session = client.beta.agents.sessions.create( + agent={ + "model": model, + "tools": [{ + "type": "mcp", + "server_label": "internal", + "transport": {"type": "http", "server_url": "https://mcp.example.com/endpoint"}, + "connection_origin": "service", + "credential_id": credential.id, + }], + }, + environment={"type": "none"}, + vault_ids=[vault.id], +) +``` + +Trusted service-side Codex and Claude SDK support `environment:none`; Codex also +supports the documented `self_hosted` combination. The daemon must advertise both +`mcp_http_tools` and `mcp_http_bearer_auth`. The usual +[MCP profile limits](README.md#http-mcp-execution) still apply. Without an explicit +`credential_id`, one exact-URL static credential among attached Vaults is selected; +zero matches remains anonymous and multiple matches fail. A foreign, missing, +unattached or wrong-destination reference returns the same local 404 before Session +creation. Saving a reference on an Agent does not authorize it for a Session. + +The Session freezes its attachment list and private selection, including anonymous +decisions. Public tools retain the caller's `credential_id` value, including null. +Identical creation retries recover the accepted Session before selecting again; +adding another credential does not change an existing binding. Each dispatch +rechecks the complete scope before decryption. The token goes only through the +private daemon request and a fresh native child environment variable, never public +configuration, history, arguments or logs. Native execution requires nonempty RFC +6750 b64token bytes and rejects other opaque stored strings without trimming them. +Exact hosted matching, response population, selection timing and error/redirect +semantics remain unverified. + +## Replace a stored token + +Use the pinned auth-only update operation when the MCP server's bearer token changes: + +```python +updated = client.beta.agents.vaults.credentials.update( + credential.id, + vault_id=vault.id, + auth={"type": "static_bearer", "token": replacement_from_private_configuration}, +) +``` + +This calls `POST /v1/vaults/{vault_id}/credentials/{credential_id}`. Both `auth` and +its `type`/string `token` are required; null, missing fields and extra mutation +fields are rejected. Empty and whitespace tokens remain opaque stored values, +subject to the existing native execution syntax limit when used. The response is +the same safe Credential metadata. ID, Vault, name, auth type, exact destination +and creation time stay unchanged; only ciphertext and update time are replaced +atomically. Missing encryption configuration or a failed mutation leaves the old +row intact. Unknown, foreign, wrong-Vault and malformed references use local 404. + +Existing explicit and implicit Session bindings keep the same selected identity +and creation retry behavior. A later dispatch reads the replacement after commit; +an already-resolved or running request may still hold the previous token. Updating +this resource performs no MCP call, changes no server-side token independently, +and provides no in-flight revocation, hot reload or cancellation. Coordinate the +destination's token change operationally. Replacing this token does not rotate the +storage encryption key or reset its encryption budget. OAuth replacement and exact +hosted overlapping-update, replay and timestamp semantics remain unverified. + +## Delete a stored credential + +```python +deleted = client.beta.agents.vaults.credentials.delete( + credential.id, + vault_id=vault.id, +) +``` + +The response confirms the ID, `deleted: true` and `object: vault.credential.deleted`. +This operation removes the owned database row and encrypted token without loading +the storage key. Local retrieval, update and repeated deletion return 404 afterward; +listings omit the row. The parent Vault and other credentials remain available. + +Existing Session snapshots and history keep their frozen credential identity. A +subsequent secret lookup fails without selecting another credential or switching +to anonymous MCP. A token already read before deletion may remain available to +dispatched work. Deletion does not stop running Sessions or revoke tokens at their +providers; use Session cancellation and provider management for those operations. + +The relationship between deletion and archived status, exact hosted metadata +visibility and duplicate-deletion errors remain unverified. This implementation +does not infer an archive transition. Row removal is not evidence of physical +erasure from PostgreSQL pages, WAL, backups or native history. + +## Delete a Vault and its credentials + +```python +deleted = client.beta.agents.vaults.delete(vault.id) +``` + +The response contains `id`, `deleted: true` and `object: vault.deleted`. Deletion +removes the project-owned Vault and every stored Credential in one database +transaction, including active and archived classifications. It needs no storage +key and sends no provider requests. Other Vaults and their Credentials are unchanged. + +Local retrieval, repeated deletion, child reads/updates/listing and new Session +attachments return 404 after removal. New child creation also returns 404 when +credential writes are configured; the existing missing-key 503 still takes +precedence when writes are disabled. Vault lists omit the deleted parent. +Existing Session snapshots and recorded creation retries keep the original Vault +and Credential IDs, and historical Items remain available. Later secret lookup +fails without selecting another Credential from an attached Vault or downgrading +to anonymous MCP. Already-read tokens may remain in dispatched work. + +This operation follows the same provider revocation, running-Session and physical +erasure limits as single-Credential deletion. Exact hosted archive relationships, +post-delete visibility and overlapping-mutation/error semantics remain unverified. diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile new file mode 100644 index 000000000..fd3eda9c5 --- /dev/null +++ b/services/agents-api/deploy/claude/Dockerfile @@ -0,0 +1,27 @@ +# Build context contains only the daemon, shared helpers and pinned SDK bundle. +FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment/workspace /workspace /home/runtime +COPY build-system-seed.py /tmp/build-system-seed.py +RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py +COPY --chmod=0555 tool-root.py /usr/local/bin/agents-api-tool-root +COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ +COPY claude-sdk /opt/claude-sdk +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize +ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ + PARSAR_CLAUDE_SDK_NODE=/usr/local/bin/node \ + PARSAR_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ + PARSAR_CLAUDE_SDK_WORKSPACE=managed \ + PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ + PARSAR_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/agents-api-codex-directory \ + PARSAR_RUNTIME_WRITE_HELPER=/usr/local/bin/agents-api-codex-write \ + PARSAR_RUNTIME_EXPORT_HELPER=/usr/local/bin/agents-api-workspace-export \ + PARSAR_RUNTIME_STAGING=/environment/staging +USER 1000:1000 +RUN node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js +WORKDIR /environment/workspace +ENTRYPOINT ["/usr/local/bin/parsar-daemon"] +CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md new file mode 100644 index 000000000..10262274c --- /dev/null +++ b/services/agents-api/deploy/claude/README.md @@ -0,0 +1,85 @@ +# Claude Code on the dedicated Runtime + +Core is independently deployed. Each managed Session receives one Docker Runtime +containing the daemon, pinned Claude Agent SDK/native harness and local workspace. +The SDK owns the model/tool loop. Public clients use the same Agents API contract. + +## Build and configure + +On Linux amd64, build the existing shared workspace helpers, then run: + +```sh +bash scripts/build-claude-sdk-runtime.sh +bash scripts/build-claude-runtime.sh +docker build --platform linux/amd64 -t agents-runtime:claude \ + "${PARSAR_HOME:-$HOME/.parsar}/build/claude-runtime" +``` + +The bundle pins SDK `0.3.269` and native Claude Code `2.1.269`. The Dockerfile pins +Node's Linux amd64 manifest. Keep the exported SDK bundle immutable. Configure +Core's existing managed Runtime file with the resulting immutable image ID, a +private Docker network, the existing `deploy/codex/seccomp.json`, and +`"nested_sandbox": true`. This option enables child reaping and the outer procfs +layout required by native bubblewrap; all native sandbox restrictions remain on. +It is disabled for existing deployments unless explicitly selected. + +Set `AGENTS_API_ENGINE=claude_sdk`. In a private file selected by +`AGENTS_API_EXECUTION_OPTIONS_FILE`, configure the provider: + +```json +{ + "claude_provider": { + "base_url": "https://api.anthropic.com", + "bearer_token": "REPLACE_WITH_OPERATOR_SECRET" + } +} +``` + +Use the endpoint and model supported by your actual provider. Do not bake keys +into the image. Core forwards this adapter-owned option transiently; it is not a +public Agent field. Workspace tool processes cannot inherit the provider secret. +Follow the existing Core setup for independent PostgreSQL credentials, migrations, +API authentication and managed Runtime enrollment. Parsar is not a dependency. + +The supported hosted profile accepts text execution with medium verbosity and +native Bash/Read/Edit and declared public functions with text results. Files and Artifacts use the shared public interfaces. +Workspace HTTP MCP, multi-agent, structured output, user-managed enrollment +and other environment installations remain explicit gaps. The existing +`environment:none` function/MCP profile is separate. This is not complete upstream +protocol compatibility. + +## Adding another native engine + +Start with the existing `agent.Factory`, `agent.Session` and +`agent.PreparationFactory` interfaces; do not create another scheduler or loop. +Registration itself is small: + +```go +registry.RegisterKind(descriptor, adapter.NewFactory(config)) +registry.RegisterPreparation(descriptor.Kind, true, adapter.NewPreparationFactory(config)) +``` + +These calls refer to the existing daemon registry. `descriptor` must advertise +only capabilities established by your adapter and deployment tests. A dedicated +local Runtime automatically reuses workspace authorization, idle directory reads, +file writes and output export; it does not need an engine-specific Files API. + +The adapter translates native configuration and events, owns its child processes, +confirms input receipts, and implements cancellation and preparation ownership. +`Prepared.Start` transfers ownership once; `Close` releases only unused preparation; +`PreparedCancellation.Cancel` follows the same resource through transfer and waits +for output settlement. Reuse the shared process runner and existing error types. +Native history belongs to the bound API Session, not the device descriptor. + +Add the qualified placement and value constraints to the qualified profile in +`services/agents-api/internal/engine/` and register it in its catalog. That declaration gates public support independently +of Runtime capability flags. Do not add engine branches to handlers, stores, +resource managers or scheduling. A genuinely new public capability may require a +bounded extension of the shared contract; registration alone cannot make an +incompatible harness conformant. + +Acceptance must use the pinned official OpenAI client, raw HTTP and a real model: +execute and continue; list/create files and retrieve artifacts; cancel and prove +process settlement; reconnect/restart without replay; verify tenant, credential and +history isolation. Missing history fails closed. Run focused race tests, `make +check` and independent review before merging. Keep unsupported combinations explicit. diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile new file mode 100644 index 000000000..55b93ec67 --- /dev/null +++ b/services/agents-api/deploy/codex/Dockerfile @@ -0,0 +1,30 @@ +# Build context is a prepared binary bundle, never the product checkout. +# Native package and resources must both be Codex 0.153.4 (linux/amd64). +FROM debian:bookworm-slim@sha256:a0982977ea1cf754c15281f48a7d5957cd14039a2a4f2aca9f23d74d226003d0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 python3-pip nodejs npm ripgrep bubblewrap \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment/workspace /workspace /home/runtime \ + && groupadd --gid 1000 runtime \ + && useradd --uid 1000 --gid 1000 --home-dir /home/runtime --no-create-home runtime +COPY build-system-seed.py /tmp/build-system-seed.py +RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py +COPY --chmod=0555 tool-root.py /usr/local/bin/agents-api-tool-root +COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export codex /usr/local/bin/ +COPY codex-resources /usr/local/codex-resources +COPY requirements.toml /etc/codex/requirements.toml +COPY --chmod=0444 tool-env.py /etc/codex/tool-env.py +RUN chmod 0555 /etc/codex && chmod 0444 /etc/codex/requirements.toml +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize +ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ + PARSAR_CODEX_BIN=/usr/local/bin/codex PARSAR_CODEX_PERMISSION_PROFILE=managed-workspace \ + PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ + PARSAR_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/agents-api-codex-directory \ + PARSAR_RUNTIME_WRITE_HELPER=/usr/local/bin/agents-api-codex-write \ + PARSAR_RUNTIME_EXPORT_HELPER=/usr/local/bin/agents-api-workspace-export \ + PARSAR_RUNTIME_STAGING=/environment/staging +USER 1000:1000 +RUN test -r /etc/codex/requirements.toml && test "$(codex --version)" = "codex-cli 0.153.4" +WORKDIR /environment/workspace +ENTRYPOINT ["/usr/local/bin/parsar-daemon"] +CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md new file mode 100644 index 000000000..9007e32f0 --- /dev/null +++ b/services/agents-api/deploy/codex/README.md @@ -0,0 +1,195 @@ +# Codex colocated Runtime + +These operator inputs select the V1 native isolation profile. The explicit service +configuration below enables basic Docker-hosted admission; it does not replace +the accepted caller-managed `self_hosted` path. Use one dedicated sandbox and retained +home/workspace per Session. Core stays outside it. Do not mount another Session's +history, host home, Docker socket, or product/Core credentials. + +The shared Runtime and its two management modes are defined in +[the contributor guide](../../../../CONTRIBUTING.md#product-and-execution-service-separation). +User-managed installation and enrollment are outside this qualification batch. + +Use Codex 0.153.4 and its matching `codex-resources` directory. Install the immutable +requirements file at `/etc/codex/requirements.toml`, mount only the authorized +Environment parent at `/environment`, containing `workspace`, private `staging`, tool `initialization` and `packages` +directories on one mount for trusted writes. Retain daemon/native state beneath `/home`. Set +`PARSAR_CODEX_PERMISSION_PROFILE=managed-workspace` on the daemon. This operator +setting enables adapter selection of the immutable Runtime network profile at +startup and on both new/resumed threads; +it also filters native shell inheritance to process essentials, retaining default +secret exclusions. Native shell snapshots are disabled for this managed path: +their private storage is unavailable inside the tool sandbox, so loading one would +abort the pinned harness's login-shell wrapper. Ordinary shell startup remains +native. Model credentials remain available to the trusted harness; +it is not a request option or a public capability. Missing/invalid native profiles +must fail, without falling back to an unrestricted run. Ordinary deployments leave +this setting unset. It is incompatible with remote/none/read-only preparations. + +The requirements file is outside the workspace and read-only. It fixes the allowed +profile and denies reads of daemon authentication, generated provider configuration +and native history under the declared daemon state layout. Minimal native reads +exclude other home contents; native helper aliases under the Session tmp directory +remain readable so the pinned harness can start its sandbox and apply_patch. +The workspace and initialized package prefix are writable by native tools; +initialized tool configuration is read-only. Staging and its ancestors +are unavailable for native tool writes; staging is also explicitly denied for reads. +The image includes enabled and disabled native network profiles with the same +filesystem restrictions. Bootstrap freezes `PARSAR_RUNTIME_NETWORK_ACCESS`; +preparation must match it, and the adapter selects the native profile. Public +omission defaults to enabled. Restricted domains are unimplemented and rejected. +The trusted harness and daemon retain model/Core connectivity with either policy. +Do not expose private stock filesystem RPC as public Files. +Public file access needs the existing bounded, authorized filesystem primitives. + +The Docker candidate uses a non-root user, no capabilities, no-new-privileges, +read-only root, private PID namespace, dedicated bridge networking, bounded tmpfs +and process/memory/CPU limits. Stock Codex's inner bubblewrap sandbox needs user, +mount and PID namespaces. `seccomp.json` retains the Moby default restrictions and +adds clone, unshare, setns, mount, umount2 and pivot_root for that inner sandbox. +No host capability or privileged container is required. On the tested AppArmor +host, Docker's default profile denies namespaced mounts; the candidate uses +container-specific `apparmor=unconfined`. This removes that outer LSM layer, so the +native sandbox, outer namespace/capability restrictions and actual isolation +checks remain required. Do not alter the host-wide AppArmor or seccomp policy. + +Seccomp source: [Moby profiles, revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json), Apache-2.0. +Unmodified source SHA-256: +`785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`. +The final extra syscall rule is the only change to the parsed upstream profile. + +Before treating this as a qualified deployment, verify native workspace execution, +credential/symlink/process-metadata denial, permission downgrade rejection, real +model execution, cancellation, daemon/container restart with retained history and +files, and missing-history rejection. An alive container or a selected profile is +not an isolation or public API acceptance result. Qualification applies only to +the selected deployment profile, not complete protocol compatibility or another +engine/provider. + +The optional local file writer uses the existing `agents-api-codex-write` binary +outside `/environment`, with `PARSAR_RUNTIME_WRITE_HELPER` selecting that immutable +executable and `PARSAR_RUNTIME_STAGING=/environment/staging`. Set +`PARSAR_RUNTIME_WORKSPACE=/environment/workspace`; the public file path remains +`/workspace/...` and Core sends only the relative path to the bound Runtime. +Docker mounts the existing volume's `workspace` subdirectory at `/workspace` as +a second view of the same files. The image contains the initial directory before +volume population, so bootstrap works with an empty volume. Native tools receive +only that additional workspace root; staging remains private. Trusted atomic +rename stays within `/environment`. A symlink is insufficient because stock Codex +mounts canonical roots. The qualified Docker version is 29.1.3; the engine must +support volume subpath mounts. Other versions need their own deployment checks. +Workspace and staging must share the same mount for atomic rename. Do not mount +them separately or put daemon/model credentials, native history or other tenants +inside `/environment`. The read-only Runtime can omit both writer settings. + +Hosted Turn output publication additionally requires the immutable +`agents-api-workspace-export` executable selected by +`PARSAR_RUNTIME_EXPORT_HELPER`. The Runtime bundle includes it outside the workspace. +It exports regular files below `outputs` through the authenticated daemon connection; +Core stores immutable copies and publishes them with successful Turn completion. +Use a matched Core/Runtime release: older Runtime images without bounded output +export are ineligible for hosted execution. Listing and downloading already +published artifacts uses the execution database and requires no running Environment. +The export capability does not replace the deployment isolation checks above. + +The installer runs as a trusted bounded daemon child with a minimal environment. +Native tools retain their narrower filesystem policy. Qualify direct reads, +symlink and process-root aliases, attempted staging modification, real uploaded +bytes consumed by Codex, cancellation and retained-history restart before using +this writer profile for public admission. Configuration alone is not that proof. + +## Managed Runtime image and Docker adapter + +Build the existing Rust filesystem helpers with `make build-agents-executor`, and +extract the official npm package `@openai/codex@0.153.4-linux-x64` beneath +`~/.parsar/`. Set `AGENTS_RUNTIME_CODEX_PACKAGE` to its extracted `package` directory +and run `scripts/build-agents-runtime.sh`. It builds the existing daemon and +prepares a binary-only Docker context at `~/.parsar/build/agents-runtime`; build +that context with the printed Docker command. This initial image is Linux amd64. +The package includes the unmodified native executable and matching resources. +It does not contain the product server, product CLI, credentials or workspace data. + +The service's `internal/sandbox` interface has five operations. Its Docker adapter +uses the official Moby Go client and an operator-selected immutable image digest, +network, installation UUID and the contents of this directory's `seccomp.json`. +The Runtime authenticates outward through the ordinary daemon bootstrap path; +`CoreURL` includes the existing `/api/v1` gateway prefix. The image's default +entrypoint is the same daemon connect command used by a user-managed Runtime. +No socket, host home or product configuration is mounted inside the Runtime. + +The caller persists a fresh allocation UUID with the authorized tenant and +Environment before Create, and serializes lifecycle operations for that allocation. +Create returns the reference even on failure. Duplicate allocation creation does +not rewrite credentials or restart the container. After a lost response, inspect +the allocation and reconcile its actual state; do not blindly replay Create. +Core owns durable allocation reconciliation through its internal managed Runtime +coordinator. Public admission requires the explicit operator configuration below. + +Two labelled named volumes retain native state and the workspace/staging pair. +The trusted daemon auth profile is copied with restrictive permissions before +startup; it does not enter image layers, environment variables, labels or arguments. +GetInfo describes observed compute state, not daemon or native readiness. Docker +has no renewable provider lease: Renew verifies the allocation, while Core owns +keepalives and expiry. Kill verifies allocation ownership, removes +the container, explicitly removes its named volumes and confirms absence. Keep the +reference and retry cleanup when an operation fails; an HTTP timeout is not proof +that a resource disappeared. Never use broad container or volume pruning. + +RunCommand is for trusted initialization, using an explicit context deadline, +argument vector and nonroot user. It preserves nonzero status and limits each +output stream to1MiB. Disconnecting an exec stream does not stop the command: +an unconfirmed result requires allocation cleanup before reuse. Routine agent +execution, cancellation and Files continue through Core/daemon/Runtime. + +## Standalone operator configuration + +Set `AGENTS_API_DAEMON_WS_URL` to the outward URL reachable from the Runtime and +`AGENTS_API_MANAGED_RUNTIMES_FILE` to a private JSON file beneath `~/.parsar/`: + +```json +{ + "core_url": "https://core.example/api/v1", + "default_provider": "11111111-1111-4111-8111-111111111111", + "docker": { + "11111111-1111-4111-8111-111111111111": { + "host": "unix:///var/run/docker.sock", + "image": "sha256:", + "network": "bridge", + "seccomp_file": "/absolute/path/to/seccomp.json" + } + } +} +``` + +The provider key identifies this Docker installation permanently. Keep its entry +while any allocation needs cleanup; changing the endpoint requires a new key. +An empty default disables new hosted admission/bootstrap while preserving existing +Session controls, input retry outcomes and cleanup of retained allocations. +V1 supports explicit local Unix Docker sockets, ignoring ambient +`DOCKER_HOST`. Optional `extra_hosts` is trusted operator configuration. No Docker +socket is mounted in a Runtime. User-managed enrollment remains separate work. + +For a trusted model endpoint, `AGENTS_API_EXECUTION_OPTIONS_FILE` can supply the +existing adapter options as a JSON object, including `codex_provider` with `name`, +`base_url`, `bearer_token` and `wire_api`. Protect this file with mode 0600; it is +read at startup, copied for each execution and never persisted as public Session +configuration. Omit it for the adapter's existing model configuration. Changes +require a Core restart. Do not place credentials in public requests or images. + +With the qualified Codex image and default provider configured, create an idle or +initial-text Session using `environment: {"type": "openai_hosted"}`. Core commits +its identity before automatically provisioning it. Queries expose durable +connection status; execution separately prepares the native harness. Session +deletion revokes authority before owned container/volume cleanup. Supported network +policies are enabled and disabled. Templates, populated startup installations, +restricted domains and hosted MCP combinations remain explicit gaps. + +### Environment initialization + +Templates and inline env/setup/npm/Python configuration share the packaged Runtime +initializer. Enable the existing `nested_sandbox: true` Docker provider setting +when admitting these configurations: user commands and package hooks require +bubblewrap user/PID/mount isolation. Runtime execution still uses native Codex +isolation. The image includes the trusted initializer and managed native Bash +hook; do not inject user env into the daemon or app-server launcher. +See [initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). diff --git a/services/agents-api/deploy/codex/requirements.toml b/services/agents-api/deploy/codex/requirements.toml new file mode 100644 index 000000000..f07cebbf8 --- /dev/null +++ b/services/agents-api/deploy/codex/requirements.toml @@ -0,0 +1,42 @@ +allow_managed_hooks_only = true +default_permissions = "managed-workspace" +allowed_approval_policies = ["never"] +[allowed_permission_profiles] +managed-workspace = true +managed-workspace-enabled = true +[permissions.managed-workspace.filesystem] +":minimal" = "read" +"/environment/workspace" = "write" +"/workspace" = "write" +"/environment/initialization" = "read" +"/environment/packages" = "write" +"/environment/packages/system" = "read" +"/tmp/parsar-tool-root" = "read" +[permissions.managed-workspace.network] +enabled = false +[permissions.managed-workspace-enabled] +extends = "managed-workspace" +[permissions.managed-workspace-enabled.network] +enabled = true +[permissions.filesystem] +deny_read = [ + "/environment/staging", + "/home/*/.parsar/parsar-daemon/*/auth.json", + "/home/*/.parsar/parsar-daemon/agent-sessions/*/config.toml", + "/home/*/.parsar/parsar-daemon/agent-sessions/*/sessions", + "/home/*/.parsar/parsar-daemon/agent-sessions/*/archived_sessions", +] + +[features] +hooks = true + +[hooks] +managed_dir = "/etc/codex/runtime-hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" +timeout = 5 diff --git a/services/agents-api/deploy/codex/seccomp.LICENSE b/services/agents-api/deploy/codex/seccomp.LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/services/agents-api/deploy/codex/seccomp.LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/services/agents-api/deploy/codex/seccomp.json b/services/agents-api/deploy/codex/seccomp.json new file mode 100644 index 000000000..a8d83b161 --- /dev/null +++ b/services/agents-api/deploy/codex/seccomp.json @@ -0,0 +1,938 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 41, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 42, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 43, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 44, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 45, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + }, + { + "names": [ + "clone", + "unshare", + "setns", + "mount", + "umount2", + "pivot_root" + ], + "action": "SCMP_ACT_ALLOW" + } + ] +} diff --git a/services/agents-api/deploy/codex/tool-env.py b/services/agents-api/deploy/codex/tool-env.py new file mode 100644 index 000000000..56e1fd4de --- /dev/null +++ b/services/agents-api/deploy/codex/tool-env.py @@ -0,0 +1,27 @@ +"""Managed native hook: transform Bash input, never execute untrusted code.""" +import json +import os +from pathlib import Path +import shlex +import sys + +try: + if os.environ.get('PARSAR_RUNTIME_TOOL_ENV') != '1': + print('{}') + sys.exit(0) + value = json.load(sys.stdin) + command = value['tool_input']['command'] + if value.get('hook_event_name') != 'PreToolUse' or value.get('tool_name') != 'Bash' or not isinstance(command, str): + raise ValueError('invalid hook input') + if not Path('/environment/initialization/tool-env.sh').is_file(): + raise ValueError('missing tool environment') + rewritten = '. /environment/initialization/tool-env.sh && eval -- ' + shlex.quote(command) + if os.environ.get('PARSAR_RUNTIME_SYSTEM_PACKAGES') == '1': + rewritten = '/usr/bin/python3 -I -S /usr/local/bin/agents-api-tool-root ' + shlex.quote(command) + print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', 'updatedInput': { + 'command': rewritten}}})) +except Exception: + # Native exit 2 denies the tool. Never return a partial rewrite or input. + print('Initialized tool configuration unavailable', file=sys.stderr) + sys.exit(2) diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md new file mode 100644 index 000000000..0411ef0f1 --- /dev/null +++ b/services/agents-api/deploy/e2b/README.md @@ -0,0 +1,144 @@ +# E2B colocated Runtime + +E2B implements the existing SandboxProvider's Create, GetInfo, Renew, Kill and +initialization-only RunCommand. Each sandbox contains the same daemon, native +harness, local tools and workspace as the qualified Docker Runtime. Core remains +independent. Daily execution and public Files/Artifacts use daemon/Runtime; they +never use E2B commands or its filesystem service. + +## Build and qualify + +Use the qualified Linux amd64 Docker image for the selected harness. Build the +E2B template on a machine with Docker and Python 3.12+ using `requirements.txt`. The +builder extracts the existing runtime binaries and native profile; it does not +rebuild the harness or add a tool loop. Archive extraction retains read-only native +configuration; extraction errors must not silently omit the profile. Store private keys and build outputs under +`~/.parsar/` and keep them out of the checkout. + +```sh +python -m venv "$HOME/.parsar/build/e2b-sdk" +"$HOME/.parsar/build/e2b-sdk/bin/pip" install -r services/agents-api/deploy/e2b/requirements.txt +"$HOME/.parsar/build/e2b-sdk/bin/python" services/agents-api/deploy/e2b/build-template.py \ + --image sha256:QUALIFIED_RUNTIME_IMAGE_DIGEST \ + --name your-runtime-build \ + --api-key-file "$HOME/.parsar/secrets/e2b.key" \ + --output "$HOME/.parsar/build/e2b-template.json" +``` + +The output's `template` is the official immutable `templateID:build_UUID` +reference. Qualify and deploy that exact reference, never a mutable alias or a +fallback template. Python and the E2B SDK are build/acceptance tools only; the +production Core uses Go and authenticated official REST/Connect transports. + +## Operator configuration + +Set `AGENTS_API_MANAGED_RUNTIMES_FILE` to a private JSON file: + +```json +{ + "core_url": "https://core.example.com/api/v1", + "default_provider": "7d7527e1-c198-4d6a-a807-c4b90e89acb4", + "e2b": { + "7d7527e1-c198-4d6a-a807-c4b90e89acb4": { + "api_key_file": "/private/e2b.key", + "template": "TEMPLATE_ID:BUILD_UUID", + "lease_seconds": 7200 + } + } +} +``` + +Set `AGENTS_API_DAEMON_WS_URL` to +`wss://core.example.com/api/v1/agent-daemon/ws`. Both endpoints must be reachable +from E2B. Select the existing `AGENTS_API_ENGINE` and corresponding private model +provider configuration for the qualified image. No public engine selector is +introduced. Docker and E2B entries share the provider-key namespace; retained +entries remain available for existing allocations and cleanup. Use a new provider +key when changing backend/account ownership. + +The account must allow the configured lease (two hours minimum). This leaves +room for Core's existing one-hour disconnect grace. Core renews the original +running sandbox; no auto-pause, auto-resume, recreation, pool or migration is +implemented. Provider expiry destroys volatile workspace/history; Core reports +failure and must not fabricate a recovered Session or replay execution. + +## Initialization and security boundary + +Core persists its allocation and dedicated credential hash before Create. E2B +metadata carries only installation, tenant, Environment, allocation, Session and +device identifiers. The account key stays in Core. A private root-owned input +injects the existing daemon auth profile, binds the workspace at `/workspace`, +then launches the non-root daemon with the image's explicit native profile. +Model credentials arrive through the existing authenticated execution contract. +Neither credential belongs in template environment, metadata, command arguments, +images or logs. + +E2B clears `/run` at boot and envd commands do not inherit template environment. +Initialization uses `/root/.parsar/e2b` and the root-owned image environment file. +E2B template finalization makes `/usr/local` writable and creates a passwordless +privileged `user` account. The protected `/opt/parsar-e2b/init.py` restores +root-owned executable paths (including injected envd/boot files) and locks that +unused account before starting daemon. These are required corrections to the +[provider's finalization](https://github.com/e2b-dev/runtime/blob/fad70f393e800cee0278669a63976c3aaa00871b/packages/orchestrator/pkg/template/build/phases/finalize/configure.sh), +not changes to the native harness. +Verify actual write and account-transition denial on every qualified template. +Its final atomic receipt distinguishes completed bootstrap from merely running +compute. On uncertain creation/initialization, Core observes the retained exact +allocation or reclaims it; it never retries startup or rotates its credential. +Inspection and cleanup recheck exact metadata ownership, including after restart. +A command timeout/transport failure is an unconfirmed effect, requiring cleanup +before reuse. Cancellation of a Provider request alone does not prove process exit. + +Native sandboxing remains mandatory inside the VM. Qualify actual tool reads, +credential/history isolation, process namespaces, privilege denial, unauthenticated +envd denial, both network policies and exact Core binding with each real harness. +A readable **inner** PID 1 environment is not itself access to the outer daemon; +verify namespace identity and actual sensitive-value/file access. Template builds +and SDK deserialization alone do not qualify deployment. + +## Acceptance scope + +Use a separate execution database, the fixed official OpenAI SDK plus raw HTTP, +real E2B instances and real model APIs. Cover all five Provider operations, actual +native execution, Files upload/list, immutable Artifacts, tenant/auth isolation, +cancellation with stopped effects, daemon/Core reconnect and exact-history recovery +without automatic replay. Keep failure and cleanup evidence. Mock tests do not +substitute for these checks. This deployment does not claim full official protocol +compatibility or add user-managed enrollment, new protocol resources or HA. + +`services/agents-api/tests/official_e2b_v1.py` runs this acceptance against the +packaged `bin/agents-api`, `bin/agents-api-migrate` and `upstream.json`. Use the +fixed OpenAI SDK from `contracts/agents-api/upstream.json`, plus `e2b` from this +directory's requirements. Pass a private JSON file with these operator inputs: + +```json +{ + "engine": "codex", + "model": "YOUR_REAL_MODEL", + "proof_root": "/absolute/private/proofs", + "package": "/absolute/agents-api-package", + "e2b_key_file": "/absolute/private/e2b.key", + "model_key_file": "/absolute/private/model.key", + "database_file": "/absolute/private/dedicated-database.url", + "options_file": "/absolute/private/execution-options.json", + "port": 19341, + "core_public_url": "https://acceptance-core.example.com", + "template": "TEMPLATE_ID:BUILD_UUID", + "native_history_root": "/home/runtime/.parsar/parsar-daemon/agent-sessions", + "psql_command": ["psql", "--dbname=YOUR_PRIVATE_TEST_DATABASE"] +} +``` + +Route the public HTTPS/WebSocket endpoint to the test port. The fixture starts +and crashes its own Core and Runtime, creates billable sandboxes, and deletes +its Sessions and cloud allocations in cleanup. Use a dedicated database and +proof directory. `psql_command` must access that same database and accept `-At -c`; +do not put passwords in its arguments. Set the engine, history root and model +options for each qualified native profile. Failures retain redacted evidence; +direct provider cleanup is reported as failed Core cleanup, not acceptance. + +For initialized-environment regression, enable `verify_environment_templates`, +`verify_initial_files` and `verify_environment_setup` in the private test config. +Add `verify_system_packages` to exercise real apt packages, compilation/linking, +package/setup composition, native tool visibility and the finalized seed's hash +and ownership. This reuses the same public execution and recovery checks. diff --git a/services/agents-api/deploy/e2b/build-template.py b/services/agents-api/deploy/e2b/build-template.py new file mode 100644 index 000000000..3b930d922 --- /dev/null +++ b/services/agents-api/deploy/e2b/build-template.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Package an already qualified Docker Runtime as a pinned E2B template build.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile + +from e2b import Template + +BASE = 'node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27' +parser = argparse.ArgumentParser() +parser.add_argument('--image', required=True, help='Qualified linux/amd64 Runtime image digest') +parser.add_argument('--name', required=True) +parser.add_argument('--api-key-file', type=Path, required=True) +parser.add_argument('--output', type=Path, required=True) +args = parser.parse_args() +if not args.image.startswith('sha256:') or not args.output.is_absolute() or not args.api_key_file.is_absolute(): + parser.error('Use an image digest and absolute private key/output paths') +image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', args.image]))[0] +if image['Architecture'] != 'amd64' or image['Os'] != 'linux': + parser.error('A qualified Linux amd64 image is required') +environment = dict(value.split('=', 1) for value in image['Config']['Env'] + if value.startswith(('HOME=', 'PARSAR_'))) +if environment.get('PARSAR_RUNTIME_WORKSPACE') != '/environment/workspace': + parser.error('Image does not use the colocated Runtime layout') +state = Path.home() / '.parsar/build/e2b' +state.mkdir(parents=True, exist_ok=True) +with tempfile.TemporaryDirectory(dir=state) as temporary: + context = Path(temporary) + tree = context / 'runtime' + tree.mkdir() + container = subprocess.check_output(['docker', 'create', args.image], text=True).strip() + try: + for path in ['/usr/local/bin', '/usr/local/codex-resources', '/etc/codex', '/opt']: + destination = tree / path.lstrip('/') + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryFile() as copied: + result = subprocess.run(['docker', 'cp', container + ':' + path, '-'], + stdout=copied, stderr=subprocess.PIPE) + if result.returncode: + if path in ['/usr/local/codex-resources', '/etc/codex'] and b'Could not find the file' in result.stderr: + continue + raise RuntimeError('Cannot extract Runtime path: ' + path) + copied.seek(0) + with tarfile.open(fileobj=copied) as archive: + # Qualified images contain absolute native executable symlinks. + archive.extractall(destination.parent, filter='tar') + finally: + subprocess.run(['docker', 'rm', container], check=True, stdout=subprocess.DEVNULL) + bundle = context / 'runtime.tar.gz' + with tarfile.open(bundle, 'w:gz') as archive: + for entry in tree.iterdir(): + archive.add(entry, arcname=entry.name) + (context / 'runtime-env.json').write_text(json.dumps(environment)) + (context / 'init.py').write_bytes(Path(__file__).with_name('init.py').read_bytes()) + template = (Template(file_context_path=context).from_image(BASE) + .run_cmd('apt-get update && apt-get install -y --no-install-recommends ' + 'ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat util-linux ' + '&& rm -rf /var/lib/apt/lists/*', user='root') + .copy('runtime.tar.gz', '/root/runtime.tar.gz', user='root') + .copy('runtime-env.json', '/etc/parsar-runtime-env.json', user='root') + .copy('init.py', '/opt/parsar-e2b/init.py', user='root') + .run_cmd('tar --no-same-owner -xzf /root/runtime.tar.gz -C / && rm /root/runtime.tar.gz ' + '&& usermod -l runtime -d /home/runtime node ' + '&& mkdir -p /home/runtime/.parsar /environment/workspace /environment/staging /environment/initialization /environment/packages /workspace ' + '&& chown -R 1000:1000 /home/runtime /environment ' + '&& chmod 0700 /home/runtime/.parsar /environment/staging ' + '&& chmod 0444 /etc/parsar-runtime-env.json ' + '&& chmod 0555 /opt/parsar-e2b /opt/parsar-e2b/init.py', user='root') + .set_user('runtime').set_workdir('/environment/workspace')) + result = Template.build(template, name=args.name, cpu_count=2, memory_mb=2048, + on_build_logs=lambda entry: print(entry.message, flush=True), + api_key=args.api_key_file.read_text().strip()) + report = {'template': result.template_id + ':' + result.build_id, 'image': image['Id'], + 'runtime_sha256': hashlib.sha256(bundle.read_bytes()).hexdigest(), 'base': BASE} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report)) diff --git a/services/agents-api/deploy/e2b/init.py b/services/agents-api/deploy/e2b/init.py new file mode 100644 index 000000000..550fe2a78 --- /dev/null +++ b/services/agents-api/deploy/e2b/init.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""One-shot trusted bootstrap; never a model/tool execution service.""" +import json +import os +from pathlib import Path +import subprocess + +root = Path('/root/.parsar/e2b') +root.mkdir(mode=0o700, parents=True, exist_ok=True) +root.chmod(0o700) +source = root / 'bootstrap.json' +receipt = root / 'ready.json' +if receipt.exists(): + raise RuntimeError('Runtime initialization cannot be replayed') +bootstrap = json.loads(source.read_text()) +# E2B finalization makes /usr/local world-writable after template commands. +# Restore trusted executable ownership before launching the unprivileged Runtime. +subprocess.run(['chown', '-R', 'root:root', '/usr/local'], check=True) +subprocess.run(['chmod', '-R', 'go-w', '/usr/local'], check=True) +os.chmod('/usr/local/bin/agents-api-tool-root', 0o555) +# The provider also injects these root service/boot files with mode 0777. +for protected in ['/usr/bin/envd', '/etc/inittab', '/etc/init.d/rcS']: + # Some cloud images omit rcS after boot; no absent startup file needs access. + if protected == '/etc/init.d/rcS' and not Path(protected).exists(): + continue + os.chown(protected, 0, 0) + os.chmod(protected, 0o755) +# E2B also provisions an unused passwordless sudo account. Only root bootstrap +# and the explicit runtime account are used by this deployment. +subprocess.run(['usermod', '--lock', '--shell', '/usr/sbin/nologin', 'user'], check=True) +environment = json.loads(Path('/etc/parsar-runtime-env.json').read_text()) +environment.update( + PATH='/usr/local/bin:/usr/bin:/bin', + PARSAR_RUNTIME_ENVIRONMENT_ID=bootstrap['EnvironmentID'], + PARSAR_RUNTIME_SESSION_ID=bootstrap['session_id'], + PARSAR_RUNTIME_NETWORK_ACCESS=bootstrap['network_access'] or 'enabled', +) +subprocess.run(['mount', '--bind', '/environment/workspace', '/workspace'], check=True) +profile = Path('/home/runtime/.parsar/parsar-daemon/default') +profile.mkdir(mode=0o700, parents=True, exist_ok=True) +for directory in [Path('/home/runtime'), Path('/home/runtime/.parsar'), profile.parent, profile, + Path('/environment/workspace'), Path('/environment/staging'), + Path('/environment/initialization'), Path('/environment/packages')]: + os.chown(directory, 1000, 1000) + directory.chmod(0o700) +auth = profile / 'auth.json' +with auth.open('x') as stream: + json.dump({key: bootstrap[key] for key in ['server_url', 'runtime_id', 'runner_credential']}, stream) +auth.chmod(0o600) +os.chown(auth, 1000, 1000) +source.unlink() +log = Path('/home/runtime/.parsar/parsar-daemon/default/daemon.log') +with log.open('xb') as stream: + os.fchmod(stream.fileno(), 0o600) + os.fchown(stream.fileno(), 1000, 1000) + subprocess.Popen(['/usr/local/bin/parsar-daemon', 'connect', '--profile', 'default'], + cwd='/environment/workspace', env=environment, user=1000, group=1000, + extra_groups=[], start_new_session=True, stdin=subprocess.DEVNULL, + stdout=stream, stderr=subprocess.STDOUT, umask=0o077) +# Last mutating step. A lost response may observe this receipt but never rerun +# credential injection or daemon startup. A connected daemon is checked by Core. +temporary = root / 'ready.tmp' +with temporary.open('x') as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump({key: bootstrap[key] for key in + ['TenantID', 'EnvironmentID', 'AllocationID', 'session_id', 'runtime_id']}, stream) + stream.flush() + os.fsync(stream.fileno()) +os.replace(temporary, receipt) diff --git a/services/agents-api/deploy/e2b/requirements.txt b/services/agents-api/deploy/e2b/requirements.txt new file mode 100644 index 000000000..cd2b36b6f --- /dev/null +++ b/services/agents-api/deploy/e2b/requirements.txt @@ -0,0 +1 @@ +e2b==2.51.0 diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile new file mode 100644 index 000000000..66e6f63af --- /dev/null +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -0,0 +1,30 @@ +# Build context contains the daemon, shared helpers, published CLI and tool companion. +FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment/workspace /workspace /home/runtime +COPY build-system-seed.py /tmp/build-system-seed.py +RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py +COPY --chmod=0555 tool-root.py /usr/local/bin/agents-api-tool-root +COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ +COPY mcode /opt/mcode +COPY mcode-harness /opt/mcode-harness +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize +ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ + PARSAR_MCODE_NODE=/usr/local/bin/node \ + PARSAR_MCODE_BIN=/opt/mcode/cli.js \ + PARSAR_MCODE_WORKSPACE_BRIDGE=/opt/mcode-harness/bridge.mjs \ + PARSAR_MCODE_AGENTS_API=1 \ + PARSAR_MCODE_WORKSPACE=managed \ + PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ + PARSAR_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/agents-api-codex-directory \ + PARSAR_RUNTIME_WRITE_HELPER=/usr/local/bin/agents-api-codex-write \ + PARSAR_RUNTIME_EXPORT_HELPER=/usr/local/bin/agents-api-workspace-export \ + PARSAR_RUNTIME_STAGING=/environment/staging +USER 1000:1000 +RUN node /opt/mcode-harness/check.mjs && /opt/mcode/cli.js --version +WORKDIR /environment/workspace +ENTRYPOINT ["/usr/local/bin/parsar-daemon"] +CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md new file mode 100644 index 000000000..e48ef640d --- /dev/null +++ b/services/agents-api/deploy/mcode/README.md @@ -0,0 +1,160 @@ +# MiniMax Code Runtime + +MiniMax Code uses the same Core/Runtime execution contract as the other engines. +The text profile supports `environment:none`; the dedicated Docker profile adds +workspace execution and the shared Files/Artifacts path. See +[workspace qualification](../../../../contracts/agents-api/mcode-workspace-v1.md) +for exact acceptance evidence and limits. +Public MCP/functions, image input and native Subagent execution remain outside +this batch. + +## Pinned prerequisite + +Use the official [`@minimax-ai/code`](https://github.com/MiniMax-AI/minimax-code) +package version **0.4.12**, with Node.js 22.x and its native SQLite dependency. The Docker image pins Node.js +22.23.1; the text fixture used 22.22.0. +The inspected upstream source is `33b259bbbeb1c16433390869938191d09bdb0680`. +Install outside the checkout, under a private operator directory in `~/.parsar/`. +Check the native install succeeds and `mcode --version` reports exactly 0.4.12. +This profile runs on a trusted execution host. + +Set `PARSAR_MCODE_BIN` to that absolute executable and `PARSAR_MCODE_AGENTS_API=1` +for the daemon. The opt-in only advertises the profile for the qualified version. +Use the existing authenticated daemon connection and operator device enrollment; +this is not a new public enrollment API or official `self_hosted` implementation. +Set `AGENTS_API_ENGINE=mcode` in the independent Core deployment. Existing Sessions +retain their engine. Do not expose a new public harness selector. + +## Docker workspace + +Build the shared workspace helpers, install the published CLI into a private +operator directory, and build the companion from the pinned native source: + +```sh +MCODE_NATIVE_SOURCE=/absolute/minimax-code bash scripts/build-mcode-harness.sh +MCODE_CLI_DIR=/absolute/published-package \ +MCODE_HARNESS_BUILD_DIR=/absolute/built-companion \ +bash scripts/build-mcode-runtime.sh +docker build --platform linux/amd64 -t agents-runtime:mcode \ + "${PARSAR_HOME:-$HOME/.parsar}/build/mcode-runtime" +``` + +Configure Core's existing managed Docker provider with the immutable image ID, +`deploy/codex/seccomp.json` and `nested_sandbox: true`. Core, database ownership, +enrollment and the public protocol remain shared. The image supplies the private +`PARSAR_MCODE_WORKSPACE=managed` and companion paths; caller Agent options cannot +change them. Public Files and Artifacts use the common bound workspace helpers. + +The native process and ACP Session use a private control directory, while six +original native tools execute against `/workspace` through one trusted MCP bridge +and the upstream Linux sandbox. MCP is internal transport here; it does not enable +caller-supplied public MCP servers. Project instructions must be read through the +workspace tools. Native automatic project configuration and diff/undo capture do +not apply to this isolated tool path. Exact native-ID continuation remains +required; recovery without a recorded ID fails closed. Native Bash observations +become public `command_execution` items after command arguments arrive. Their +text output and status are retained; absent native exit code/duration stay unknown. +The private MCP server and file/skill/task utilities are not invented public MCP +or function calls. + +## Provider configuration + +Keep `AGENTS_API_EXECUTION_OPTIONS_FILE` private (mode 0600). Supply the existing +`mcode_provider` native custom-provider shape, for example: + +```json +{ + "mcode_provider": { + "name": "Parsar", + "kind": "custom", + "enabled": true, + "npm": "@ai-sdk/anthropic", + "options": { + "baseURL": "https://api.moonshot.cn/anthropic", + "apiKey": "" + }, + "models": { + "kimi-k3": { + "name": "Kimi K3", + "tool_call": true, + "limit": {"context": 64000, "output": 4096} + } + } + } +} +``` + +The public Agent model must appear in this managed provider's native model list. +The adapter selects it explicitly; it does not use a native account fallback. +Use the real provider endpoint for the selected credential. Test proxies may +forward requests unchanged and capture tool names/status, but must not synthesize +model responses or log keys. + +## Execution boundaries + +Each API Session uses a separate native state directory and cwd. The execution +child inherits only process and model-network essentials; it does not inherit +Core/daemon tokens or arbitrary Node startup configuration. The adapter owns its +native config, instructions and home and disables external skills, delegated work, +web search, builtin file/shell tools, browser tools, mcode-tools and native goals. +The workspace profile adds only its trusted isolated tool bridge. + +The native model may still see `skill`, `task_query`, `task_output` and `task_stop`. +The first loads an exact registered skill name and cannot execute a script; the +configured builtin/external skill catalog is empty. Task utilities cannot create +work and enforce native Session ownership. Auxiliary native title requests are +internal bookkeeping. Do not equate these with public function or workspace tools. + +ACP `mcode/session/steer` acknowledges acceptance for the active native Turn, +separately from the transport write. It is not a promise that the model consumed +that input before cancellation. Unknown outcomes are never automatically replayed. +Cancellation waits for process-group exit and output settlement. Public slash +text remains a model message instead of invoking native ACP operator commands. + +Cold continuation requires the exact persisted native Session ID and matching +private cwd. Missing/foreign history fails; recovery by guessing an ID from native +session listings is not qualified. Public usage breakdown is unavailable because +native ACP context occupancy and cumulative cost are not per-Turn usage. + +## Acceptance + +The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, +raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. +Provide private `PARSAR_MCODE_REAL_OPTIONS` (the provider object above plus the +`model` string), `PARSAR_MCODE_BIN`, `PARSAR_NATIVE_DAEMON_BIN`, +`PARSAR_NATIVE_PROOF_DIR`, `PARSAR_OFFICIAL_SDK_PYTHON` and +`PARSAR_AGENTS_API_TEST_DATABASE_URL`, then run: + +```sh +go test ./services/agents-api/internal/store \ + -run '^TestNativeMCodePublicExecution$' -count=1 -v -timeout=15m +``` + +It verifies real text execution, same-Turn steering and durable input receipt, +daemon cold restart with the same native ID, ordinary slash text, foreign-tenant +rejection, cancellation and subsequent continuation. Run independently with real +Kimi and MiniMax options. This fixture creates only operator device credentials +privately; all tested Sessions and inputs enter through public HTTP. + +`TestNativeMCodeHistoryIsolation` additionally takes +`PARSAR_MCODE_FOREIGN_NATIVE_ID` from a successful public run and verifies rejection +in another private native home, plus rejection of a nonexistent history ID. Run it +in the daemon's `internal/agent/mcode` package with the same private native/provider +options. It must fail before model input, without a replacement native Session. + +On 2026-09-19, the public fixture passed independently with real Kimi K3 and +MiniMax M2.7 APIs. Both exercised execution, native steering receipts, daemon +restart, history continuation, tenant rejection and cancel/continue. Native +missing/foreign history rejection passed separately. Credential scans found the +provider key only in each private mode-0600 native config, not in logs, public +results or native history. Product ACP regression uses the same 0.4.12 package +and covers new/resume, model/instruction refresh, Skill discovery and MCP using +its existing synthetic provider fixture. + +That text acceptance used an in-process Core HTTP server and a separate real +daemon. The subsequent [Docker workspace qualification](../../../../contracts/agents-api/mcode-workspace-v1.md) +uses independently built Core binaries, its own database and managed Runtime. +It covers public Files/Artifacts, cancellation, reconnect, exact-history recovery +and isolation with real Kimi and MiniMax APIs. Read its recorded Kimi continuation +limitation: new model-issued commands are not automatic recovery replay. Neither +acceptance establishes complete official protocol compatibility. diff --git a/services/agents-api/deploy/runtime/build-system-seed.py b/services/agents-api/deploy/runtime/build-system-seed.py new file mode 100644 index 000000000..b1dd9c954 --- /dev/null +++ b/services/agents-api/deploy/runtime/build-system-seed.py @@ -0,0 +1,46 @@ +"""Run during image construction, before installing any Runtime or harness code.""" +import hashlib +import json +from pathlib import Path +import tarfile + + +OUTPUT = Path('/opt/agents-runtime') +EXCLUDED = {'etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id', + 'etc/mtab', 'etc/shadow', 'etc/gshadow', 'opt/agents-runtime'} + + +def member(info): + if any(info.name == path or info.name.startswith(path + '/') for path in EXCLUDED): + return None + return info + + +def main(): + OUTPUT.mkdir(mode=0o755) + seed = OUTPUT / 'system-root.tar.gz' + # Preserve the matching package database and all base tool symlink targets. + with tarfile.open(seed, 'w:gz', compresslevel=1, dereference=False) as archive: + for name in ('usr', 'bin', 'sbin', 'lib', 'lib64', 'opt', 'etc', + 'var/lib/dpkg', 'var/lib/apt', 'var/cache/debconf'): + path = Path('/') / name + if path.exists() or path.is_symlink(): + archive.add(path, arcname=name, filter=member) + for name in ('dev', 'proc', 'sys', 'tmp', 'run', 'home', 'root', 'workspace', + 'environment', 'var/log', 'var/cache/apt/archives/partial', + 'var/lib/apt/lists/partial'): + info = tarfile.TarInfo(name) + info.type, info.mode = tarfile.DIRTYPE, 0o755 + archive.addfile(info) + for name in ('etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id'): + archive.addfile(tarfile.TarInfo(name)) + seed.chmod(0o444) + with seed.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + manifest = OUTPUT / 'system-root.json' + manifest.write_text(json.dumps({'version': 1, 'sha256': digest, 'size_bytes': seed.stat().st_size}) + '\n') + manifest.chmod(0o444) + + +if __name__ == '__main__': + main() diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py new file mode 100644 index 000000000..caba7df92 --- /dev/null +++ b/services/agents-api/deploy/runtime/initialize.py @@ -0,0 +1,237 @@ +"""Trusted hosted initialization. Invoke with /usr/bin/python3 -I -S. + +Core owns sequencing and the completion ledger. This helper executes one bounded +operation; it never retries, schedules, selects a harness or interprets templates. +""" +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import runpy +import shlex +import subprocess +import sys + +ROOT = Path('/environment') +CONFIG = ROOT / 'initialization' +PACKAGES = ROOT / 'packages' +ENV_FILE = CONFIG / 'tool-env.sh' +CAPABILITIES = CONFIG / 'capabilities' +SKILLS = CAPABILITIES / 'skills' +MAX_INPUT = 32 * 1024 * 1024 +BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} +DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def open_directory(parent, name): + """Do not follow user-created directory aliases outside the Runtime roots.""" + return os.open(name, DIRECTORY, dir_fd=parent) + + +def roots(): + root = os.open('/', DIRECTORY) + try: + environment = open_directory(root, 'environment') + finally: + os.close(root) + try: + for name in ('workspace', 'packages', 'initialization'): + child = open_directory(environment, name) + os.close(child) + finally: + os.close(environment) + + +def install_skill(request): + name, files = request['name'], request['files'] + if not isinstance(name, str) or not re.fullmatch('[a-z0-9]+(?:[-_][a-z0-9]+)*', name) or len(name) > 64 or not isinstance(files, list) or not 0 < len(files) <= 1000: + raise ValueError('invalid skill') + directory = os.open(SKILLS, DIRECTORY) + try: + os.mkdir(name, mode=0o700, dir_fd=directory) + skill = open_directory(directory, name) + finally: + os.close(directory) + total = 0 + try: + for file in files: + relative = file['path'] + if not isinstance(relative, str) or len(relative) > 4096 or any(c in relative for c in ('\\', '\x00', '\r', '\n')): + raise ValueError('invalid skill path') + parts = relative.split('/') + if any(not part or part in ('.', '..') for part in parts): + raise ValueError('invalid skill path') + body = base64.b64decode(file['data'], validate=True) + total += len(body) + if total > 20 * 1024 * 1024: + raise ValueError('skill too large') + parent = os.dup(skill) + try: + for part in parts[:-1]: + try: + os.mkdir(part, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + child = open_directory(parent, part) + os.close(parent) + parent = child + # A fresh installation cannot replace an existing member. + try: + os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise ValueError('duplicate skill member') + result = subprocess.run(['/usr/local/bin/agents-api-codex-write', str(SKILLS / name), relative, + str(len(body)), str(ROOT / 'staging')], + input=body + hashlib.sha256(body).digest(), env=BASE_ENV, + capture_output=True, check=True) + receipt = json.loads(result.stdout) + if result.stderr or receipt != {'version': 1, 'outcome': 'completed', 'size_bytes': len(body)}: + raise ValueError('skill write unconfirmed') + fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent) + try: + os.fchmod(fd, 0o500 if file.get('executable', False) else 0o400) + os.fsync(fd) + finally: + os.close(fd) + finally: + os.close(parent) + os.fsync(skill) + finally: + os.close(skill) + + +def configure(env): + if not isinstance(env, dict) or any( + not isinstance(name, str) or not re.fullmatch('[A-Za-z_][A-Za-z0-9_]*', name) + or not isinstance(value, str) or '\x00' in value + for name, value in env.items() + ): + raise ValueError('invalid environment') + # These Runtime-owned defaults are applied inside the sandbox, never to its + # launcher. Public reserved-name validation belongs to Core. + values = { + 'PATH': '/environment/packages/npm/bin:/environment/packages/python/bin:' + BASE_ENV['PATH'], + **env, + 'PYTHONPATH': '/environment/packages/python' + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), + } + materialized = json.dumps(values, ensure_ascii=True) + script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) + environment = os.open(CONFIG, DIRECTORY) + try: + os.mkdir('capabilities', mode=0o700, dir_fd=environment) + capabilities = open_directory(environment, 'capabilities') + try: + os.mkdir('skills', mode=0o700, dir_fd=capabilities) + finally: + os.close(capabilities) + finally: + os.close(environment) + directory = os.open(CONFIG, DIRECTORY) + try: + fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + with os.fdopen(fd, 'w') as stream: + stream.write(script) + stream.flush() + os.fsync(stream.fileno()) + fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + with os.fdopen(fd, 'w') as stream: + stream.write(materialized) + stream.flush() + os.fsync(stream.fileno()) + os.fsync(directory) + finally: + os.close(directory) + + +def sandbox(network, cwd): + if network not in ('enabled', 'disabled') or not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: + raise ValueError('invalid execution configuration') + if (CONFIG / 'system-root.json').exists(): + tools = runpy.run_path('/usr/local/bin/agents-api-tool-root') + if not tools['installed'](): + raise ValueError('system tools unavailable') + return tools['initialization_sandbox'](cwd, network) + # One packaging contract for every Provider/harness. No native state, daemon + # credential, staging payload or parent process is visible in this mount map. + args = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', + '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', + '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', + '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', + '--symlink', 'usr/lib64', '/lib64', '--dir', '/etc', + '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', + '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home', + '--dir', '/environment', '--bind', str(ROOT / 'workspace'), '/workspace', + '--bind', str(PACKAGES), str(PACKAGES), '--ro-bind', str(CONFIG), str(CONFIG)] + if network == 'disabled': + args += ['--unshare-net'] + for key, value in BASE_ENV.items(): + args += ['--setenv', key, value] + return args + ['--chdir', cwd, '--'] + + +def run(request): + action = request['action'] + if action == 'configure': + configure(request['env']) + return + if action == 'skill': + install_skill(request) + return + if action == 'system': + runpy.run_path('/usr/local/bin/agents-api-tool-root')['install'](request['packages']) + return + args = sandbox(request['network'], request.get('cwd', '/workspace')) + if action == 'setup': + command = request['command'] + if not isinstance(command, str) or not command or '\x00' in command: + raise ValueError('invalid setup command') + # Source confidential values only inside isolation. eval preserves the + # shell's cwd, unlike replacing the native shell with a child wrapper. + args += ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', command] + elif action in ('npm', 'python'): + packages = request['packages'] + if not isinstance(packages, list) or not packages or any( + not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') + for package in packages + ): + raise ValueError('invalid packages') + command = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] + if action == 'python': + command = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', + '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] + args += ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command] + else: + raise ValueError('invalid action') + # Setup/package output can contain arbitrary confidential values. The public + # completion receipt deliberately contains no child stdout/stderr or command. + subprocess.run(args, env=BASE_ENV, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + + +def main(): + try: + raw = sys.stdin.buffer.read(MAX_INPUT + 1) + if len(raw) > MAX_INPUT: + raise ValueError('input too large') + request = json.loads(raw) + if not isinstance(request, dict) or request.get('version') != 1: + raise ValueError('invalid version') + roots() + run(request) + except Exception: + # Never serialize an exception that could contain input or process args. + print('{"version":1,"outcome":"failed"}') + return 1 + print('{"version":1,"outcome":"completed"}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py new file mode 100644 index 000000000..70e19dbea --- /dev/null +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -0,0 +1,119 @@ +"""Real Linux initialization checks; run inside a disposable packaged Runtime. + +The fixture must expose writable workspace/packages/initialization roots and +support the same nested isolation as its deployed Provider. No model is mocked; +these checks exercise initialization only, not public native-model acceptance. +""" +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +HELPER = '/usr/local/bin/agents-api-runtime-initialize' +CANARY = 'private-initialization-canary-47a8' + + +def invoke(action, *, succeeds=True, **fields): + payload = json.dumps({'version': 1, 'action': action, 'network': 'enabled', **fields}) + result = subprocess.run(['/usr/bin/python3', '-I', '-S', HELPER], input=payload, + text=True, capture_output=True, timeout=120) + expected = 'completed' if succeeds else 'failed' + assert result.returncode == (0 if succeeds else 1), (action, result.returncode) + assert result.stderr == '', (action, 'unexpected stderr') + assert json.loads(result.stdout) == {'version': 1, 'outcome': expected}, action + assert CANARY not in result.stdout + result.stderr, 'confidential output exposed' + + +def main(): + for name in ('workspace', 'packages', 'initialization', 'private', 'staging'): + Path('/environment', name).mkdir(exist_ok=True) + Path('/environment/private/credential').write_text(CANARY) + Path('/environment/staging/request').write_text(CANARY) + os.environ['DAEMON_PRIVATE_CANARY'] = CANARY + env = {'INITIALIZATION_VALUE': CANARY, 'WITH_QUOTES': "'\n$(false)"} + if os.environ.get('PARSAR_TEST_PACKAGE_PROXY'): + env.update(http_proxy=os.environ['PARSAR_TEST_PACKAGE_PROXY'], + https_proxy=os.environ['PARSAR_TEST_PACKAGE_PROXY']) + invoke('configure', env=env) + skill = [{'path': 'SKILL.md', 'data': base64.b64encode(b'---\nname: proof\ndescription: A proof.\n---\nRead check.sh.').decode()}, + {'path': 'scripts/check.sh', 'data': base64.b64encode(b'#!/bin/sh\nprintf skill-proof').decode(), 'executable': True}, + {'path': 'data.bin', 'data': base64.b64encode(bytes(range(256))).decode()}] + invoke('skill', name='proof', files=skill) + assert Path('/environment/initialization/capabilities/skills/proof/data.bin').read_bytes() == bytes(range(256)) + assert Path('/environment/initialization/capabilities/skills/proof/scripts/check.sh').stat().st_mode & 0o777 == 0o500 + invoke('skill', succeeds=False, name='proof', files=skill) + invoke('skill', succeeds=False, name='invalid', files=[{'path': '../../private/credential', 'data': 'YmFk'}]) + assert Path('/environment/private/credential').read_text() == CANARY + invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > /workspace/skill-result') + assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' + if '--system' in sys.argv: + invoke('system', packages=['jq', 'build-essential', 'libpq-dev']) + invoke('system', succeeds=False, packages=['jq']) + invoke('setup', command='''set -eu +test ! -e /usr/local/bin/parsar-daemon +test ! -e /usr/local/bin/agents-api-tool-root +test ! -e /opt/agents-runtime/system-root.tar.gz +printf '{"value":42}' | jq -e '.value == 42' +printf '#include \nint main(void){return PQlibVersion() > 0 ? 0 : 1;}\n' > /workspace/link.c +cc -I/usr/include/postgresql /workspace/link.c -lpq -o /workspace/link +/workspace/link +! touch /usr/bin/changed +! touch /environment/packages/system/usr/bin/changed +node -e 'if (1 + 1 !== 2) process.exit(1)' +''') + + # Re-entry must not replace confidential configuration after any effects. + invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) + invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') + assert Path('/environment/workspace/first').read_text() == CANARY + check = '''import os, pathlib, socket +for path in ('/environment/private/credential', '/environment/staging/request', '/home/runtime/.parsar'): + assert not pathlib.Path(path).exists(), path +assert 'DAEMON_PRIVATE_CANARY' not in os.environ +assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' +assert os.environ['WITH_QUOTES'] == "'\\n$(false)" +for p in pathlib.Path('/proc').glob('[0-9]*/environ'): + assert b'DAEMON_PRIVATE_CANARY=' not in p.read_bytes() +for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh', '/environment/initialization/capabilities/skills/proof/SKILL.md'): + try: pathlib.Path(path).write_text('bad') + except OSError: pass + else: raise AssertionError(path) +pathlib.Path('/environment/packages/visible').write_text('ok') +assert len(socket.if_nameindex()) == 1 +''' + Path('/environment/workspace/check.py').write_text(check) + invoke('setup', network='disabled', command='/usr/bin/python3 /workspace/check.py') + # Shell cwd is explicit and ordered effects survive between invocations. + Path('/environment/workspace/sub').mkdir() + if '--system' in sys.argv: + for cwd in ['/environment/workspace', '/environment/workspace/sub']: + result = subprocess.run( + ['/usr/bin/bwrap', '--bind', '/', '/', + '--bind', '/environment/workspace', '/workspace', '--', + '/usr/bin/python3', '-I', '/usr/local/bin/agents-api-tool-root', + "pwd; printf '{\"value\":42}' | jq -r .value"], + cwd=cwd, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + assert result.stdout == cwd + '\n42\n', result.stdout + invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') + assert Path('/environment/workspace/sub/second').read_text() == '/workspace/sub\n' + invoke('setup', succeeds=False, command='echo secret; echo secret >&2; exit 7') + invoke('setup', succeeds=False, cwd='/missing', command='touch /workspace/should-not-exist') + assert not Path('/environment/workspace/should-not-exist').exists() + invoke('setup', command='setsid /bin/bash -c "sleep 2; touch /workspace/descendant" >/dev/null 2>&1 &') + time.sleep(3) + assert not Path('/environment/workspace/descendant').exists(), 'detached setup descendant survived' + if '--packages' in sys.argv: + # Actual public registries, not synthetic package fixtures. + invoke('npm', packages=['is-number@7.0.0']) + invoke('python', packages=['packaging==26.0']) + invoke('setup', cwd='/workspace/sub', command="node -e \"if (!require('/environment/packages/npm/lib/node_modules/is-number')(42)) process.exit(1)\" && python3 -c 'import packaging; assert packaging.__version__ == \"26.0\"'") + print(json.dumps({'initialization': 'passed', 'real_packages': '--packages' in sys.argv, + 'system_packages': '--system' in sys.argv})) + + +if __name__ == '__main__': + main() diff --git a/services/agents-api/deploy/runtime/tool-root.py b/services/agents-api/deploy/runtime/tool-root.py new file mode 100644 index 000000000..d436331e4 --- /dev/null +++ b/services/agents-api/deploy/runtime/tool-root.py @@ -0,0 +1,116 @@ +#!/usr/bin/python3 -I +"""Trusted entry into an Environment's installed system tools.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + + +ROOT = Path('/environment/packages/system') +CONFIG = Path('/environment/initialization') +SEED = Path('/opt/agents-runtime/system-root.tar.gz') +BASE_ENV = {'PATH': '/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'HOME': '/tmp', 'TMPDIR': '/tmp', 'LANG': 'C.UTF-8'} +MARKER = CONFIG / 'system-root.json' + + +def installed(): + try: + if json.loads(MARKER.read_text()) != {'version': 1}: + raise ValueError('invalid system tools receipt') + return True + except FileNotFoundError: + return False + + +def sandbox(cwd, *, writable=False, network='enabled', workspace='/workspace', scratch=None): + if not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: + raise ValueError('invalid working directory') + args = ['/usr/bin/bwrap', '--unshare-user', '--uid', '0', '--gid', '0', + '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--die-with-parent', + '--cap-drop', 'ALL', '--bind' if writable else '--ro-bind', str(ROOT), '/', + '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', + '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--tmpfs', '/home', + '--bind', workspace, '/workspace', '--dir', '/environment', + '--bind', workspace, '/environment/workspace', + '--bind', '/environment/packages', '/environment/packages', + '--ro-bind', str(ROOT), str(ROOT), '--ro-bind', str(CONFIG), str(CONFIG)] + if network == 'disabled': + args += ['--unshare-net'] + elif network != 'enabled': + raise ValueError('invalid network configuration') + if scratch: + args += ['--bind', scratch, scratch] + return args + ['--chdir', cwd, '--'] + + +def initialization_sandbox(cwd, network='enabled', *, writable=False): + args = sandbox(cwd, writable=writable, network=network, workspace='/environment/workspace') + args[1:1] = ['--new-session', '--clearenv'] + for key, value in {**BASE_ENV, 'DEBIAN_FRONTEND': 'noninteractive'}.items(): + args[-1:-1] = ['--setenv', key, value] + return args + + +def install(packages): + if not isinstance(packages, list) or not packages or any( + not isinstance(p, str) or not p or p.startswith('-') or '\x00' in p for p in packages + ): + raise ValueError('invalid packages') + manifest = json.loads(SEED.with_name('system-root.json').read_text()) + with SEED.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + if manifest != {'version': 1, 'sha256': digest, 'size_bytes': SEED.stat().st_size}: + raise ValueError('invalid system seed') + # The immutable build artifact predates credentials; never snapshot a live Runtime. + ROOT.mkdir(mode=0o700) + subprocess.run(['/usr/bin/tar', '--no-same-owner', '--no-same-permissions', '-xzf', str(SEED), '-C', str(ROOT)], + check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + args = initialization_sandbox('/workspace', writable=True) + # Namespace root maps only to the unprivileged Runtime UID; _apt is unmapped. + apt = ['/usr/bin/apt-get', '-o', 'APT::Sandbox::User=root', '-o', 'Acquire::Retries=0'] + for command in (apt + ['update'], apt + ['install', '-y', '--no-install-recommends', '--', *packages]): + subprocess.run(args + ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command], + check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + fd = os.open(MARKER, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o400) + with os.fdopen(fd, 'w') as stream: + stream.write('{"version":1}\n') + stream.flush() + os.fsync(stream.fileno()) + + +def main(): + if len(sys.argv) != 2 or not installed(): + raise ValueError('system tools unavailable') + env = dict(BASE_ENV) + scratch = os.environ.get('PARSAR_RUNTIME_TOOL_SCRATCH') + if scratch: + path = Path(scratch) + temporary = Path(os.environ.get('TMPDIR', scratch)) + if not path.is_absolute() or path == Path('/') or path.resolve(strict=True) != path or not path.is_dir(): + raise ValueError('invalid Runtime scratch') + if temporary.resolve(strict=True) != temporary or not temporary.is_dir() or not temporary.is_relative_to(path): + raise ValueError('invalid native temporary directory') + env['TMPDIR'] = str(temporary) + # Native sandbox networking already selected the proxy and namespace. + for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'): + if key in os.environ: + env[key] = os.environ[key] + args = sandbox(os.getcwd(), scratch=scratch) + args += ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', sys.argv[1]] + os.execve(args[0], args, env) + + +if __name__ == '__main__': + try: + main() + except Exception: + print('Initialized system tools unavailable', file=sys.stderr) + sys.exit(1) diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go new file mode 100644 index 000000000..157f1c963 --- /dev/null +++ b/services/agents-api/internal/api/agents.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type AgentStore interface { + DeleteAgent(context.Context, string, string) (string, error) + UpdateAgent(context.Context, string, string, store.UpdateAgentInput) (store.SavedAgent, error) + ListAgents(context.Context, string, string, int, bool) (store.AgentPage, error) + CreateAgent(context.Context, string, store.CreateAgentInput) (store.SavedAgent, error) + GetAgent(context.Context, string, string) (store.SavedAgent, error) +} + +// @Summary Create a reusable Agent +// @Description Persists configuration independently of execution. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants, web_search and public retry conformance remain incomplete. Session execution admits only its supported configuration subset. +// @Tags Agents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.CreateAgentRequest true "Reusable Agent configuration" +// @Success 200 {object} v1.SavedAgent +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /agents [post] +func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent creation does not accept query parameters.") + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var request v1.CreateAgentRequest + if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + return + } + input, err := resolveSavedAgent(request) + if err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + return + } + agent, err := h.store.CreateAgent(r.Context(), tenantID(r), input) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgent(w, r, agent) +} + +// @Summary Retrieve a reusable Agent +// @Description Reads the saved resource owned by the authenticated tenant, independently of execution Sessions. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Success 200 {object} v1.SavedAgent +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [get] +func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent retrieval does not accept query parameters.") + return + } + agent, err := h.lookupAgent(r.Context(), tenantID(r), chi.URLParam(r, "agent_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgent(w, r, agent) +} + +func (h *Handler) respondAgent(w http.ResponseWriter, r *http.Request, agent store.SavedAgent) { + response, err := agentResponse(agent) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +func agentResponse(agent store.SavedAgent) (v1.SavedAgent, error) { + var response v1.SavedAgent + if err := json.Unmarshal(agent.Configuration, &response.SavedAgentConfiguration); err != nil { + return response, err + } + response.ID, response.Object = agent.ID, "agent" + response.Metadata = agent.Metadata + response.CreatedAt, response.UpdatedAt = agent.CreatedAt.Unix(), agent.UpdatedAt.Unix() + return response, nil +} + +func (h *Handler) lookupAgent(ctx context.Context, tenant, id string) (store.SavedAgent, error) { + if !validAgentID(id) { + return store.SavedAgent{}, store.ErrNotFound + } + return h.store.GetAgent(ctx, tenant, id) +} + +func validAgentID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil +} diff --git a/services/agents-api/internal/api/agents_delete.go b/services/agents-api/internal/api/agents_delete.go new file mode 100644 index 000000000..449d8af64 --- /dev/null +++ b/services/agents-api/internal/api/agents_delete.go @@ -0,0 +1,46 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Delete a reusable Agent +// @Description Deletes only the authenticated tenant's saved configuration. Existing Session snapshots, history and recorded creation retry identities remain independent. Missing and repeated deletion locally return404; exact hosted error and in-flight creation/deletion semantics remain unverified. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Success 200 {object} v1.AgentDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [delete] +func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent deletion does not accept query parameters.") + return + } + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent deletion does not accept a request body.") + return + } + id := chi.URLParam(r, "agent_id") + if !validAgentID(id) { + writeStoreError(w, r, store.ErrNotFound) + return + } + deleted, err := h.store.DeleteAgent(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.AgentDeleted{ID: deleted, Object: "agent.deleted", Deleted: true}) +} diff --git a/services/agents-api/internal/api/agents_list.go b/services/agents-api/internal/api/agents_list.go new file mode 100644 index 000000000..102df3e89 --- /dev/null +++ b/services/agents-api/internal/api/agents_list.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary List reusable Agents +// @Description Lists only the authenticated tenant's saved Agents, independently of Sessions. Positive int64 limits are accepted; each page returns at most 100 resources with continuation. The local default is 20; exact upstream default/cap, empty cursor fields and error conformance remain unverified. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Last Agent ID from the previous page" +// @Param limit query int64 false "Maximum requested resources; pages contain at most 100" minimum(1) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SavedAgentList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents [get] +func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { + options, ok := readPageSize(w, r, false) + if !ok { + return + } + page, err := h.store.ListAgents(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.SavedAgentList{Object: "list", Data: make([]v1.SavedAgent, 0, len(page.Agents)), HasMore: page.NextCursor != ""} + for _, agent := range page.Agents { + item, err := agentResponse(agent) + if err != nil { + writeStoreError(w, r, err) + return + } + response.Data = append(response.Data, item) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/agents_update.go b/services/agents-api/internal/api/agents_update.go new file mode 100644 index 000000000..ee9e27ffe --- /dev/null +++ b/services/agents-api/internal/api/agents_update.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Update a reusable Agent +// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Existing Session snapshots are unchanged. Nested replacement/null defaults, model-derived reasoning and exact hosted error/no-op timestamp behavior remain incompletely verified. +// @Tags Agents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Param body body v1.UpdateAgentRequest true "Supplied reusable Agent fields" +// @Success 200 {object} v1.SavedAgent +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [post] +func (h *Handler) updateAgent(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent updates do not accept query parameters.") + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + input, err := resolveAgentUpdate(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + return + } + id := chi.URLParam(r, "agent_id") + if !validAgentID(id) { + writeStoreError(w, r, store.ErrNotFound) + return + } + updated, err := h.store.UpdateAgent(r.Context(), tenantID(r), id, input) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgent(w, r, updated) +} + +func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { + var request v1.UpdateAgentRequest + if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + return store.UpdateAgentInput{}, errors.New("Request must be a JSON object containing supported fields.") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return store.UpdateAgentInput{}, err + } + if _, supplied := fields["model"]; supplied && request.Model == nil { + return store.UpdateAgentInput{}, errors.New("model must be a string when supplied.") + } + normalized, err := resolveSavedFields(v1.CreateAgentRequest(request)) + if err != nil { + return store.UpdateAgentInput{}, err + } + var patch map[string]json.RawMessage + if err := json.Unmarshal(normalized.Configuration, &patch); err != nil { + return store.UpdateAgentInput{}, err + } + if _, supplied := fields["x_agents_core"]; supplied && request.XAgentsCore == nil { + patch["x_agents_core"] = json.RawMessage(`null`) + } + for field := range patch { + if _, supplied := fields[field]; !supplied { + delete(patch, field) + } + } + result := store.UpdateAgentInput{} + if _, supplied := fields["metadata"]; supplied { + result.Metadata = &normalized.Metadata + } + result.Configuration, err = json.Marshal(patch) + return result, err +} diff --git a/services/agents-api/internal/api/auth.go b/services/agents-api/internal/api/auth.go new file mode 100644 index 000000000..fb37d85e4 --- /dev/null +++ b/services/agents-api/internal/api/auth.go @@ -0,0 +1,110 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" +) + +// APIKey binds a service credential to an execution principal, not a product user. +// Configuration stores the SHA-256 hex digest, never the plaintext key. +type APIKey struct { + TokenSHA256 string `json:"token_sha256"` + TenantID string `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` +} + +type Authenticator struct { + principals map[[32]byte]identity.Principal + projects []identity.ProjectScope +} + +func NewAuthenticator(keys []APIKey) (*Authenticator, error) { + if len(keys) == 0 { + return nil, errors.New("at least one Agents API key is required") + } + a := &Authenticator{principals: make(map[[32]byte]identity.Principal, len(keys))} + for _, key := range keys { + principal := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID, OrganizationID: key.OrganizationID, ProjectID: key.ProjectID}, SubjectKind: key.SubjectKind, SubjectID: key.SubjectID} + if err := principal.Validate(); err != nil { + return nil, err + } + digest, err := hex.DecodeString(key.TokenSHA256) + if err != nil || len(digest) != sha256.Size { + return nil, errors.New("API key token_sha256 must be a SHA-256 hex digest") + } + hash := [32]byte(digest) + if _, exists := a.principals[hash]; exists { + return nil, errors.New("duplicate API key digest") + } + a.principals[hash] = principal + a.projects = append(a.projects, principal.ProjectScope) + } + var err error + a.projects, err = identity.ProjectScopes(a.projects) + if err != nil { + return nil, err + } + return a, nil +} + +func (a *Authenticator) ProjectScopes() []identity.ProjectScope { + return append([]identity.ProjectScope(nil), a.projects...) +} + +func (a *Authenticator) principal(r *http.Request) (identity.Principal, bool) { + parts := strings.Fields(r.Header.Get("Authorization")) + if len(r.Header.Values("Authorization")) != 1 || len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return identity.Principal{}, false + } + principal, ok := a.principals[sha256.Sum256([]byte(parts[1]))] + if !ok || !matchesScopeHeader(r, "OpenAI-Organization", principal.OrganizationID) || !matchesScopeHeader(r, "OpenAI-Project", principal.ProjectID) { + return identity.Principal{}, false + } + return principal, true +} + +func matchesScopeHeader(r *http.Request, name, expected string) bool { + values := r.Header.Values(name) + return len(values) == 0 || (len(values) == 1 && values[0] == expected) +} + +type principalContextKey struct{} + +func (h *Handler) authenticate(next http.Handler) http.Handler { + return h.authenticateProject(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("OpenAI-Beta") != "agents=v1" { + writeError(w, http.StatusBadRequest, "invalid_beta_header", "OpenAI-Beta: agents=v1 is required.") + return + } + next.ServeHTTP(w, r) + })) +} + +func (h *Handler) authenticateProject(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := h.auth.principal(r) + if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") + writeError(w, http.StatusUnauthorized, "invalid_api_key", "A valid Agents API bearer key is required.") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal))) + }) +} + +func tenantID(r *http.Request) string { + return r.Context().Value(principalContextKey{}).(identity.Principal).TenantID +} + +func sessionCreator(r *http.Request) identity.Subject { + return r.Context().Value(principalContextKey{}).(identity.Principal).Subject() +} diff --git a/services/agents-api/internal/api/auth_test.go b/services/agents-api/internal/api/auth_test.go new file mode 100644 index 000000000..7299f334c --- /dev/null +++ b/services/agents-api/internal/api/auth_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func callerBinding() APIKey { + return APIKey{TokenSHA256: device.HashCredential("caller"), TenantID: uuid.NewString(), + OrganizationID: "org-one", ProjectID: "project-one", SubjectKind: "user", SubjectID: "user-one"} +} + +func TestCallerPrincipalConfiguration(t *testing.T) { + for _, test := range []struct { + name string + edit func(*APIKey) + }{ + {"missing organization", func(k *APIKey) { k.OrganizationID = "" }}, + {"missing project", func(k *APIKey) { k.ProjectID = "" }}, + {"missing subject", func(k *APIKey) { k.SubjectID = "" }}, + {"unknown subject kind", func(k *APIKey) { k.SubjectKind = "workspace" }}, + {"untyped subject", func(k *APIKey) { k.SubjectKind = "" }}, + {"blank subject", func(k *APIKey) { k.SubjectID = " " }}, + {"invalid tenant", func(k *APIKey) { k.TenantID = "product-workspace" }}, + {"zero tenant", func(k *APIKey) { k.TenantID = uuid.Nil.String() }}, + {"invalid digest", func(k *APIKey) { k.TokenSHA256 = "plaintext" }}, + } { + t.Run(test.name, func(t *testing.T) { + key := callerBinding() + test.edit(&key) + if _, err := NewAuthenticator([]APIKey{key}); err == nil { + t.Fatal("invalid caller configuration accepted") + } + }) + } + key := callerBinding() + for _, test := range []struct { + name string + edit func(*APIKey) + }{ + {"tenant remap", func(k *APIKey) { k.ProjectID = "project-two" }}, + {"project remap", func(k *APIKey) { k.TenantID = uuid.NewString() }}, + {"duplicate key", func(k *APIKey) { k.TokenSHA256 = key.TokenSHA256 }}, + } { + t.Run(test.name, func(t *testing.T) { + other := key + other.TokenSHA256 = device.HashCredential("other") + test.edit(&other) + if _, err := NewAuthenticator([]APIKey{key, other}); err == nil { + t.Fatal("ambiguous caller configuration accepted") + } + }) + } +} + +func TestCallerPrincipalHeadersAndKeyRotation(t *testing.T) { + key := callerBinding() + rotated, peer := key, key + rotated.TokenSHA256 = device.HashCredential("rotated") + peer.TokenSHA256, peer.SubjectKind, peer.SubjectID = device.HashCredential("peer"), "service_account", "service-one" + auth, err := NewAuthenticator([]APIKey{key, rotated, peer}) + if err != nil { + t.Fatal(err) + } + scopes := auth.ProjectScopes() + if len(scopes) != 1 || scopes[0].TenantID != key.TenantID { + t.Fatalf("project scopes = %+v", scopes) + } + scopes[0].ProjectID = "mutated" + if auth.ProjectScopes()[0].ProjectID != key.ProjectID { + t.Fatal("returned scopes mutate authenticated configuration") + } + for _, test := range []struct { + name, token, subject string + headers http.Header + status int + }{ + {"absent scopes", "caller", key.SubjectID, nil, 200}, + {"matching scopes", "caller", key.SubjectID, http.Header{"Openai-Organization": {key.OrganizationID}, "Openai-Project": {key.ProjectID}}, 200}, + {"rotated key", "rotated", key.SubjectID, nil, 200}, + {"same project peer", "peer", peer.SubjectID, nil, 200}, + {"forged identity", "caller", key.SubjectID, http.Header{"X-Tenant-Id": {uuid.NewString()}, "X-User-Id": {"other"}, "X-Forwarded-User": {"other"}}, 200}, + {"wrong organization", "caller", "", http.Header{"Openai-Organization": {"org-two"}}, 401}, + {"wrong project", "caller", "", http.Header{"Openai-Project": {"project-two"}}, 401}, + {"empty project", "caller", "", http.Header{"Openai-Project": {""}}, 401}, + {"duplicate project", "caller", "", http.Header{"Openai-Project": {key.ProjectID, key.ProjectID}}, 401}, + {"duplicate authorization", "caller", "", http.Header{"Authorization": {"Bearer caller", "Bearer peer"}}, 401}, + {"merged projects", "caller", "", http.Header{"Openai-Project": {key.ProjectID + ", other"}}, 401}, + {"wrong key", "unknown", "", nil, 401}, + } { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions", nil) + r.Header.Set("Authorization", "Bearer "+test.token) + r.Header.Set("OpenAI-Beta", "agents=v1") + for name, values := range test.headers { + r.Header[name] = values + } + called := false + h := (&Handler{auth: auth}).authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + principal := r.Context().Value(principalContextKey{}).(identity.Principal) + if principal.SubjectID != test.subject || tenantID(r) != key.TenantID || principal.ProjectID != key.ProjectID { + t.Fatalf("authenticated principal = %+v", principal) + } + w.WriteHeader(http.StatusOK) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != test.status || called != (test.status == 200) { + t.Fatalf("response = %d, called = %v", w.Code, called) + } + if test.status == 401 && (!strings.Contains(w.Body.String(), "invalid_api_key") || w.Header().Get("WWW-Authenticate") != "Bearer") { + t.Fatalf("authentication response = %s", w.Body) + } + }) + } +} diff --git a/services/agents-api/internal/api/claude_admission_test.go b/services/agents-api/internal/api/claude_admission_test.go new file mode 100644 index 000000000..a04be6dd5 --- /dev/null +++ b/services/agents-api/internal/api/claude_admission_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestClaudeSessionConfigurationAdmission(t *testing.T) { + for _, stream := range []bool{false, true} { + for _, initial := range []bool{false, true} { + for _, test := range []struct { + name, fields string + accepted bool + }{ + {"defaults", `,"instructions":null`, true}, + {"medium", `,"text":{"verbosity":"medium"}`, true}, + {"low", `,"text":{"verbosity":"low"}`, false}, + {"high", `,"text":{"verbosity":"high"}`, false}, + {"object function", `,"tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object","properties":{}}}]`, true}, + {"implicit root", `,"tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"properties":{}}}]`, false}, + {"union root", `,"tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":["object","null"]}}]`, false}, + {"MCP defaults", `,"tools":[` + publicMCP + `]`, true}, + {"MCP null", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":null}]`, true}, + {"MCP empty", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":[]}]`, true}, + {"MCP selected", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":["lookup.v1"]}]`, true}, + {"MCP required", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"required":true}]`, true}, + {"MCP reserved label", `,"tools":[` + strings.Replace(publicMCP, `"records"`, `"functions"`, 1) + `]`, false}, + {"MCP invalid label", `,"tools":[` + strings.Replace(publicMCP, `"records"`, `"records.v1"`, 1) + `]`, false}, + {"MCP wildcard name", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":["*"]}]`, false}, + {"MCP empty fragment", `,"tools":[` + strings.Replace(publicMCP, `/tools"`, `/tools#"`, 1) + `]`, false}, + } { + t.Run(fmt.Sprintf("%s/stream=%t/initial=%t", test.name, stream, initial), func(t *testing.T) { + digest := sha256.Sum256([]byte("test-api-key")) + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(digest[:]), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + saved := &recordingStore{} + handler, err := NewHandler(saved, auth, "claude_sdk") + if err != nil { + t.Fatal(err) + } + input := "" + if initial { + input = `,"input":"Hello"` + } + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"%s},"environment":{"type":"none"},"stream":%t%s}`, test.fields, stream, input) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + want := http.StatusBadRequest + if test.accepted { + want = http.StatusOK + if stream || initial { + want = http.StatusServiceUnavailable + } + } + if response.Code != want { + t.Fatalf("status %d, expected %d: %s", response.Code, want, response.Body) + } + if want != http.StatusOK && saved.tenant != "" { + t.Fatal("rejected request persisted a Session") + } + if want == http.StatusOK && saved.input.Engine != "claude_sdk" { + t.Fatal("wrong engine persisted") + } + }) + } + } + } +} diff --git a/services/agents-api/internal/api/claude_mcp_test.go b/services/agents-api/internal/api/claude_mcp_test.go new file mode 100644 index 000000000..c0760c9db --- /dev/null +++ b/services/agents-api/internal/api/claude_mcp_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type claudeCredentialStore struct { + recordingStore + binding store.MCPCredentialBinding + calls int +} + +func (s *claudeCredentialStore) ResolveMCPCredentials(_ context.Context, _ string, _ []string, _ []store.MCPCredentialRequest) ([]store.MCPCredentialBinding, error) { + s.calls++ + return []store.MCPCredentialBinding{s.binding}, nil +} + +func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) { + for _, selection := range []string{"implicit", "explicit", "unmatched"} { + t.Run(selection, func(t *testing.T) { + vault, credential := uuid.NewString(), uuid.NewString() + s := &claudeCredentialStore{binding: store.MCPCredentialBinding{ServerLabel: "records", ServerURL: "https://mcp.example.test/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}} + tool := publicMCP + if selection == "explicit" { + tool = strings.TrimSuffix(tool, "}") + `,"credential_id":"` + credential + `"}` + } + if selection == "unmatched" { + s.binding.VaultID, s.binding.CredentialID, s.binding.AuthType = "", "", "" + } + digest := sha256.Sum256([]byte("test-api-key")) + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(digest[:]), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, auth, "claude_sdk") + if err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q]}`, tool, vault) + response := credentialRequest(h, "POST", "/v1/agents/sessions", body) + if response.Code != 200 || s.calls != 1 || s.tenant == "" { + t.Fatal("credential selection or admission failed", response.Code, response.Body, s.calls) + } + }) + } +} diff --git a/services/agents-api/internal/api/configuration.go b/services/agents-api/internal/api/configuration.go new file mode 100644 index 000000000..3ff09d9dc --- /dev/null +++ b/services/agents-api/internal/api/configuration.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type configuration struct { + Agent v1.Agent `json:"agent"` + Environment v1.Environment `json:"environment"` + VaultIDs []string `json:"vault_ids,omitempty"` + MCPCredentials []store.MCPCredentialBinding `json:"mcp_credentials,omitempty"` +} + +func resolve(input sessionRequest, tenant, key string, saved *v1.SavedAgent) (json.RawMessage, error) { + if input.Environment == nil || (input.Environment.Type != "none" && input.Environment.Type != "self_hosted" && input.Environment.Type != "openai_hosted") { + return nil, errors.New("Unsupported environment type.") + } + if err := validateMetadata(input.Metadata); err != nil { + return nil, err + } + agent, err := resolveSessionAgent(input, saved) + if err != nil { + return nil, err + } + if saved == nil { + // Inline execution configuration has its own stable identity for creation retries. + agent.ID = "agent_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(tenant+"\x00"+key)).String() + } else { + agent.ID = saved.ID + } + return json.Marshal(configuration{Agent: agent, Environment: *input.Environment, VaultIDs: input.VaultIDs}) +} diff --git a/services/agents-api/internal/api/credentials.go b/services/agents-api/internal/api/credentials.go new file mode 100644 index 000000000..0ed264ba3 --- /dev/null +++ b/services/agents-api/internal/api/credentials.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "net/http" + "net/url" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type CredentialStore interface { + CreateStaticCredential(context.Context, string, string, store.CreateStaticCredentialInput) (store.Credential, error) + UpdateStaticCredential(context.Context, string, string, string, store.UpdateStaticCredentialInput) (store.Credential, error) + GetCredential(context.Context, string, string, string) (store.Credential, error) + DeleteCredential(context.Context, string, string, string) (string, error) + ListCredentials(context.Context, string, string, string, int, bool, []string) (store.CredentialPage, error) +} + +// @Summary Create a static-bearer Vault Credential +// @Description Stores the write-only token as execution-owned authenticated ciphertext. Required name is trimmed to 1–256 UTF-8 bytes; auth requires static_bearer, an HTTPS mcp_server_url and a string token. Token bytes are preserved, including empty strings; hosted token edge validation is unverified. The initial URL profile excludes userinfo and fragments, preserves queries and makes no network request. Public responses contain only safe metadata. Missing encryption configuration returns local 503. Session admission can bind static credentials from attached Vaults to exact HTTPS MCP destinations; secret decryption occurs only at dispatch. OAuth, storage-key rotation and exact hosted error/retry semantics remain gaps. +// @Tags Credentials +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Param body body v1.CreateCredentialRequest true "Write-only static bearer credential" +// @Success 200 {object} v1.Credential +// @Failure 400,401,404,413,500,503 {object} v1.ErrorResponse +// @Router /vaults/{vault_id}/credentials [post] +func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Credential creation does not accept query parameters.") + return + } + vaultID, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var request v1.CreateCredentialRequest + if decodeInputObject(raw, &request, "name", "auth") != nil || request.Name == nil || request.Auth == nil || request.Auth.Type != "static_bearer" || request.Auth.Token == nil || request.Auth.MCPServerURL == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "name and static_bearer auth with string mcp_server_url and token are required.") + return + } + name, err := normalizedVaultName(*request.Name) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + u, err := url.Parse(*request.Auth.MCPServerURL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + writeError(w, http.StatusBadRequest, "invalid_request", "mcp_server_url must be an absolute HTTPS URL without userinfo or a fragment.") + return + } + credential, err := h.store.CreateStaticCredential(r.Context(), tenantID(r), vaultID, store.CreateStaticCredentialInput{Name: name, MCPServerURL: *request.Auth.MCPServerURL, Token: *request.Auth.Token}) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, credentialResponse(credential)) +} + +// @Summary Retrieve safe Vault Credential metadata +// @Description Reads only non-secret metadata scoped to the authenticated project and owning Vault. No token decryption, network request or execution is performed. Unknown, foreign, wrong-Vault and malformed IDs use the same local not-found response; hosted error parity remains unverified. +// @Tags Credentials +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Param credential_id path string true "Credential ID" +// @Success 200 {object} v1.Credential +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id}/credentials/{credential_id} [get] +func (h *Handler) getCredential(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Credential retrieval does not accept query parameters.") + return + } + vaultID, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + id, ok := credentialResourceID(w, r, "credential_id") + if !ok { + return + } + credential, err := h.store.GetCredential(r.Context(), tenantID(r), vaultID, id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, credentialResponse(credential)) +} + +func credentialResourceID(w http.ResponseWriter, r *http.Request, param string) (string, bool) { + id, err := uuid.Parse(chi.URLParam(r, param)) + if err != nil || id == uuid.Nil { + writeStoreError(w, r, store.ErrNotFound) + return "", false + } + return id.String(), true +} + +func credentialResponse(c store.Credential) v1.Credential { + return v1.Credential{ID: c.ID, VaultID: c.VaultID, Name: c.Name, Object: "vault.credential", Auth: v1.StaticBearerCredentialAuth{Type: c.AuthType, MCPServerURL: c.MCPServerURL}, CreatedAt: c.CreatedAt.Unix(), UpdatedAt: c.UpdatedAt.Unix()} +} diff --git a/services/agents-api/internal/api/credentials_delete.go b/services/agents-api/internal/api/credentials_delete.go new file mode 100644 index 000000000..b1dd8bafe --- /dev/null +++ b/services/agents-api/internal/api/credentials_delete.go @@ -0,0 +1,48 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary Delete a Vault Credential +// @Description Removes one Credential and its encrypted token within the authenticated project and owning Vault, without an encryption key or secret decryption. Subsequent metadata reads, updates and dispatch lookups cannot use it. Existing Session snapshots and history retain their frozen identities; already-resolved tokens and running Sessions are not revoked or cancelled. This local policy removes the row rather than defining archived lifecycle; missing/repeated deletion returns 404. Exact hosted archive, post-delete visibility and retry/error semantics remain unverified. Provider revocation and physical erasure from native history, WAL or backups are separate concerns. +// @Tags Credentials +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Param credential_id path string true "Credential ID" +// @Success 200 {object} v1.CredentialDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id}/credentials/{credential_id} [delete] +func (h *Handler) deleteCredential(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Credential deletion does not accept query parameters.") + return + } + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Credential deletion does not accept a request body.") + return + } + vaultID, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + id, ok := credentialResourceID(w, r, "credential_id") + if !ok { + return + } + deleted, err := h.store.DeleteCredential(r.Context(), tenantID(r), vaultID, id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.CredentialDeleted{ID: deleted, Deleted: true, Object: "vault.credential.deleted"}) +} diff --git a/services/agents-api/internal/api/credentials_delete_test.go b/services/agents-api/internal/api/credentials_delete_test.go new file mode 100644 index 000000000..041787261 --- /dev/null +++ b/services/agents-api/internal/api/credentials_delete_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func (f *credentialFixture) DeleteCredential(_ context.Context, tenant, vault, id string) (string, error) { + f.tenant, f.vault, f.id, f.calls = tenant, vault, id, f.calls+1 + return f.credential.ID, f.err +} + +func TestCredentialDeletionConfirmationAndScope(t *testing.T) { + h, f, tenant := credentialHandler(t) + w := credentialRequest(h, "DELETE", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, "") + var got map[string]any + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal("deletion failed", w.Code) + } + want := map[string]any{"id": f.credential.ID, "deleted": true, "object": "vault.credential.deleted"} + if !reflect.DeepEqual(got, want) || f.tenant != tenant || f.vault != f.credential.VaultID || f.id != f.credential.ID || f.calls != 1 { + t.Fatal("deletion changed authenticated scope or confirmation") + } +} + +func TestCredentialDeletionRejectsBeforeMutation(t *testing.T) { + for _, mode := range []string{"auth", "beta", "query", "body", "null", "vault", "credential", "zero"} { + t.Run(mode, func(t *testing.T) { + h, f, _ := credentialHandler(t) + path := "/v1/vaults/" + f.credential.VaultID + "/credentials/" + f.credential.ID + body, status := "", http.StatusBadRequest + switch mode { + case "auth": + status = http.StatusUnauthorized + case "query": + path += "?tenant_id=untrusted" + case "body": + body = `{"token":"credential-canary"}` + case "null": + body = "null" + case "vault": + path, status = strings.Replace(path, f.credential.VaultID, "invalid", 1), http.StatusNotFound + case "credential": + path, status = strings.Replace(path, f.credential.ID, "invalid", 1), http.StatusNotFound + case "zero": + path, status = strings.Replace(path, f.credential.ID, uuid.Nil.String(), 1), http.StatusNotFound + } + r := httptest.NewRequest("DELETE", path, strings.NewReader(body)) + if mode != "auth" { + r.Header.Set("Authorization", "Bearer test-api-key") + } + if mode != "beta" { + r.Header.Set("OpenAI-Beta", "agents=v1") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status || f.calls != 0 || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("invalid deletion reached storage or disclosed input", w.Code) + } + }) + } + for _, tc := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {errors.New("credential-canary"), 500}} { + h, f, _ := credentialHandler(t) + f.err = tc.err + w := credentialRequest(h, "DELETE", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, "") + if w.Code != tc.status || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("deletion changed error mapping or disclosed storage detail") + } + } +} diff --git a/services/agents-api/internal/api/credentials_list.go b/services/agents-api/internal/api/credentials_list.go new file mode 100644 index 000000000..07fd78b92 --- /dev/null +++ b/services/agents-api/internal/api/credentials_list.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary List safe Vault Credential metadata +// @Description Lists only metadata from the authenticated project's requested Vault, without decryption or execution. Includes active and archived Credentials by default, independently of Vault status. Status accepts a scalar or SDK status[] array; mixed encodings and repeated scalars are rejected locally. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering. Hosted errors, concurrent-page behavior and archive/delete lifecycle remain unverified or unimplemented. +// @Tags Credentials +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Param after query string false "Last Credential ID from the previous page" +// @Param limit query integer false "Requested page size, clamped to 1–100" default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param status query string false "Scalar status filter" Enums(active,archived) +// @Param status[] query []string false "Array status filter; cannot be combined with status" collectionFormat(multi) Enums(active,archived) +// @Success 200 {object} v1.CredentialList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id}/credentials [get] +func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request) { + vaultID, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + options, statuses, ok := readVaultPage(w, r) + if !ok { + return + } + page, err := h.store.ListCredentials(r.Context(), tenantID(r), vaultID, options.after, options.limit, options.ascending, statuses) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.CredentialList{Object: "list", Data: make([]v1.Credential, 0, len(page.Credentials)), HasMore: page.NextCursor != ""} + for _, credential := range page.Credentials { + response.Data = append(response.Data, credentialResponse(credential)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/credentials_list_test.go b/services/agents-api/internal/api/credentials_list_test.go new file mode 100644 index 000000000..4a8ecb42c --- /dev/null +++ b/services/agents-api/internal/api/credentials_list_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (f *credentialFixture) ListCredentials(_ context.Context, tenant, vault, after string, limit int, ascending bool, statuses []string) (store.CredentialPage, error) { + f.tenant, f.vault, f.calls = tenant, vault, f.calls+1 + f.options, f.statuses = pageOptions{after: after, limit: limit, ascending: ascending}, statuses + return f.page, f.err +} + +func TestCredentialListScopeProjectionAndParameters(t *testing.T) { + for _, tc := range []struct { + query string + limit int + statuses []string + }{ + {"", 20, nil}, {"?limit=0", 1, nil}, {"?limit=101", 100, nil}, + {"?status=archived", 20, []string{"archived"}}, + {"?status[]=active&status[]=archived", 20, []string{"active", "archived"}}, + } { + h, f, tenant := credentialHandler(t) + f.page = store.CredentialPage{Credentials: []store.Credential{f.credential}, NextCursor: f.credential.ID} + w := credentialRequest(h, "GET", "/v1/vaults/"+f.credential.VaultID+"/credentials"+tc.query, "") + var body v1.CredentialList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil { + t.Fatal(w.Code, w.Body.String()) + } + if f.calls != 1 || f.tenant != tenant || f.vault != f.credential.VaultID || f.options.limit != tc.limit || !reflect.DeepEqual(f.statuses, tc.statuses) { + t.Fatal("list scope or parameters changed") + } + if !reflect.DeepEqual(body.Data, []v1.Credential{credentialResponse(f.credential)}) || !body.HasMore || body.FirstID == nil || *body.FirstID != f.credential.ID || body.LastID == nil || *body.LastID != f.credential.ID { + t.Fatal("list projection or cursor changed") + } + } + h, f, _ := credentialHandler(t) + path := "/v1/vaults/" + f.credential.VaultID + "/credentials" + w := credentialRequest(h, "GET", path+"?order=asc&after="+f.credential.ID, "") + var empty map[string]any + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &empty) != nil || !reflect.DeepEqual(empty, map[string]any{"object": "list", "data": []any{}, "has_more": false, "first_id": nil, "last_id": nil}) || !f.options.ascending || f.options.after != f.credential.ID { + t.Fatal("empty page or cursor parsing changed") + } + f.err = store.ErrNotFound + if w = credentialRequest(h, "GET", path, ""); w.Code != 404 { + t.Fatal("missing parent must not be an empty collection") + } +} + +func TestCredentialListRejectsInvalidInputBeforeStorage(t *testing.T) { + for _, suffix := range []string{"?status=deleted", "?status=active&status[]=archived", "?limit=1.5", "?limit=1&limit=2", "?tenant_id=foreign"} { + h, f, _ := credentialHandler(t) + w := credentialRequest(h, "GET", "/v1/vaults/"+f.credential.VaultID+"/credentials"+suffix, "") + if w.Code != 400 || f.calls != 0 { + t.Fatal("invalid query reached storage", suffix, w.Code) + } + } + h, f, _ := credentialHandler(t) + if w := credentialRequest(h, "GET", "/v1/vaults/invalid/credentials", ""); w.Code != 404 || f.calls != 0 { + t.Fatal("invalid parent reached storage") + } +} diff --git a/services/agents-api/internal/api/credentials_test.go b/services/agents-api/internal/api/credentials_test.go new file mode 100644 index 000000000..7eb9eb747 --- /dev/null +++ b/services/agents-api/internal/api/credentials_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type credentialFixture struct { + ResourceStore + credential store.Credential + input store.CreateStaticCredentialInput + replacement store.UpdateStaticCredentialInput + tenant, vault, id string + calls int + err error + page store.CredentialPage + options pageOptions + statuses []string +} + +func (f *credentialFixture) CreateStaticCredential(_ context.Context, tenant, vault string, input store.CreateStaticCredentialInput) (store.Credential, error) { + f.tenant, f.vault, f.input, f.calls = tenant, vault, input, f.calls+1 + f.credential.Name, f.credential.MCPServerURL = input.Name, input.MCPServerURL + return f.credential, f.err +} + +func (f *credentialFixture) GetCredential(_ context.Context, tenant, vault, id string) (store.Credential, error) { + f.tenant, f.vault, f.id, f.calls = tenant, vault, id, f.calls+1 + return f.credential, f.err +} + +func credentialHandler(t *testing.T) (http.Handler, *credentialFixture, string) { + t.Helper() + h, s, tenant := testHandler(t) + f := &credentialFixture{credential: store.Credential{ID: uuid.NewString(), VaultID: uuid.NewString(), AuthType: "static_bearer", CreatedAt: time.Unix(1700000000, 0), UpdatedAt: time.Unix(1700000000, 0)}} + s.ResourceStore = f + return h, f, tenant +} + +func credentialRequest(h http.Handler, method, path, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestCredentialSafeProjectionAndOpaqueInput(t *testing.T) { + h, f, tenant := credentialHandler(t) + path := "/v1/vaults/" + f.credential.VaultID + "/credentials" + w := credentialRequest(h, "POST", path, `{"name":" Vault credential \n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid/mcp?q=x","token":" \tcredential-canary\n雪 "}}`) + var got map[string]any + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal(w.Code, w.Body) + } + want := map[string]any{"id": f.credential.ID, "vault_id": f.credential.VaultID, "name": "Vault credential", "object": "vault.credential", "created_at": float64(1700000000), "updated_at": float64(1700000000), "auth": map[string]any{"type": "static_bearer", "mcp_server_url": "https://example.invalid/mcp?q=x"}} + if !reflect.DeepEqual(got, want) || f.tenant != tenant || f.vault != f.credential.VaultID || f.input.Token != " \tcredential-canary\n雪 " { + t.Fatal("resource projection or authenticated request changed") + } + read := credentialRequest(h, "GET", path+"/"+f.credential.ID, "") + if read.Code != 200 || read.Body.String() != w.Body.String() || f.id != f.credential.ID || f.calls != 2 { + t.Fatal("safe retrieve changed", read.Code) + } +} + +func TestCredentialInvalidRequestsNeverReachStorage(t *testing.T) { + for _, body := range []string{ + `null`, `[]`, `{} {}`, `{}`, `{"name":null,"auth":{}}`, + `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid","token":null}}`, + `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://credential-canary@example.invalid","token":"credential-canary"}}`, + `{"name":"n","auth":{"type":"mcp_oauth","access_token":"credential-canary"}}`, + `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"http://example.invalid","token":"credential-canary"}}`, + `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid#fragment","token":"credential-canary"}}`, + } { + h, f, _ := credentialHandler(t) + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials", body) + if w.Code != 400 || f.calls != 0 || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("invalid request reached storage or leaked input", w.Code, f.calls) + } + } + for _, id := range []string{"invalid", uuid.Nil.String()} { + h, f, _ := credentialHandler(t) + w := credentialRequest(h, "GET", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+id, "") + if w.Code != 404 || f.calls != 0 { + t.Fatal("malformed ID reached storage", w.Code, f.calls) + } + } +} + +func TestCredentialStorageErrorsStaySafe(t *testing.T) { + for _, test := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {store.ErrCredentialStorageUnavailable, 503}, {errors.New("credential-canary"), 500}} { + h, f, _ := credentialHandler(t) + f.err = test.err + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials", `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid","token":"credential-canary"}}`) + if w.Code != test.status || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("unsafe storage error", w.Code) + } + } +} diff --git a/services/agents-api/internal/api/credentials_update.go b/services/agents-api/internal/api/credentials_update.go new file mode 100644 index 000000000..7e8f6d763 --- /dev/null +++ b/services/agents-api/internal/api/credentials_update.go @@ -0,0 +1,51 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// @Summary Replace a static-bearer Vault Credential token +// @Description Requires auth with type=static_bearer and a string token; empty and opaque token bytes are preserved. Only the write-only secret and updated_at change, atomically within the authenticated project and owning Vault. Identity, name, auth type, exact destination, created_at and Session bindings remain unchanged. Responses contain only safe metadata; no old-token decryption or network call occurs. Missing encryption configuration returns local 503 without modifying the credential. Subsequent dispatch reads use the committed replacement; already-resolved requests may retain the old token. OAuth, storage-key rotation, hot reload/revocation and exact hosted concurrent-update/retry/timestamp semantics remain gaps. +// @Tags Credentials +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Param credential_id path string true "Credential ID" +// @Param body body v1.UpdateCredentialRequest true "Write-only static bearer replacement" +// @Success 200 {object} v1.Credential +// @Failure 400,401,404,413,500,503 {object} v1.ErrorResponse +// @Router /vaults/{vault_id}/credentials/{credential_id} [post] +func (h *Handler) updateCredential(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Credential update does not accept query parameters.") + return + } + vaultID, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + id, ok := credentialResourceID(w, r, "credential_id") + if !ok { + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var request v1.UpdateCredentialRequest + if decodeInputObject(raw, &request, "auth") != nil || request.Auth == nil || request.Auth.Type != "static_bearer" || request.Auth.Token == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "auth with type=static_bearer and a string token is required.") + return + } + credential, err := h.store.UpdateStaticCredential(r.Context(), tenantID(r), vaultID, id, store.UpdateStaticCredentialInput{Token: *request.Auth.Token}) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, credentialResponse(credential)) +} diff --git a/services/agents-api/internal/api/credentials_update_test.go b/services/agents-api/internal/api/credentials_update_test.go new file mode 100644 index 000000000..82fdc7f2d --- /dev/null +++ b/services/agents-api/internal/api/credentials_update_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func (f *credentialFixture) UpdateStaticCredential(_ context.Context, tenant, vault, id string, input store.UpdateStaticCredentialInput) (store.Credential, error) { + f.tenant, f.vault, f.id, f.replacement, f.calls = tenant, vault, id, input, f.calls+1 + return f.credential, f.err +} + +func TestCredentialUpdatePreservesOpaqueInputAndSafeProjection(t *testing.T) { + for _, token := range []string{"", " \tcredential-canary\n雪 ", "credential-canary"} { + h, f, tenant := credentialHandler(t) + f.credential.Name, f.credential.MCPServerURL = "Retained name", "https://example.invalid/mcp?q=x" + body, _ := json.Marshal(map[string]any{"auth": map[string]string{"type": "static_bearer", "token": token}}) + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, string(body)) + var got map[string]any + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal("update failed", w.Code) + } + want := map[string]any{"id": f.credential.ID, "vault_id": f.credential.VaultID, "name": f.credential.Name, + "object": "vault.credential", "created_at": float64(f.credential.CreatedAt.Unix()), "updated_at": float64(f.credential.UpdatedAt.Unix()), + "auth": map[string]any{"type": "static_bearer", "mcp_server_url": f.credential.MCPServerURL}} + if !reflect.DeepEqual(got, want) || f.tenant != tenant || f.vault != f.credential.VaultID || f.id != f.credential.ID || f.replacement.Token != token || f.calls != 1 || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("update changed scope, secret bytes or safe resource shape") + } + } +} + +func TestCredentialUpdateRejectsInvalidBodiesBeforeStorage(t *testing.T) { + for _, body := range []string{ + `null`, `[]`, `{} {}`, `{}`, `{"auth":null}`, `{"auth":[]}`, `{"auth":{}}`, + `{"auth":{"type":"static_bearer"}}`, `{"auth":{"token":"credential-canary"}}`, + `{"auth":{"type":null,"token":"credential-canary"}}`, `{"auth":{"type":3,"token":"credential-canary"}}`, + `{"auth":{"type":"static_bearer","token":null}}`, `{"auth":{"type":"static_bearer","token":3}}`, + `{"auth":{"type":"static_bearer","token":{}}}`, `{"auth":{"type":"static_bearer","token":[]}}`, + `{"auth":{"type":"mcp_oauth","access_token":"credential-canary"}}`, + `{"auth":{"type":"static_bearer","token":"credential-canary","mcp_server_url":"https://other.invalid"}}`, + `{"auth":{"type":"static_bearer","token":"credential-canary"},"name":"other"}`, + `{"auth":{"type":"static_bearer","token":"credential-canary"},"vault_id":"other"}`, + } { + h, f, _ := credentialHandler(t) + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, body) + if w.Code != http.StatusBadRequest || f.calls != 0 || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("invalid update reached storage or disclosed input", w.Code) + } + } +} + +func TestCredentialUpdateUsesExistingBoundariesAndSafeErrors(t *testing.T) { + const body = `{"auth":{"type":"static_bearer","token":"credential-canary"}}` + for _, mode := range []string{"missing auth", "missing beta", "method", "query", "invalid Vault", "invalid Credential", "zero ID"} { + h, f, _ := credentialHandler(t) + path := "/v1/vaults/" + f.credential.VaultID + "/credentials/" + f.credential.ID + method, status := "POST", http.StatusBadRequest + switch mode { + case "method": + method, status = "PATCH", http.StatusMethodNotAllowed + case "query": + path += "?unknown=1" + case "invalid Vault": + path, status = strings.Replace(path, f.credential.VaultID, "invalid", 1), http.StatusNotFound + case "invalid Credential": + path, status = strings.Replace(path, f.credential.ID, "invalid", 1), http.StatusNotFound + case "zero ID": + path, status = strings.Replace(path, f.credential.ID, uuid.Nil.String(), 1), http.StatusNotFound + case "missing auth": + status = http.StatusUnauthorized + } + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if mode != "missing auth" { + r.Header.Set("Authorization", "Bearer test-api-key") + } + if mode != "missing beta" { + r.Header.Set("OpenAI-Beta", "agents=v1") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status || f.calls != 0 { + t.Fatal("update boundary changed", mode, w.Code) + } + } + for _, tc := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {store.ErrCredentialStorageUnavailable, 503}, {errors.New("credential-canary"), 500}} { + h, f, _ := credentialHandler(t) + f.err = tc.err + w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, body) + if w.Code != tc.status || strings.Contains(w.Body.String(), "credential-canary") { + t.Fatal("update exposed a storage error", w.Code) + } + } +} diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/agents-api/internal/api/environment_creation_test.go new file mode 100644 index 000000000..58d7204b3 --- /dev/null +++ b/services/agents-api/internal/api/environment_creation_test.go @@ -0,0 +1,229 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentCreationFixture struct { + streamFixture + input store.CreateSessionInput +} + +func (f *environmentCreationFixture) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { + return store.SessionCreation{}, store.ErrNotFound +} + +func (f *environmentCreationFixture) CreateSession(_ context.Context, tenant string, input store.CreateSessionInput) (store.Session, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.input = input + f.session = store.Session{ID: uuid.NewString(), TenantID: tenant, Configuration: input.Configuration, Metadata: input.Metadata, CreatedAt: time.Unix(1700000000, 0)} + var snapshot struct { + Environment json.RawMessage `json:"environment"` + } + if err := json.Unmarshal(input.Configuration, &snapshot); err != nil { + return store.Session{}, err + } + f.session.Environment = &store.Environment{ + ID: uuid.NewString(), SessionID: f.session.ID, TenantID: tenant, Status: "pending", Configuration: snapshot.Environment, + } + return f.session, nil +} + +func (f *environmentCreationFixture) CreateSessionStream(ctx context.Context, tenant string, input store.CreateSessionInput) (store.SessionCreation, error) { + session, err := f.CreateSession(ctx, tenant, input) + return store.SessionCreation{Session: session, Created: true}, err +} + +func environmentCreationHandler(t *testing.T, engine string, options ...Option) (http.Handler, *environmentCreationFixture) { + t.Helper() + fixture := &environmentCreationFixture{} + auth, err := NewAuthenticator([]APIKey{{ + OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", + TokenSHA256: device.HashCredential("key"), TenantID: uuid.NewString(), + }}) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandler(fixture, auth, engine, options...) + if err != nil { + t.Fatal(err) + } + return handler, fixture +} + +func TestSelfHostedEmptyCreationAndStream(t *testing.T) { + var canonical string + for _, capability := range []string{"", `,"capability_directories":null`, `,"capability_directories":[]`} { + for _, input := range []string{"", `,"input":null`} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/stream=%t", capability, input, stream), func(t *testing.T) { + handler, fixture := environmentCreationHandler(t, "codex", WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL(environmentOrigin)) + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/remote/workspace"%s},"stream":%t%s}`, capability, stream, input) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Host = "forged.example" + request.Header.Set("X-Forwarded-Host", "forged.example") + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Idempotency-Key", "empty-environment") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatal("empty creation rejected", response.StatusCode) + } + var session v1.Session + if stream { + if response.Header.Get("Content-Type") != "text/event-stream" { + t.Fatal("streamed creation returned a non-stream response") + } + scanner := bufio.NewScanner(response.Body) + for scanner.Scan() { + if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok { + var event v1.SessionEvent + if json.Unmarshal([]byte(data), &event) != nil || event.Type != "agent.session.created" || event.Session == nil { + t.Fatal("invalid creation event", data) + } + session = *event.Session + break + } + } + } else if err := json.NewDecoder(response.Body).Decode(&session); err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + created, persisted := fixture.session, fixture.input + fixture.mu.Unlock() + if session.ID != created.ID || session.Status != "idle" || session.Error != nil || session.RequiredActions == nil || len(session.RequiredActions) != 0 || len(persisted.InitialInputs) != 0 || created.LastTurn != nil { + t.Fatal("empty creation invented execution activity", session, persisted) + } + if persisted.Engine != "codex" || persisted.Creator.Kind != "service_account" || persisted.Creator.ID != "test-runner" || persisted.IdempotencyKey != "empty-environment" { + t.Fatal("creation lost engine or authenticated identity", persisted) + } + if session.Environment.ID != created.Environment.ID || session.Environment.RemoteURL != environmentOrigin || session.Environment.WorkspaceDirectory != "/remote/workspace" || session.Environment.CapabilityDirectories == nil || *session.Environment.CapabilityDirectories == nil || len(*session.Environment.CapabilityDirectories) != 0 { + t.Fatal("creation did not use the owned Environment and configured origin", session.Environment) + } + value := string(created.Environment.Configuration) + if canonical == "" { + canonical = value + } + if value != canonical { + t.Fatal("omitted/null/empty capability defaults changed retry configuration", value, canonical) + } + }) + } + } + } +} + +func TestSelfHostedCreationRejectsBeforePersistence(t *testing.T) { + validEnvironment := `{"type":"self_hosted","workspace_directory":"/remote/workspace"}` + for _, tc := range []struct { + name, environment, agentFields, input, engine string + }{ + {name: "missing environment", environment: ""}, + {name: "null environment", environment: "null"}, + {name: "array environment", environment: "[]"}, + {name: "missing type", environment: `{"workspace_directory":"/remote/workspace"}`}, + {name: "null type", environment: `{"type":null,"workspace_directory":"/remote/workspace"}`}, + {name: "missing workspace", environment: `{"type":"self_hosted"}`}, + {name: "null workspace", environment: `{"type":"self_hosted","workspace_directory":null}`}, + {name: "numeric workspace", environment: `{"type":"self_hosted","workspace_directory":1}`}, + {name: "relative workspace", environment: `{"type":"self_hosted","workspace_directory":"relative"}`}, + {name: "tilde workspace", environment: `{"type":"self_hosted","workspace_directory":"~/project"}`}, + {name: "nul workspace", environment: `{"type":"self_hosted","workspace_directory":"/remote/\u0000"}`}, + {name: "newline workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\n"}`}, + {name: "carriage return workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\r"}`}, + {name: "backslash workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\\"}`}, + {name: "capabilities", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":["/remote/skills"]}`}, + {name: "null capability entry", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":[null]}`}, + {name: "scalar capabilities", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":"/remote/skills"}`}, + {name: "output field", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","remote_url":"https://forged.example"}`}, + {name: "none extra field", environment: `{"type":"none","workspace_directory":null}`}, + {name: "initial image", environment: validEnvironment, input: `,"input":[{"role":"user","content":[{"type":"input_image","image_url":"https://example.com/image.png"}]}]`}, + {name: "initial assistant message", environment: validEnvironment, input: `,"input":[{"role":"assistant","content":[{"type":"input_text","text":"start"}]}]`}, + {name: "deferred functions", environment: validEnvironment, agentFields: `,"tools":[{"type":"function","name":"lookup","description":"Find a value","parameters":{"type":"object"},"defer_loading":true}]`}, + {name: "Claude SDK placement", environment: validEnvironment, engine: "claude_sdk"}, + {name: "Claude Code placement", environment: validEnvironment, engine: "claude_code"}, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", tc.name, stream), func(t *testing.T) { + engine := tc.engine + if engine == "" { + engine = "codex" + } + handler, fixture := environmentCreationHandler(t, engine, WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL(environmentOrigin)) + environment := "" + if tc.environment != "" { + environment = `,"environment":` + tc.environment + } + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"%s},"stream":%t%s%s}`, tc.agentFields, stream, environment, tc.input) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || fixture.input.Engine != "" || fixture.session.ID != "" { + t.Fatal("unsupported configuration reached persistence", response.Code, response.Body.String(), fixture.input) + } + }) + } + } +} + +func TestSelfHostedCreationRequiresOperatorExecution(t *testing.T) { + for _, options := range [][]Option{nil, {WithExecution(&inputRecorder{})}, {WithEnvironmentRemoteURL(environmentOrigin)}} { + for _, stream := range []bool{false, true} { + handler, fixture := environmentCreationHandler(t, "codex", options...) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/remote/workspace"},"stream":%t}`, stream) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + var failure v1.ErrorResponse + if response.Code != http.StatusServiceUnavailable || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code != "execution_unavailable" || fixture.input.Engine != "" { + t.Fatal("operator prerequisites did not fail before persistence", response.Code, response.Body.String(), fixture.input) + } + } + } +} + +func TestHostedCreationRequiresOperatorExecution(t *testing.T) { + for _, stream := range []bool{false, true} { + handler, fixture := environmentCreationHandler(t, "codex", WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL(environmentOrigin)) + body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t}`, stream) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + var failure v1.ErrorResponse + if response.Code != http.StatusServiceUnavailable || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code != "execution_unavailable" || fixture.input.Engine != "" { + t.Fatal("hosted configuration bypassed operator prerequisites", response.Code, response.Body.String()) + } + } +} diff --git a/services/agents-api/internal/api/environment_files.go b/services/agents-api/internal/api/environment_files.go new file mode 100644 index 000000000..3be4fe99a --- /dev/null +++ b/services/agents-api/internal/api/environment_files.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "net/http" + "path" + "slices" + "strings" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type EnvironmentDirectoryReader interface { + ReadEnvironmentDirectory(context.Context, store.Environment, string) (proto.WorkspaceDirectoryResult, error) +} + +func WithEnvironmentDirectoryReader(reader EnvironmentDirectoryReader) Option { + return func(h *Handler) { h.directoryReader = reader } +} + +// @Summary List live Environment files +// @Description Lists direct regular files in one authorized self_hosted or qualified local workspace directory. Local paths use the public /workspace root. This partial implementation defaults to the workspace root and limit 20; recursive scope, directory/symlink treatment and these defaults are not verified upstream semantics. Sorts by case-sensitive path components, descending by default. Keep the same path, order and limit when using page. Each page rereads the complete bounded directory; changed file paths/sizes invalidate continuation locally with 400. There is no snapshot guarantee. Truncated or uncertain native results fail with 503 without returning a partial page. This read never starts a Turn or admits model input. Actual transport disconnect/reconnect events remain observable. +// @Tags Environments +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_id path string true "Environment ID" +// @Param path query string false "Absolute directory inside the Environment workspace" +// @Param limit query int false "Maximum file count; local default 20" minimum(1) maximum(100) +// @Param order query string false "Case-sensitive path-component order" Enums(asc,desc) default(desc) +// @Param page query string false "Opaque continuation token; keep path, order and limit unchanged" +// @Success 200 {object} v1.EnvironmentFileList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/environments/{environment_id}/files [get] +func (h *Handler) listEnvironmentFiles(w http.ResponseWriter, r *http.Request) { + environment, err := h.store.GetEnvironment(r.Context(), tenantID(r), chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + options, ok := readEnvironmentFileQuery(w, r, environment) + if !ok { + return + } + if h.directoryReader == nil { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + // Allow the Worker's 45-second observation budget plus response delivery. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(50 * time.Second)); err != nil { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + result, err := h.directoryReader.ReadEnvironmentDirectory(r.Context(), environment, options.relativeDirectory) + if err != nil { + writeStoreError(w, r, err) + return + } + if result.Truncated || !proto.ValidWorkspaceDirectory(&result, proto.WorkspaceDirectoryMaxEntries) { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + files := make([]v1.EnvironmentFile, 0, len(result.Entries)) + for _, entry := range result.Entries { + if entry.Kind == "file" { + files = append(files, v1.EnvironmentFile{ + EnvironmentID: environment.ID, Object: "agent.environment.file", + Path: path.Join(options.directory, entry.Name), SizeBytes: *entry.SizeBytes, + }) + } + } + // All entries share one parent, so comparing their final components is sufficient. + slices.SortFunc(files, func(a, b v1.EnvironmentFile) int { + comparison := strings.Compare(a.Path, b.Path) + if !options.ascending { + return -comparison + } + return comparison + }) + response, err := environmentFilePage(files, options) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/environment_files_completeness_test.go b/services/agents-api/internal/api/environment_files_completeness_test.go new file mode 100644 index 000000000..8d3f63958 --- /dev/null +++ b/services/agents-api/internal/api/environment_files_completeness_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestEnvironmentFilesRejectsIncompleteOrMalformedDirectories(t *testing.T) { + for name, result := range map[string]proto.WorkspaceDirectoryResult{ + "truncated": {Entries: []proto.WorkspaceDirectoryEntry{environmentFileEntry("secret", 1)}, Truncated: true}, + "missing entries": {}, + "invalid name": {Entries: []proto.WorkspaceDirectoryEntry{environmentFileEntry("../secret", 1)}}, + "duplicate": {Entries: []proto.WorkspaceDirectoryEntry{environmentFileEntry("secret", 1), environmentFileEntry("secret", 1)}}, + "missing size": {Entries: []proto.WorkspaceDirectoryEntry{{Name: "secret", Kind: "file"}}}, + "negative size": {Entries: []proto.WorkspaceDirectoryEntry{environmentFileEntry("secret", -1)}}, + "unknown kind": {Entries: []proto.WorkspaceDirectoryEntry{{Name: "secret", Kind: "unknown"}}}, + "oversized": {Entries: make([]proto.WorkspaceDirectoryEntry, proto.WorkspaceDirectoryMaxEntries+1)}, + } { + t.Run(name, func(t *testing.T) { + h, f := environmentFilesHandler(t, true) + f.result = result + w := requestEnvironmentFiles(h, f.environment.ID, "?limit=1", "files-key") + if w.Code != 503 || strings.Contains(w.Body.String(), `"data"`) || strings.Contains(w.Body.String(), `"next"`) || strings.Contains(w.Body.String(), "secret") { + t.Fatal("unsafe incomplete response", w.Code, w.Body) + } + }) + } +} + +func TestEnvironmentFilesCursorRejectsChangesAndAcceptsNativeReordering(t *testing.T) { + for _, change := range []string{"limit", "order", "path", "environment", "size", "name", "removed", "added", "native order"} { + t.Run(change, func(t *testing.T) { + h, f := environmentFilesHandler(t, true) + f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("A", 1), environmentFileEntry("B", 2)} + page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?limit=1", "files-key")) + q := url.Values{"limit": {"1"}, "page": {*page.Next}} + switch change { + case "limit": + q.Set("limit", "2") + case "order": + q.Set("order", "asc") + case "path": + q.Set("path", "/private/workspace/sub") + case "environment": + f.environment.ID = "other-authorized-environment" + case "size": + f.result.Entries[0] = environmentFileEntry("A", 3) + case "name": + f.result.Entries[0] = environmentFileEntry("C", 1) + case "removed": + f.result.Entries = f.result.Entries[:1] + case "added": + f.result.Entries = append(f.result.Entries, environmentFileEntry("C", 3)) + case "native order": + f.result.Entries[0], f.result.Entries[1] = f.result.Entries[1], f.result.Entries[0] + } + w := requestEnvironmentFiles(h, f.environment.ID, "?"+q.Encode(), "files-key") + if change == "native order" { + decodeEnvironmentFiles(t, w) + } else if w.Code != 400 || strings.Contains(w.Body.String(), `"data"`) { + t.Fatal("cursor accepted changed listing", w.Code, w.Body) + } + }) + } +} + +func TestEnvironmentFilesMalformedCursorBounds(t *testing.T) { + for _, change := range []string{"version", "binding", "fingerprint", "negative", "overflow", "unknown", "trailing", "non-page offset"} { + t.Run(change, func(t *testing.T) { + h, f := environmentFilesHandler(t, true) + f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("A", 1), environmentFileEntry("B", 2), environmentFileEntry("C", 3)} + page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?limit=2", "files-key")) + raw, _ := base64.RawURLEncoding.DecodeString(*page.Next) + var cursor map[string]any + _ = json.Unmarshal(raw, &cursor) + switch change { + case "version": + cursor["v"] = 2 + case "binding": + cursor["b"] = "other" + case "fingerprint": + cursor["f"] = strings.Repeat("0", 64) + case "negative": + cursor["o"] = -1 + case "overflow": + cursor["o"] = 1e30 + case "unknown": + cursor["x"] = 1 + case "non-page offset": + cursor["o"] = 1 + } + raw, _ = json.Marshal(cursor) + if change == "trailing" { + raw = append(raw, []byte(` {}`)...) + } + q := url.Values{"limit": {"2"}, "page": {base64.RawURLEncoding.EncodeToString(raw)}} + w := requestEnvironmentFiles(h, f.environment.ID, "?"+q.Encode(), "files-key") + if w.Code != 400 { + t.Fatal("malformed cursor accepted", w.Code, w.Body) + } + }) + } +} diff --git a/services/agents-api/internal/api/environment_files_create.go b/services/agents-api/internal/api/environment_files_create.go new file mode 100644 index 000000000..2dd7999f2 --- /dev/null +++ b/services/agents-api/internal/api/environment_files_create.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "path" + "strings" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type EnvironmentFileWriter interface { + WriteEnvironmentFile(context.Context, store.Environment, string, []byte) (int64, error) +} + +func WithEnvironmentFileWriter(writer EnvironmentFileWriter) Option { + return func(h *Handler) { h.fileWriter = writer } +} + +// @Summary Create an Environment file from inline bytes or a source file +// @Description Uploads standard Base64 bytes to a file beneath /workspace in a qualified local Environment. Accepts inline bytes or a project-owned source file_id through the same write path. Basic public hosted creation requires explicit managed Runtime configuration. A private 50 MiB decoded-content limit applies. The parent directory must exist. Replacement installs a new mode-0600 inode; upstream overwrite metadata semantics remain unverified. Idle writes exclude execution. Missing receipts return unavailable and retain a durable mutation gate without automatic replay. Error/timing parity with upstream remains unverified. +// @Tags Environments +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_id path string true "Environment ID" +// @Param request body v1.EnvironmentFileCreateRequest true "Inline bytes or source file ID and absolute workspace path" +// @Success 200 {object} v1.EnvironmentFile +// @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse +// @Router /agents/environments/{environment_id}/files [post] +func (h *Handler) createEnvironmentFile(w http.ResponseWriter, r *http.Request) { + environment, err := h.store.GetEnvironment(r.Context(), tenantID(r), chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + if r.URL.RawQuery != "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + const maxJSON = int64(((proto.WorkspaceWriteMaxBytes+2)/3)*4 + (16 << 10)) + raw, ok := readJSONBodyLimit(w, r, maxJSON, "Inline upload exceeds this service's bounded file limit.") + if !ok { + return + } + var request v1.EnvironmentFileCreateRequest + fields := []string{"type", "path", "data", "file_id"} + if err := decodeInputObject(raw, &request, fields...); err != nil || request.Path == nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + switch request.Type { + case "inline": + fields = []string{"type", "path", "data"} + if request.Data == nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + case "file_id": + fields = []string{"type", "path", "file_id"} + if request.FileID == nil || *request.FileID == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + default: + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if decodeInputObject(raw, &request, fields...) != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if !validEnvironmentFilePath(*request.Path) || path.Clean(*request.Path) != *request.Path || !strings.HasPrefix(*request.Path, "/workspace/") { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + var data []byte + if request.Type == "inline" { + data, err = base64.StdEncoding.Strict().DecodeString(*request.Data) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if len(data) > proto.WorkspaceWriteMaxBytes { + writeStoreError(w, r, store.ErrSourceFileTooLarge) + return + } + } else { + if !h.sourceFilesReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + err = h.sourceFiles.ReadSourceFile(ctx, tenantID(r), *request.FileID, func(file store.SourceFile, body io.Reader) error { + if file.SizeBytes > proto.WorkspaceWriteMaxBytes { + return store.ErrSourceFileTooLarge + } + data, err = io.ReadAll(io.LimitReader(body, proto.WorkspaceWriteMaxBytes+1)) + if err == nil && int64(len(data)) != file.SizeBytes { + return io.ErrUnexpectedEOF + } + return err + }) + if err != nil { + writeStoreError(w, r, err) + return + } + } + if h.fileWriter == nil || !execution.LocalWorkspaceConfiguration(environment.Configuration) { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(215 * time.Second)); err != nil { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + size, err := h.fileWriter.WriteEnvironmentFile(r.Context(), environment, strings.TrimPrefix(*request.Path, "/workspace/"), data) + if err != nil { + writeStoreError(w, r, err) + return + } + if size != int64(len(data)) { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return + } + writeJSON(w, http.StatusOK, v1.EnvironmentFile{EnvironmentID: environment.ID, Object: "agent.environment.file", Path: *request.Path, SizeBytes: size}) +} diff --git a/services/agents-api/internal/api/environment_files_create_test.go b/services/agents-api/internal/api/environment_files_create_test.go new file mode 100644 index 000000000..f0c8cec11 --- /dev/null +++ b/services/agents-api/internal/api/environment_files_create_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentFileCreateFixture struct { + *environmentFilesFixture + writes int + path string + data []byte + err error + wrongSize bool +} + +func (f *environmentFileCreateFixture) WriteEnvironmentFile(_ context.Context, environment store.Environment, path string, data []byte) (int64, error) { + f.writes++ + f.readEnvironment, f.path, f.data = environment, path, append([]byte(nil), data...) + if f.wrongSize { + return int64(len(data) + 1), nil + } + return int64(len(data)), f.err +} + +func environmentFileCreateHandler(t *testing.T, extra ...Option) (http.Handler, *environmentFileCreateFixture) { + t.Helper() + _, base := environmentFilesHandler(t, false) + base.environment.Configuration = json.RawMessage(`{"type":"openai_hosted","network":{"access":"disabled"}}`) + f := &environmentFileCreateFixture{environmentFilesFixture: base} + auth, err := NewAuthenticator([]APIKey{ + {OrganizationID: "org", ProjectID: "project", SubjectKind: "user", SubjectID: "caller", TokenSHA256: device.HashCredential("files-key"), TenantID: f.environment.TenantID}, + {OrganizationID: "org", ProjectID: "other", SubjectKind: "user", SubjectID: "other", TokenSHA256: device.HashCredential("other-key"), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + options := append([]Option{WithEnvironmentFileWriter(f), WithEnvironmentDirectoryReader(f)}, extra...) + h, err := NewHandler(f, auth, "codex", options...) + if err != nil { + t.Fatal(err) + } + return h, f +} + +func requestCreateEnvironmentFile(h http.Handler, id, body, key string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/"+id+"/files", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+key) + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(environmentFilesRecorder{w}, r) + return w +} + +func TestEnvironmentFileCreateInlineAndLocalListing(t *testing.T) { + for _, data := range [][]byte{{}, {0, 1, 255}, bytes.Repeat([]byte{0, 255, 7}, 400000)} { + h, f := environmentFileCreateHandler(t) + body, _ := json.Marshal(map[string]any{"type": "inline", "data": base64.StdEncoding.EncodeToString(data), "path": "/workspace/input.bin"}) + w := requestCreateEnvironmentFile(h, f.environment.ID, string(body), "files-key") + var file v1.EnvironmentFile + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &file) != nil { + t.Fatalf("create: %d %s", w.Code, w.Body) + } + if f.writes != 1 || f.path != "input.bin" || !bytes.Equal(f.data, data) || f.readEnvironment.ID != f.environment.ID || file.EnvironmentID != f.environment.ID || file.Path != "/workspace/input.bin" || file.Object != "agent.environment.file" || file.SizeBytes != int64(len(data)) { + t.Fatal("scope/data/metadata changed") + } + var fields map[string]json.RawMessage + if json.Unmarshal(w.Body.Bytes(), &fields) != nil || len(fields) != 4 { + t.Fatal("nonprotocol response fields") + } + f.result.Entries = append(f.result.Entries, environmentFileEntry("input.bin", int64(len(data)))) + page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?order=asc", "files-key")) + if len(page.Data) != 1 || page.Data[0] != file || f.directory != "" { + t.Fatal("local list/create path mismatch", page) + } + } +} + +func TestEnvironmentFileCreateRejectsInvalidUnionAndPath(t *testing.T) { + for _, body := range []string{ + `null`, `[]`, `{}`, `{"type":"inline","path":"/workspace/a"}`, + `{"type":"inline","data":null,"path":"/workspace/a"}`, + `{"type":"inline","data":"","path":null}`, + `{"type":"inline","data":"?","path":"/workspace/a"}`, + `{"type":"inline","data":"","path":"/workspace"}`, + `{"type":"inline","data":"","path":"/workspacex/a"}`, + `{"type":"inline","data":"","path":"/workspace/../secret"}`, + `{"type":"inline","data":"","path":"/workspace/a/"}`, + `{"type":"inline","data":"","path":"/workspace/a","file_id":"x"}`, + `{"type":"file_id","file_id":null,"path":"/workspace/a"}`, + `{"type":"inline","data":"","path":"/workspace/a"} {}`, + } { + h, f := environmentFileCreateHandler(t) + w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key") + if w.Code != 400 || f.writes != 0 { + t.Fatalf("invalid body admitted %s: %d", body, w.Code) + } + } +} + +func TestEnvironmentFileCreateAuthorityAndUncertainResults(t *testing.T) { + body := `{"type":"inline","data":"YWJj","path":"/workspace/a"}` + h, f := environmentFileCreateHandler(t) + for _, key := range []string{"other-key", "invalid"} { + w := requestCreateEnvironmentFile(h, f.environment.ID, body, key) + if (key == "other-key" && w.Code != 404) || (key == "invalid" && w.Code != 401) || f.writes != 0 { + t.Fatal("caller authority bypassed", w.Code) + } + } + f.err = execution.ErrExecutionUnavailable + if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 503 { + t.Fatal("uncertain write reported success", w.Code) + } + f.err, f.wrongSize = nil, true + if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 503 { + t.Fatal("wrong byte count reported success", w.Code) + } + f.environment.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`) + f.writes = 0 + if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 503 || f.writes != 0 { + t.Fatal("unsupported placement admitted", w.Code) + } +} diff --git a/services/agents-api/internal/api/environment_files_cursor.go b/services/agents-api/internal/api/environment_files_cursor.go new file mode 100644 index 000000000..7fabf2dfd --- /dev/null +++ b/services/agents-api/internal/api/environment_files_cursor.go @@ -0,0 +1,62 @@ +package api + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type environmentFileCursor struct { + Version int `json:"v"` + Binding string `json:"b"` + Fingerprint string `json:"f"` + Offset int `json:"o"` +} + +func environmentFilesDigest(value any) string { + data, _ := json.Marshal(value) + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func decodeEnvironmentFileCursor(token, binding string) (environmentFileCursor, error) { + var cursor environmentFileCursor + if len(token) > 1024 { + return cursor, store.ErrInvalidInput + } + data, err := base64.RawURLEncoding.Strict().DecodeString(token) + if err != nil { + return cursor, store.ErrInvalidInput + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&cursor) != nil || decoder.Decode(new(any)) != io.EOF || cursor.Version != 1 || cursor.Binding != binding || len(cursor.Fingerprint) != 64 || cursor.Offset <= 0 { + return environmentFileCursor{}, store.ErrInvalidInput + } + return cursor, nil +} + +func environmentFilePage(files []v1.EnvironmentFile, options environmentFileOptions) (v1.EnvironmentFileList, error) { + fingerprint := environmentFilesDigest(files) + start := 0 + if cursor := options.cursor; cursor != nil { + if cursor.Fingerprint != fingerprint || cursor.Offset >= len(files) || cursor.Offset%options.limit != 0 { + return v1.EnvironmentFileList{}, store.ErrInvalidInput + } + start = cursor.Offset + } + end := min(start+options.limit, len(files)) + response := v1.EnvironmentFileList{Data: files[start:end]} + if end < len(files) { + data, _ := json.Marshal(environmentFileCursor{Version: 1, Binding: options.binding, Fingerprint: fingerprint, Offset: end}) + token := base64.RawURLEncoding.EncodeToString(data) + response.Next = &token + } + return response, nil +} diff --git a/services/agents-api/internal/api/environment_files_deadline_test.go b/services/agents-api/internal/api/environment_files_deadline_test.go new file mode 100644 index 000000000..ea87ace2f --- /dev/null +++ b/services/agents-api/internal/api/environment_files_deadline_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" +) + +func TestEnvironmentFilesReadOutlivesDefaultHTTPWriteDeadline(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + handler, fixture := environmentFilesHandler(t, true) + fixture.readDelay = 100 * time.Millisecond + if status == http.StatusServiceUnavailable { + fixture.readError = execution.ErrExecutionUnavailable + } + server := httptest.NewUnstartedServer(handler) + server.Config.WriteTimeout = 50 * time.Millisecond + server.Start() + defer server.Close() + client := server.Client() + client.Timeout = 5 * time.Second + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + server.URL+"/v1/agents/environments/"+fixture.environment.ID+"/files", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer files-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response, err := client.Do(request) + if err != nil { + t.Fatal("read lost its response to the default write deadline", err) + } + defer response.Body.Close() + var body map[string]json.RawMessage + if err := json.NewDecoder(response.Body).Decode(&body); err != nil || response.StatusCode != status { + t.Fatal("invalid delayed response", response.StatusCode, err) + } + if status == http.StatusOK { + if string(body["data"]) != "[]" || string(body["next"]) != "null" { + t.Fatal("delayed page changed shape") + } + } else if body["error"] == nil || body["data"] != nil || body["next"] != nil { + t.Fatal("unavailable delayed read exposed a page") + } + }) + } +} diff --git a/services/agents-api/internal/api/environment_files_query.go b/services/agents-api/internal/api/environment_files_query.go new file mode 100644 index 000000000..4cd8da3f0 --- /dev/null +++ b/services/agents-api/internal/api/environment_files_query.go @@ -0,0 +1,88 @@ +package api + +import ( + "net/http" + "net/url" + "path" + "slices" + "strings" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type environmentFileOptions struct { + directory string + relativeDirectory string + limit int + ascending bool + binding string + cursor *environmentFileCursor +} + +func readEnvironmentFileQuery(w http.ResponseWriter, r *http.Request, environment store.Environment) (environmentFileOptions, bool) { + var options environmentFileOptions + q, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return options, false + } + for key, values := range q { + if !slices.Contains([]string{"path", "limit", "order", "page"}, key) || len(values) != 1 || values[0] == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Supported list parameters are path, limit, order and page, each supplied once with a nonempty value.") + return options, false + } + } + pageQuery := url.Values{} + for _, key := range []string{"limit", "order"} { + if values, exists := q[key]; exists { + pageQuery[key] = values + } + } + page, ok := readPageQuery(w, pageQuery, true) + if !ok { + return options, false + } + root := "/workspace" + if !execution.LocalWorkspaceConfiguration(environment.Configuration) { + configuration, err := decodeSessionEnvironment(environment.Configuration) + if err != nil || configuration.Type != "self_hosted" || len(configuration.CapabilityDirectories) != 0 || !validEnvironmentFilePath(configuration.WorkspaceDirectory) { + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return options, false + } + root = path.Clean(configuration.WorkspaceDirectory) + } + directory := root + if requested, exists := q["path"]; exists { + if !validEnvironmentFilePath(requested[0]) { + writeStoreError(w, r, store.ErrInvalidInput) + return options, false + } + directory = path.Clean(requested[0]) + } + rootPrefix := strings.TrimSuffix(root, "/") + "/" + if directory != root && !strings.HasPrefix(directory, rootPrefix) { + writeStoreError(w, r, store.ErrInvalidInput) + return options, false + } + options = environmentFileOptions{directory: directory, limit: page.limit, ascending: page.ascending} + if directory != root { + options.relativeDirectory = strings.TrimPrefix(directory, rootPrefix) + } + options.binding = environmentFilesDigest([]any{tenantID(r), environment.ID, directory, options.limit, options.ascending}) + if token, exists := q["page"]; exists { + cursor, err := decodeEnvironmentFileCursor(token[0], options.binding) + if err != nil { + writeStoreError(w, r, err) + return environmentFileOptions{}, false + } + options.cursor = &cursor + } + return options, true +} + +func validEnvironmentFilePath(value string) bool { + return path.IsAbs(value) && len(value) <= 4096 && utf8.ValidString(value) && + !strings.ContainsAny(value, "\\\x00\r\n") && !slices.Contains(strings.Split(value, "/"), "..") +} diff --git a/services/agents-api/internal/api/environment_files_test.go b/services/agents-api/internal/api/environment_files_test.go new file mode 100644 index 000000000..4bc23fbe1 --- /dev/null +++ b/services/agents-api/internal/api/environment_files_test.go @@ -0,0 +1,240 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentFilesFixture struct { + ResourceStore + environment store.Environment + result proto.WorkspaceDirectoryResult + storeError, readError error + lookups, reads int + directory string + readEnvironment store.Environment + readDelay time.Duration +} + +func (f *environmentFilesFixture) GetEnvironment(_ context.Context, tenant, id string) (store.Environment, error) { + f.lookups++ + if f.storeError != nil { + return store.Environment{}, f.storeError + } + if tenant != f.environment.TenantID || id != f.environment.ID { + return store.Environment{}, store.ErrNotFound + } + return f.environment, nil +} + +func (f *environmentFilesFixture) ReadEnvironmentDirectory(ctx context.Context, environment store.Environment, directory string) (proto.WorkspaceDirectoryResult, error) { + f.reads++ + f.directory, f.readEnvironment = directory, environment + if f.readDelay > 0 { + select { + case <-time.After(f.readDelay): + case <-ctx.Done(): + return proto.WorkspaceDirectoryResult{}, ctx.Err() + } + } + return f.result, f.readError +} + +func environmentFilesHandler(t *testing.T, enabled bool) (http.Handler, *environmentFilesFixture) { + t.Helper() + f := &environmentFilesFixture{ + environment: store.Environment{ID: uuid.NewString(), TenantID: uuid.NewString(), SessionID: uuid.NewString(), Status: "connected", + Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/private/workspace"}`)}, + result: proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}}, + } + keys := []APIKey{} + for _, key := range []struct{ token, tenant, project string }{ + {"files-key", f.environment.TenantID, "files-project"}, + {"shared-key", f.environment.TenantID, "files-project"}, + {"other-key", uuid.NewString(), "other-project"}, + } { + keys = append(keys, APIKey{OrganizationID: "files-org", ProjectID: key.project, SubjectKind: "user", SubjectID: key.token, + TokenSHA256: device.HashCredential(key.token), TenantID: key.tenant}) + } + auth, err := NewAuthenticator(keys) + if err != nil { + t.Fatal(err) + } + options := []Option{} + if enabled { + options = append(options, WithEnvironmentDirectoryReader(f)) + } + h, err := NewHandler(f, auth, "claude_code", options...) + if err != nil { + t.Fatal(err) + } + return h, f +} + +func requestEnvironmentFiles(h http.Handler, id, query, key string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/v1/agents/environments/"+id+"/files"+query, nil) + r.Header.Set("Authorization", "Bearer "+key) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(environmentFilesRecorder{w}, r) + return w +} + +type environmentFilesRecorder struct{ *httptest.ResponseRecorder } + +func (environmentFilesRecorder) SetWriteDeadline(time.Time) error { return nil } + +func environmentFileEntry(name string, size int64) proto.WorkspaceDirectoryEntry { + return proto.WorkspaceDirectoryEntry{Name: name, Kind: "file", SizeBytes: &size} +} + +func decodeEnvironmentFiles(t *testing.T, w *httptest.ResponseRecorder) v1.EnvironmentFileList { + t.Helper() + var page v1.EnvironmentFileList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || page.Data == nil { + t.Fatal("invalid file page", w.Code, w.Body) + } + var fields map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &fields) + if len(fields) != 2 || fields["data"] == nil || !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("unexpected wire page", w.Header(), fields) + } + return page +} + +func TestEnvironmentFilesOrderingPaginationAndProjection(t *testing.T) { + for _, order := range []string{"", "asc", "desc"} { + t.Run("order="+order, func(t *testing.T) { + h, f := environmentFilesHandler(t, true) + f.result.Entries = []proto.WorkspaceDirectoryEntry{ + environmentFileEntry("a.txt", 14), environmentFileEntry("z.txt", 5), environmentFileEntry("A.txt", 0), environmentFileEntry("a-b.txt", 6), + {Name: "directory", Kind: "directory"}, {Name: "symlink", Kind: "symlink"}, {Name: "socket", Kind: "other"}, + } + q := url.Values{"path": {"/private/workspace/sub/.//"}, "limit": {"2"}} + if order != "" { + q.Set("order", order) + } + var got []string + for index := 0; index < 2; index++ { + page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?"+q.Encode(), "files-key")) + if len(page.Data) != 2 { + t.Fatal("wrong page size", page) + } + for _, item := range page.Data { + if item.EnvironmentID != f.environment.ID || item.Object != "agent.environment.file" || item.SizeBytes < 0 { + t.Fatal("wrong projection", item) + } + got = append(got, item.Path) + } + if index == 0 { + if page.Next == nil || *page.Next == "" { + t.Fatal("missing continuation") + } + q.Set("page", *page.Next) + } else if page.Next != nil { + t.Fatal("unexpected continuation") + } + } + want := []string{"/private/workspace/sub/z.txt", "/private/workspace/sub/a.txt", "/private/workspace/sub/a-b.txt", "/private/workspace/sub/A.txt"} + if order == "asc" { + want = []string{want[3], want[2], want[1], want[0]} + } + if !reflect.DeepEqual(got, want) || f.directory != "sub" || !reflect.DeepEqual(f.readEnvironment, f.environment) { + t.Fatal("wrong order or owner", got, f.directory, f.readEnvironment) + } + }) + } +} + +func TestEnvironmentFilesEmptyRootDefaultsAndSharedAccess(t *testing.T) { + h, f := environmentFilesHandler(t, true) + page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "", "shared-key")) + if len(page.Data) != 0 || page.Next != nil || f.directory != "" { + t.Fatal("invalid empty root", page, f.directory) + } + for index := 0; index < 21; index++ { + f.result.Entries = append(f.result.Entries, environmentFileEntry(string(rune('A'+index)), int64(index))) + } + page = decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "", "files-key")) + if len(page.Data) != 20 || page.Next == nil || page.Data[0].Path != "/private/workspace/U" { + t.Fatal("wrong local defaults", page) + } + q := url.Values{"page": {*page.Next}} + page = decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?"+q.Encode(), "files-key")) + if len(page.Data) != 1 || page.Next != nil || page.Data[0].SizeBytes != 0 { + t.Fatal("wrong final page", page) + } +} + +func TestEnvironmentFilesAuthorizationPrecedesInspection(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, query := range []string{"", "?path=/foreign-secret/../&page=invalid&limit=999", "?bad=%GG"} { + h, f := environmentFilesHandler(t, enabled) + w := requestEnvironmentFiles(h, f.environment.ID, query, "other-key") + if w.Code != 404 || f.lookups != 1 || f.reads != 0 || strings.Contains(w.Body.String(), "foreign-secret") || strings.Contains(w.Body.String(), f.environment.ID) { + t.Fatal("foreign resource inspected", w.Code, w.Body, f) + } + } + } + h, f := environmentFilesHandler(t, true) + w := requestEnvironmentFiles(h, f.environment.ID, "", "invalid") + if w.Code != 401 || f.lookups != 0 || f.reads != 0 { + t.Fatal("unauthenticated read", w.Code, f) + } +} + +func TestEnvironmentFilesRejectsInvalidRequestsBeforeRead(t *testing.T) { + for _, query := range []string{ + "limit=0", "limit=101", "limit=no", "limit=", "limit=1&limit=2", "order=ASC", "order=", "after=x", "unknown=x", "path=", "path=relative", "path=/private/workspace-sibling", "path=/private/workspace/../workspace", "path=/private/workspace/a/../../workspace", "path=/private/workspace/%00", "path=/private/workspace/%5C", "path=/private/workspace/%0A", "path=/private/workspace/%FF", "path=x&path=y", "path=" + strings.Repeat("a", 4097), "page=", "page=not-json", "page=" + strings.Repeat("a", 1025), "bad=%GG", + } { + t.Run(query[:min(len(query), 70)], func(t *testing.T) { + h, f := environmentFilesHandler(t, true) + w := requestEnvironmentFiles(h, f.environment.ID, "?"+query, "files-key") + if w.Code != 400 || f.lookups != 1 || f.reads != 0 { + t.Fatal("invalid query reached runtime", w.Code, w.Body, f) + } + }) + } +} + +func TestEnvironmentFilesSafeStoreAndReaderFailures(t *testing.T) { + for _, target := range []string{"store", "reader"} { + for _, test := range []struct { + err error + status int + }{ + {store.ErrNotFound, 404}, {store.ErrInvalidInput, 400}, {execution.ErrExecutionUnavailable, 503}, {errors.New("private-native-secret"), 500}, + } { + h, f := environmentFilesHandler(t, true) + if target == "store" { + f.storeError = test.err + } else { + f.readError = test.err + } + w := requestEnvironmentFiles(h, f.environment.ID, "", "files-key") + if w.Code != test.status || strings.Contains(w.Body.String(), "private-native-secret") || strings.Contains(w.Body.String(), `"data"`) || strings.Contains(w.Body.String(), `"next"`) { + t.Fatal("unsafe error", target, w.Code, w.Body) + } + } + } + h, f := environmentFilesHandler(t, false) + if w := requestEnvironmentFiles(h, f.environment.ID, "", "files-key"); w.Code != 503 || f.reads != 0 { + t.Fatal("missing reader accepted", w.Code, f) + } +} diff --git a/services/agents-api/internal/api/environment_input.go b/services/agents-api/internal/api/environment_input.go new file mode 100644 index 000000000..380bec8d7 --- /dev/null +++ b/services/agents-api/internal/api/environment_input.go @@ -0,0 +1,28 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" +) + +func (h *Handler) setEnvironmentInputWriteDeadline(w http.ResponseWriter, r *http.Request, sessionID string) error { + session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) + if err != nil { + return err + } + var snapshot configuration + if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { + return err + } + if snapshot.Environment.Type != "self_hosted" && snapshot.Environment.Type != "openai_hosted" { + return nil + } + // This read selects only the HTTP budget; admission and its deadline remain under the Session lock. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(6 * time.Minute)); err != nil { + return execution.ErrExecutionUnavailable + } + return nil +} diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/agents-api/internal/api/environment_input_test.go new file mode 100644 index 000000000..14e76ddfd --- /dev/null +++ b/services/agents-api/internal/api/environment_input_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestPublicEnvironmentInputFailureMappings(t *testing.T) { + for _, tc := range []struct { + err error + status int + code string + }{ + {store.ErrEnvironmentUnavailable, http.StatusConflict, "environment_unavailable"}, + {execution.ErrEnvironmentInputExpired, http.StatusConflict, "environment_input_expired"}, + {execution.ErrEnvironmentInputCancelled, http.StatusConflict, "environment_input_cancelled"}, + {execution.ErrExecutionUnavailable, http.StatusServiceUnavailable, "execution_unavailable"}, + } { + t.Run(tc.code, func(t *testing.T) { + recorder := &inputRecorder{err: fmt.Errorf("submission: %w", tc.err)} + handler, _, _ := testHandler(t, WithExecution(recorder)) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/session/events", strings.NewReader(`{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Start"}]}]}]}`)) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Idempotency-Key", "retained-request") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != tc.status || !strings.Contains(response.Body.String(), `"code":"`+tc.code+`"`) || recorder.key != "retained-request" { + t.Fatal("submission failure was not mapped", response.Code, response.Body.String(), recorder.key) + } + }) + } +} + +type waitingEnvironmentInput struct { + InputSubmitter + entered chan struct{} + release chan struct{} +} + +func (s *waitingEnvironmentInput) SubmitInputs(ctx context.Context, _, _, _ string, _ []store.Input) ([]store.InputReceipt, error) { + close(s.entered) + select { + case <-s.release: + return nil, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T) { + for _, environment := range []string{"none", "self_hosted", "openai_hosted"} { + t.Run(environment, func(t *testing.T) { + waiting := &waitingEnvironmentInput{entered: make(chan struct{}), release: make(chan struct{})} + options := []Option{WithExecution(waiting)} + environmentJSON := `{"type":"none"}` + if environment == "self_hosted" { + environmentJSON = `{"type":"self_hosted","workspace_directory":"/remote/workspace"}` + options = append(options, WithEnvironmentRemoteURL(environmentOrigin)) + } + handler, fixture := environmentCreationHandler(t, "codex", options...) + create := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"MiniMax-M3"},"environment":`+environmentJSON+`}`)) + create.Header.Set("Authorization", "Bearer key") + create.Header.Set("OpenAI-Beta", "agents=v1") + created := httptest.NewRecorder() + handler.ServeHTTP(created, create) + if created.Code != http.StatusOK { + t.Fatal("fixture creation failed", created.Code, created.Body.String()) + } + if environment == "openai_hosted" { + // A retained hosted Session still waits when new hosted admission is disabled. + fixture.session.Configuration = json.RawMessage(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"openai_hosted"}}`) + } + server := httptest.NewUnstartedServer(handler) + server.Config.WriteTimeout = 50 * time.Millisecond + server.Start() + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/v1/agents/sessions/"+fixture.session.ID+"/events", strings.NewReader(`{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Start"}]}]}]}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + type result struct { + response *http.Response + err error + } + completed := make(chan result, 1) + go func() { + response, err := server.Client().Do(request) + completed <- result{response, err} + }() + select { + case <-waiting.entered: + case <-ctx.Done(): + t.Fatal("submission did not enter its wait") + } + select { + case outcome := <-completed: + if outcome.response != nil { + _ = outcome.response.Body.Close() + } + t.Fatal("input returned before admission", outcome.err) + case <-time.After(100 * time.Millisecond): + } + close(waiting.release) + select { + case outcome := <-completed: + if outcome.response != nil { + defer outcome.response.Body.Close() + } + if environment != "none" { + if outcome.err != nil || outcome.response.StatusCode != http.StatusNoContent { + t.Fatal("prepared Environment wait lost its response to the ordinary timeout", outcome.err) + } + } else if outcome.err == nil { + t.Fatal("none input unexpectedly replaced the server write deadline", outcome.response.StatusCode) + } + case <-ctx.Done(): + t.Fatal("released input did not finish") + } + }) + } +} diff --git a/services/agents-api/internal/api/environment_request.go b/services/agents-api/internal/api/environment_request.go new file mode 100644 index 000000000..faee62171 --- /dev/null +++ b/services/agents-api/internal/api/environment_request.go @@ -0,0 +1,34 @@ +package api + +import ( + "encoding/json" + "path" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { + var environment v1.Environment + if json.Unmarshal(raw, &environment) != nil { + return nil, store.ErrInvalidInput + } + fields := []string{"type"} + switch environment.Type { + case "none": + case "openai_hosted": + return decodeHostedEnvironment(raw) + case "self_hosted": + fields = append(fields, "workspace_directory", "capability_directories") + if !path.IsAbs(environment.WorkspaceDirectory) || strings.ContainsRune(environment.WorkspaceDirectory, 0) { + return nil, store.ErrInvalidInput + } + default: + return nil, store.ErrInvalidInput + } + if err := decodeInputObject(raw, &environment, fields...); err != nil { + return nil, err + } + return &environment, nil +} diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go new file mode 100644 index 000000000..b7c9dbf16 --- /dev/null +++ b/services/agents-api/internal/api/environment_setup.go @@ -0,0 +1,90 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { + var result store.EnvironmentSetup + if value, ok := fields["env"]; ok { + var entries map[string]*string + if json.Unmarshal(value, &entries) != nil { + return result, store.ErrInvalidInput + } + result.Env = make(map[string]string, len(entries)) + for name, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + result.Env[name] = *entry + } + } + if value, ok := fields["setup_commands"]; ok { + var commands []json.RawMessage + if json.Unmarshal(value, &commands) != nil { + return result, store.ErrInvalidInput + } + for _, command := range commands { + var input struct { + Command *string `json:"command"` + CWD *string `json:"cwd"` + } + if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil { + return result, store.ErrInvalidInput + } + step := store.SetupCommand{Command: *input.Command} + if input.CWD != nil { + if *input.CWD == "" { + return result, store.ErrInvalidInput + } + step.CWD = *input.CWD + } + result.Commands = append(result.Commands, step) + } + } + if value, ok := fields["packages"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + var input struct { + NPM []*string `json:"npm"` + Python []*string `json:"python"` + System []*string `json:"system"` + } + if decodeInputObject(value, &input, "npm", "python", "system") != nil { + return result, store.ErrInvalidInput + } + for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python, "system": input.System} { + var target *[]string + switch name { + case "npm": + target = &result.Packages.NPM + case "python": + target = &result.Packages.Python + case "system": + target = &result.Packages.System + } + for _, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + *target = append(*target, *entry) + } + } + } + var err error + result.Skills, err = decodeInlineSkills(fields["skills"]) + if err != nil { + return result, err + } + return result, result.Validate() +} + +func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackages { + value := store.EnvironmentSetup{} + if packages != nil { + value.Packages = *packages + } + return value.PackageMetadata() +} diff --git a/services/agents-api/internal/api/environment_setup_test.go b/services/agents-api/internal/api/environment_setup_test.go new file mode 100644 index 000000000..04fb900ae --- /dev/null +++ b/services/agents-api/internal/api/environment_setup_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestEnvironmentSetupSharedParsingAndConfidentialSnapshot(t *testing.T) { + raw := []byte(`{"env":{"TOKEN":"private-env-canary","QUOTED":"'\n$(false)"},"packages":{"npm":["is-number@7.0.0"],"python":["packaging==26.0"]},"setup_commands":[{"command":"printf private-command-canary > result","cwd":null},{"command":"pwd","cwd":"/workspace/sub"}]}`) + input, err := decodeTemplateInput(raw) + if err != nil || !input.SetEnv || !input.SetSetup || !input.SetPackages || len(input.Initialization.Commands) != 2 { + t.Fatal("template setup input", err) + } + var request decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted",`), raw[1:len(raw)-1]...), []byte(`}}`)...), &request); err != nil { + t.Fatal(err) + } + decoded, err := request.validated() + if err != nil { + t.Fatal(err) + } + configuration, err := resolve(decoded, "tenant", "key", nil) + if err != nil || bytes.Contains(configuration, []byte("canary")) || !bytes.Contains(configuration, []byte("is-number@7.0.0")) { + t.Fatal("confidential input in ordinary configuration", err) + } + if decoded.initialization.Env["TOKEN"] != input.Initialization.Env["TOKEN"] || len(decoded.initialization.Commands) != 2 { + t.Fatal("inline and template parsing diverged") + } + for _, invalid := range []string{`{"env":{"OPENAI_API_KEY":"x"}}`, `{"env":{"CODEX_HOME":"x"}}`, `{"env":{"PARSAR_HOME":"x"}}`, `{"env":{"BAD-NAME":"x"}}`, `{"env":{"VALUE":null}}`, `{"setup_commands":[null]}`, `{"setup_commands":[{}]}`, `{"setup_commands":[{"command":null}]}`, `{"setup_commands":[{"command":"pwd","cwd":""}]}`, `{"packages":{"python":[null]}}`, `{"packages":{"npm":["--ignore-scripts"]}}`} { + if _, err := decodeTemplateInput([]byte(invalid)); err == nil { + t.Fatal("invalid setup accepted", invalid) + } + } + cleared, err := decodeTemplateInput([]byte(`{"env":null,"setup_commands":null,"packages":null}`)) + if err != nil || !cleared.Initialization.Empty() || !cleared.SetEnv || !cleared.SetSetup || !cleared.SetPackages { + t.Fatal("nullable replacements", err) + } +} + +func TestSavedAgentCreationIntentRetainsInlineSetup(t *testing.T) { + intent := func(environment string) json.RawMessage { + t.Helper() + var request decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent_id":"saved","environment":`+environment+`}`), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + value, err := sessionCreationRequest(input, nil) + if err != nil { + t.Fatal(err) + } + return value + } + variants := []string{ + `{"type":"openai_hosted"}`, + `{"type":"openai_hosted","env":{"TOKEN":"first"}}`, + `{"type":"openai_hosted","env":{"TOKEN":"changed"}}`, + `{"type":"openai_hosted","setup_commands":[{"command":"first"}]}`, + `{"type":"openai_hosted","setup_commands":[{"command":"changed"}]}`, + `{"type":"openai_hosted","env":{"TOKEN":"first"},"setup_commands":[{"command":"first"}]}`, + } + for i, first := range variants { + identity := intent(first) + if !bytes.Equal(identity, intent(first)) { + t.Fatal("unchanged intent differs") + } + for j, second := range variants { + if i != j && bytes.Equal(identity, intent(second)) { + t.Fatalf("changed, added or removed setup lost from intent: %d, %d", i, j) + } + } + } +} diff --git a/services/agents-api/internal/api/environment_skills.go b/services/agents-api/internal/api/environment_skills.go new file mode 100644 index 000000000..11b560227 --- /dev/null +++ b/services/agents-api/internal/api/environment_skills.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeInlineSkills(raw json.RawMessage) ([]store.InlineSkill, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + result := make([]store.InlineSkill, 0, len(entries)) + for _, entry := range entries { + var input struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Source json.RawMessage `json:"source"` + } + if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { + return nil, store.ErrInvalidInput + } + var source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + } + if decodeInputObject(input.Source, &source, "type", "media_type", "data") != nil || source.Type != "base64" || source.MediaType != "application/zip" || len(source.Data) > base64.StdEncoding.EncodedLen(agentskill.MaxArchiveBytes) { + return nil, store.ErrInvalidInput + } + body, err := base64.StdEncoding.Strict().DecodeString(source.Data) + if err != nil { + return nil, store.ErrInvalidInput + } + result = append(result, store.InlineSkill{Metadata: agentskill.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + } + return result, store.ValidateInlineSkills(result) +} + +func skillResponse(skills []agentskill.Metadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(skills)) + for _, skill := range skills { + raw, _ := json.Marshal(skill) + result = append(result, raw) + } + return result +} + +func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { + if len(raw) == 0 { + return []json.RawMessage{}, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + seen := map[string]bool{} + for _, entry := range entries { + var metadata agentskill.Metadata + if decodeInputObject(entry, &metadata, "type", "name", "description") != nil || metadata.Type != "inline" || metadata.Name == "" || metadata.Description == "" || seen[metadata.Name] { + return nil, store.ErrInvalidInput + } + seen[metadata.Name] = true + } + if entries == nil { + entries = []json.RawMessage{} + } + return entries, nil +} diff --git a/services/agents-api/internal/api/environment_skills_test.go b/services/agents-api/internal/api/environment_skills_test.go new file mode 100644 index 000000000..c1eb6e78a --- /dev/null +++ b/services/agents-api/internal/api/environment_skills_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "testing" +) + +func skillInput(t *testing.T, body string) json.RawMessage { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.Create("proof/SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\n" + body)); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof", "description": "A proof.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { + skill := skillInput(t, "private-skill-canary") + raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) + template, err := decodeTemplateInput(raw) + if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { + t.Fatal("template", err) + } + environment := append([]byte(`{"type":"openai_hosted",`), raw[1:]...) + decode := func(agent string, environment []byte) sessionRequest { + var request decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{`+agent+`,"environment":`), environment...), '}'), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + return input + } + inline := decode(`"agent":{"model":"test"}`, environment) + if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Initialization.Skills[0].Archive) { + t.Fatal("inline/template differ") + } + configuration, err := resolve(inline, "tenant", "key", nil) + if err != nil || bytes.Contains(configuration, []byte(`"source"`)) || bytes.Contains(configuration, []byte(`"archive"`)) { + t.Fatal("confidential snapshot", err) + } + public, err := storedEnvironment(mustEnvironment(t, configuration)) + if err != nil || len(public.Skills) != 1 || !bytes.Contains(public.Skills[0], []byte(`"name":"proof"`)) { + t.Fatal("metadata", err) + } + intent, err := sessionCreationRequest(decode(`"agent_id":"saved"`, environment), nil) + if err != nil { + t.Fatal(err) + } + changed := append(append([]byte(`{"type":"openai_hosted","skills":[`), skillInput(t, "different")...), []byte(`]}`)...) + other, err := sessionCreationRequest(decode(`"agent_id":"saved"`, changed), nil) + if err != nil || bytes.Equal(intent, other) { + t.Fatal("archive omitted from retry identity", err) + } + for _, clearing := range []string{`{"skills":null}`, `{"skills":[]}`} { + input, err := decodeTemplateInput([]byte(clearing)) + if err != nil || !input.SetSkills || !input.Initialization.Empty() { + t.Fatal("clear", err) + } + } + for _, invalid := range []string{`{"skills":[null]}`, `{"skills":[{"type":"skill_reference","skill_id":"foreign"}]}`, `{"skills":[{"type":"inline","name":"proof","description":"A proof.","source":{"type":"base64","media_type":"application/zip","data":"invalid"}}]}`} { + if _, err := decodeTemplateInput([]byte(invalid)); err == nil { + t.Fatal("invalid or unsupported skill accepted") + } + } + duplicate := append(append(append(append([]byte(`{"skills":[`), skill...), ','), skill...), []byte(`]}`)...) + if _, err := decodeTemplateInput(duplicate); err == nil { + t.Fatal("duplicate skill accepted") + } +} + +func mustEnvironment(t *testing.T, configuration []byte) json.RawMessage { + t.Helper() + var fields map[string]json.RawMessage + if err := json.Unmarshal(configuration, &fields); err != nil { + t.Fatal(err) + } + return fields["environment"] +} diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go new file mode 100644 index 000000000..40e83da0e --- /dev/null +++ b/services/agents-api/internal/api/environment_templates.go @@ -0,0 +1,217 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "unicode/utf8" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type EnvironmentTemplateStore interface { + ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) + CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error) + UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + DeleteEnvironmentTemplate(context.Context, string, string) (string, error) + ListEnvironmentTemplates(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) +} + +func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { + var fields map[string]json.RawMessage + if decodeInputObject(raw, &fields, "name", "network", "capability_directories", "env", "files", "packages", "plugins", "skills", "setup_commands") != nil { + return store.EnvironmentTemplateInput{}, store.ErrInvalidInput + } + in := store.EnvironmentTemplateInput{} + if value, supplied := fields["name"]; supplied { + in.SetName = true + if json.Unmarshal(value, &in.Name) != nil || (in.Name != nil && (!utf8.ValidString(*in.Name) || utf8.RuneCountInString(*in.Name) < 1 || utf8.RuneCountInString(*in.Name) > 256)) { + return in, store.ErrInvalidInput + } + } + delete(fields, "name") + _, in.SetNetwork = fields["network"] + _, in.SetFiles = fields["files"] + _, in.SetEnv = fields["env"] + _, in.SetSetup = fields["setup_commands"] + _, in.SetPackages = fields["packages"] + _, in.SetSkills = fields["skills"] + var setupErr error + in.Initialization, setupErr = decodeEnvironmentSetup(fields) + if setupErr != nil { + return in, setupErr + } + var fileErr error + in.Files, fileErr = decodeInitialFiles(fields["files"]) + if fileErr != nil { + return in, fileErr + } + fields["type"] = json.RawMessage(`"openai_hosted"`) + configuration, err := json.Marshal(fields) + if err != nil { + return in, err + } + environment, err := decodeHostedEnvironment(configuration) + if err != nil { + return in, err + } + in.NetworkAccess = environment.Network.Access + return in, nil +} + +func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: skillResponse(t.Skills)} +} + +func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "This template operation does not accept query parameters.") + return false + } + return true +} + +func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.EnvironmentTemplateInput, bool) { + if !templateNoQuery(w, r) { + return store.EnvironmentTemplateInput{}, false + } + raw, ok := readJSONBodyLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return store.EnvironmentTemplateInput{}, false + } + in, err := decodeTemplateInput(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled network, initial files, env, system/npm/Python packages, setup commands and inline Skill ZIPs are supported.") + return in, false + } + return in, true +} + +// @Summary Create an Environment Template +// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. Omitted/null network defaults to enabled. Other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.EnvironmentTemplateRequest true "Reusable configuration" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [post] +func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.CreateEnvironmentTemplate(r.Context(), tenantID(r), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Retrieve an Environment Template +// @Description Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found response. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [get] +func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + if !templateNoQuery(w, r) { + return + } + value, err := h.store.GetEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Update an Environment Template +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Other populated installations are unsupported. Exact hosted no-op timestamp behavior remains unverified. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Param body body v1.EnvironmentTemplateRequest true "Configuration replacements" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [post] +func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.UpdateEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id"), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Delete an Environment Template +// @Description Deletes the tenant-owned reusable configuration without changing or deleting existing Sessions and their frozen configuration. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplateDeleted +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [delete] +func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + if !templateNoQuery(w, r) { + return + } + id, err := h.store.DeleteEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.EnvironmentTemplateDeleted{ID: id, Object: "agent.environment.template.deleted", Deleted: true}) +} + +// @Summary List Environment Templates +// @Description Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit must be 1–100. Foreign and missing cursors reject identically. Concurrent-page and exact hosted error behavior remain unverified. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Previous Template ID" +// @Param limit query integer false "Page size" default(20) minimum(1) maximum(100) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.EnvironmentTemplateList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [get] +func (h *Handler) listEnvironmentTemplates(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.store.ListEnvironmentTemplates(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.EnvironmentTemplateList{Object: "list", Data: make([]v1.EnvironmentTemplate, 0, len(page.Templates)), HasMore: page.HasMore} + for _, value := range page.Templates { + response.Data = append(response.Data, templateResponse(value)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go new file mode 100644 index 000000000..a370ab46e --- /dev/null +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { + for _, raw := range []string{`{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"packages":{"system":["jq","libpq-dev"]}}`, `{"packages":{"system":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { + if _, err := decodeTemplateInput([]byte(raw)); err != nil { + t.Fatalf("supported input: %s: %v", raw, err) + } + } + for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"network":{"access":"restricted","allowed_domains":["example.com"]}}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["-o"]}}`, `{"packages":{"system":[""]}}`, `{"packages":{"system":[null]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { + if _, err := decodeTemplateInput([]byte(raw)); err == nil { + t.Fatalf("unsupported input accepted: %s", raw) + } + } + h, _, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/templates", strings.NewReader(`{"env":{"PATH":"confidential-canary"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), "confidential-canary") { + t.Fatal("confidential input not safely rejected", response.Code, response.Body.String()) + } +} + +type templateLookupStore struct { + ResourceStore + network string + tenant string +} + +func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []store.InitialFile, error) { + s.tenant = tenant + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network}, nil, nil +} + +func TestTemplateResolutionAndCreationIntent(t *testing.T) { + lookup := &templateLookupStore{network: "disabled"} + h := Handler{store: lookup} + request := func(raw string) sessionRequest { + t.Helper() + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":`+raw+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + return input + } + inherited := request(`{"type":"openai_hosted","environment_template_id":"saved"}`) + intent, err := sessionCreationRequest(inherited, nil) + if err != nil || !strings.Contains(string(intent), `"environment_template_id":"saved"`) { + t.Fatal("missing caller intent", string(intent), err) + } + if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &inherited); err != nil || inherited.Environment.Network.Access != "disabled" || lookup.tenant != "tenant-a" { + t.Fatal("inheritance failed", err) + } + raw, _ := json.Marshal(inherited.Environment) + if strings.Contains(string(raw), "template") { + t.Fatal("template leaked to execution", string(raw)) + } + broader := request(`{"type":"openai_hosted","environment_template_id":"saved","network":{"access":"enabled"}}`) + broaderIntent, _ := sessionCreationRequest(broader, nil) + if string(broaderIntent) == string(intent) { + t.Fatal("default erased caller override") + } + if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &broader); err == nil { + t.Fatal("network broadened") + } + narrower := request(`{"type":"openai_hosted","environment_template_id":"saved","network":{"access":"disabled"}}`) + lookup.network = "enabled" + if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &narrower); err != nil { + t.Fatal(err) + } + for _, raw := range []string{`{"type":"openai_hosted","environment_template_id":null}`, `{"type":"none","environment_template_id":"saved"}`, `{"type":"openai_hosted","environment_template_id":"saved","network":null}`, `{"type":"openai_hosted","environment_template_id":"saved","env":{"KEY":"secret"}}`} { + if _, _, _, err := decodeTemplateEnvironment(json.RawMessage(raw)); err == nil { + t.Fatal("invalid reference accepted", raw) + } + } +} diff --git a/services/agents-api/internal/api/environments.go b/services/agents-api/internal/api/environments.go new file mode 100644 index 000000000..eefec6487 --- /dev/null +++ b/services/agents-api/internal/api/environments.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Retrieve an execution Environment +// @Description Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose frozen safe metadata without content; empty plugins/skills describe the absence of API-managed installations, not the contents or discovered capabilities of the caller's machine. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status does not prove native readiness or process quiescence. +// @Tags Environments +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_id path string true "Environment ID" +// @Success 200 {object} v1.EnvironmentInfo +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/{environment_id} [get] +func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Environment retrieval does not accept query parameters.") + return + } + environment, err := h.store.GetEnvironment(r.Context(), tenantID(r), chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := environmentResponse(environment) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, error) { + configuration, err := storedEnvironment(environment.Configuration) + if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || len(configuration.CapabilityDirectories) != 0 || environment.ID == "" { + return v1.EnvironmentInfo{}, errors.New("unsupported stored environment metadata configuration") + } + switch environment.Status { + case "pending", "connected", "disconnected", "expired", "failed": + default: + return v1.EnvironmentInfo{}, errors.New("unsupported stored environment resource status") + } + files := configuration.Files + if files == nil { + files = []json.RawMessage{} + } + if configuration.Skills == nil { + configuration.Skills = []json.RawMessage{} + } + return v1.EnvironmentInfo{ + ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, + Files: files, Plugins: []json.RawMessage{}, Skills: configuration.Skills, + }, nil +} diff --git a/services/agents-api/internal/api/environments_test.go b/services/agents-api/internal/api/environments_test.go new file mode 100644 index 000000000..9ee62301f --- /dev/null +++ b/services/agents-api/internal/api/environments_test.go @@ -0,0 +1,151 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentResourceFixture struct { + ResourceStore + environment store.Environment + err error + tenant, id string + calls int +} + +func (f *environmentResourceFixture) GetEnvironment(_ context.Context, tenant, id string) (store.Environment, error) { + f.tenant, f.id = tenant, id + f.calls++ + return f.environment, f.err +} + +func environmentResourceHandler(t *testing.T) (http.Handler, *environmentResourceFixture) { + t.Helper() + f := &environmentResourceFixture{environment: store.Environment{ + ID: uuid.NewString(), TenantID: uuid.NewString(), SessionID: uuid.NewString(), Status: "pending", + Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/private/workspace"}`), + }} + auth, err := NewAuthenticator([]APIKey{{ + OrganizationID: "resource-org", ProjectID: "resource-project", SubjectKind: "user", SubjectID: "resource-reader", + TokenSHA256: device.HashCredential("resource-key"), TenantID: f.environment.TenantID, + }}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(f, auth, "claude_code") + if err != nil { + t.Fatal(err) + } + return h, f +} + +func TestEnvironmentResourceExactProjectionWithoutExecution(t *testing.T) { + for _, status := range []string{"pending", "connected", "disconnected", "expired", "failed"} { + t.Run(status, func(t *testing.T) { + h, f := environmentResourceHandler(t) + f.environment.Status = status + request := httptest.NewRequest(http.MethodGet, "/v1/agents/environments/"+f.environment.ID, nil) + request.Header.Set("Authorization", "Bearer resource-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("X-Tenant-ID", "untrusted-tenant") + request.Host = "untrusted.example" + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + var got map[string]any + if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal("resource read failed", w.Code, w.Body) + } + want := map[string]any{ + "id": f.environment.ID, "object": "agent.environment", "type": "self_hosted", "status": status, + "files": []any{}, "plugins": []any{}, "skills": []any{}, + } + if !reflect.DeepEqual(got, want) || f.calls != 1 || f.tenant != f.environment.TenantID || f.id != f.environment.ID { + t.Fatal("resource projection or authenticated lookup changed", got, f) + } + }) + } + for _, capabilities := range []string{"", `,"capability_directories":null`, `,"capability_directories":[]`} { + _, f := environmentResourceHandler(t) + f.environment.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"` + capabilities + `}`) + value, err := environmentResponse(f.environment) + if err != nil || value.Files == nil || value.Plugins == nil || value.Skills == nil { + t.Fatal("equivalent empty installation profile lost required arrays", value, err) + } + } +} + +func TestEnvironmentResourceRejectsUnknownInventoryAndInvalidState(t *testing.T) { + for name, configuration := range map[string]string{ + "missing": `{}`, + "none": `{"type":"none"}`, + "hosted": `{"type":"openai_hosted","env":{"SECRET":"private-canary"}}`, + "capabilities": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":["/skills"]}`, + "files": `{"type":"self_hosted","workspace_directory":"/workspace","files":[{"data":"private-canary"}]}`, + "plugins": `{"type":"self_hosted","workspace_directory":"/workspace","plugins":[]}`, + "skills": `{"type":"self_hosted","workspace_directory":"/workspace","skills":[]}`, + "wrong type": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":false}`, + "invalid": `{"type":"self_hosted","workspace_directory":`, + } { + t.Run(name, func(t *testing.T) { + h, f := environmentResourceHandler(t) + f.environment.Configuration = json.RawMessage(configuration) + request := httptest.NewRequest(http.MethodGet, "/v1/agents/environments/"+f.environment.ID, nil) + request.Header.Set("Authorization", "Bearer resource-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + var response map[string]json.RawMessage + if w.Code != 500 || json.Unmarshal(w.Body.Bytes(), &response) != nil || response["error"] == nil || len(response) != 1 || strings.Contains(w.Body.String(), "private-canary") { + t.Fatal("unsupported inventory became empty metadata or leaked configuration", w.Code, w.Body) + } + }) + } + for _, status := range []string{"", "ready", "unknown"} { + _, f := environmentResourceHandler(t) + f.environment.Status = status + if _, err := environmentResponse(f.environment); err == nil { + t.Fatal("event or unknown status accepted as a resource status", status) + } + } +} + +func TestEnvironmentResourceRequestAndStoreErrors(t *testing.T) { + for _, test := range []struct { + name, method, suffix, auth, beta string + storeError error + status, calls int + }{ + {"no auth", "GET", "", "", "agents=v1", nil, 401, 0}, + {"wrong auth", "GET", "", "Bearer unknown", "agents=v1", nil, 401, 0}, + {"no beta", "GET", "", "Bearer resource-key", "", nil, 400, 0}, + {"wrong beta", "GET", "", "Bearer resource-key", "agents=v2", nil, 400, 0}, + {"query", "GET", "?tenant_id=foreign", "Bearer resource-key", "agents=v1", nil, 400, 0}, + {"method", "POST", "", "Bearer resource-key", "agents=v1", nil, 405, 0}, + {"not found", "GET", "", "Bearer resource-key", "agents=v1", store.ErrNotFound, 404, 1}, + {"invalid id", "GET", "", "Bearer resource-key", "agents=v1", store.ErrInvalidInput, 400, 1}, + {"backend", "GET", "", "Bearer resource-key", "agents=v1", errors.New("private-backend-canary"), 500, 1}, + } { + t.Run(test.name, func(t *testing.T) { + h, f := environmentResourceHandler(t) + f.err = test.storeError + request := httptest.NewRequest(test.method, "/v1/agents/environments/"+f.environment.ID+test.suffix, nil) + request.Header.Set("Authorization", test.auth) + request.Header.Set("OpenAI-Beta", test.beta) + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + if w.Code != test.status || f.calls != test.calls || strings.Contains(w.Body.String(), "private-backend-canary") { + t.Fatal("request or shared error handling changed", w.Code, w.Body, f.calls) + } + }) + } +} diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go new file mode 100644 index 000000000..5ca79d1ab --- /dev/null +++ b/services/agents-api/internal/api/errors.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + kind := "invalid_request_error" + if status >= 500 { + kind = "server_error" + } else if status == http.StatusUnauthorized { + kind = "authentication_error" + } + writeJSON(w, status, v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: code}}) +} + +func writeStoreError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, store.ErrSourceFileTooLarge): + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "File exceeds this operation's content limit.") + case errors.Is(err, store.ErrCredentialStorageUnavailable): + writeError(w, http.StatusServiceUnavailable, "credential_storage_unavailable", "Credential encryption is not configured on this service.") + case errors.Is(err, store.ErrEnvironmentUnavailable): + writeError(w, http.StatusConflict, "environment_unavailable", "The environment is no longer available for new input.") + case errors.Is(err, execution.ErrEnvironmentInputExpired): + writeError(w, http.StatusConflict, "environment_input_expired", "The environment input deadline elapsed before admission.") + case errors.Is(err, execution.ErrEnvironmentInputCancelled): + writeError(w, http.StatusConflict, "environment_input_cancelled", "The environment input was cancelled before admission.") + case errors.Is(err, execution.ErrExecutionUnavailable): + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not available on this service.") + case errors.Is(err, store.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found", "Resource not found.") + case errors.Is(err, store.ErrTurnConflict): + writeError(w, http.StatusConflict, "turn_conflict", "The Turn cannot accept this input in its current state.") + case errors.Is(err, store.ErrIdempotencyConflict): + writeError(w, http.StatusConflict, "idempotency_conflict", "This idempotency key was used with different input.") + case errors.Is(err, store.ErrInvalidInput): + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid resource identifier or request limits.") + default: + // Driver errors can include submitted values; do not log the raw error. + log.Ctx(r.Context()).Error("agents-api persistence operation failed") + writeError(w, http.StatusInternalServerError, "internal_error", "The operation could not be completed.") + } +} diff --git a/services/agents-api/internal/api/execution_policy.go b/services/agents-api/internal/api/execution_policy.go new file mode 100644 index 000000000..b5570c8c9 --- /dev/null +++ b/services/agents-api/internal/api/execution_policy.go @@ -0,0 +1,9 @@ +package api + +import "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + +// WithExecutionPolicy supplies the same immutable qualification used by the +// execution Dispatcher. Omission uses the built-in engine registrations. +func WithExecutionPolicy(policy execution.Policy) Option { + return func(h *Handler) { h.policy = policy } +} diff --git a/services/agents-api/internal/api/function_configuration.go b/services/agents-api/internal/api/function_configuration.go new file mode 100644 index 000000000..2e91e5238 --- /dev/null +++ b/services/agents-api/internal/api/function_configuration.go @@ -0,0 +1,69 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func resolveFunctions(input []v1.FunctionToolInput) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, 0, len(input)) + if len(input) > 64 { + return nil, errors.New("This service supports at most 64 function tools.") + } + names := make(map[string]bool, len(input)) + for _, tool := range input { + value, deferred, err := resolveFunction(tool) + if err != nil { + return nil, err + } + if strings.TrimSpace(*tool.Name) == "" || len(*tool.Name) > 512 || names[*tool.Name] { + return nil, errors.New("Function names must be nonempty, unique and at most 512 bytes.") + } + if deferred { + return nil, errors.New("Deferred function discovery is not supported by this service yet.") + } + + names[*tool.Name] = true + tools = append(tools, value) + } + return tools, nil +} + +// resolveFunction validates the persisted wire shape. Execution admission may +// impose additional restrictions, without narrowing the reusable resource. +func resolveFunction(tool v1.FunctionToolInput) (json.RawMessage, bool, error) { + if tool.Type != "function" || tool.Name == nil || tool.Description == nil { + return nil, false, errors.New("Function tools require type=function, name and description.") + } + var schema map[string]json.RawMessage + if json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, false, errors.New("Function parameters must be a JSON Schema object.") + } + deferred, err := optionalBoolean(tool.DeferLoading, false) + if err != nil { + return nil, false, errors.New("defer_loading must be a boolean when supplied.") + } + value, err := json.Marshal(struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + DeferLoading bool `json:"defer_loading"` + }{"function", *tool.Name, *tool.Description, tool.Parameters, deferred}) + return value, deferred, err +} + +func optionalBoolean(raw json.RawMessage, fallback bool) (bool, error) { + if len(raw) == 0 { + return fallback, nil + } + var value bool + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &value) != nil { + return false, errors.New("Expected a boolean.") + } + return value, nil +} diff --git a/services/agents-api/internal/api/function_configuration_test.go b/services/agents-api/internal/api/function_configuration_test.go new file mode 100644 index 000000000..02a684e06 --- /dev/null +++ b/services/agents-api/internal/api/function_configuration_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestPublicFunctionConfiguration(t *testing.T) { + tool := `{"type":"function","name":"lookup","description":"","parameters":{"const":9007199254740993}}` + for _, suffix := range []string{"", `,"tools":null`, `,"tools":[]`, `,"tools":[` + tool + `]`, `,"tools":[` + strings.TrimSuffix(tool, "}") + `,"defer_loading":false}]`} { + h, s, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model"`+suffix+`},"environment":{"type":"none"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatal(suffix, w.Code, w.Body) + } + var response v1.Session + var saved configuration + if json.Unmarshal(w.Body.Bytes(), &response) != nil || json.Unmarshal(s.input.Configuration, &saved) != nil { + t.Fatal(w.Body) + } + if len(response.Agent.Tools) != len(saved.Agent.Tools) || response.Agent.Tools == nil { + t.Fatal(response.Agent.Tools, saved.Agent.Tools) + } + if strings.Contains(suffix, "lookup") { + if len(response.Agent.Tools) != 1 || !strings.Contains(string(saved.Agent.Tools[0]), `9007199254740993`) || !strings.Contains(string(saved.Agent.Tools[0]), `"defer_loading":false`) { + t.Fatal(saved.Agent.Tools) + } + } else if len(response.Agent.Tools) != 0 { + t.Fatal(response.Agent.Tools) + } + } +} + +func TestPublicFunctionConfigurationRejectsInvalidOrUnsupported(t *testing.T) { + base := `"type":"function","name":"lookup","description":"","parameters":{}` + for _, raw := range []string{ + `[null]`, `[{}]`, `[{"type":"web_search"}]`, `[{"type":"function","name":"lookup","parameters":{}}]`, + `[{"type":"function","name":null,"description":"","parameters":{}}]`, + `[{"type":"function","name":"lookup","description":null,"parameters":{}}]`, + `[{"type":"function","name":"lookup","description":""}]`, + `[{"type":"function","name":"lookup","description":"","parameters":null}]`, + `[{"type":"function","name":"lookup","description":"","parameters":[]}]`, + `[{` + base + `,"defer_loading":null}]`, `[{` + base + `,"defer_loading":true}]`, `[{` + base + `,"defer_loading":"false"}]`, + `[{` + base + `,"unexpected":true}]`, `[{` + base + `},{` + base + `}]`, + } { + h, s, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model","tools":`+raw+`},"environment":{"type":"none"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 400 || s.tenant != "" { + t.Fatal(raw, w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/api/function_inputs.go b/services/agents-api/internal/api/function_inputs.go new file mode 100644 index 000000000..e330ea15c --- /dev/null +++ b/services/agents-api/internal/api/function_inputs.go @@ -0,0 +1,118 @@ +package api + +import ( + "bytes" + "encoding/json" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type decodedInputEvent struct { + v1.SessionInput + Output json.RawMessage `json:"output,omitempty"` +} + +func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { + var event decodedInputEvent + if err := json.Unmarshal(raw, &event); err != nil { + return event, store.ErrInvalidInput + } + fields := []string{"type"} + switch event.Type { + case "agent.session.input.message": + fields = append(fields, "input") + case "agent.session.input.cancel": + case "agent.session.input.tool_result": + fields = append(fields, "call_id", "turn_id", "success", "error", "output") + default: + return event, store.ErrInvalidInput + } + return event, decodeInputObject(raw, &event, fields...) +} + +func decodeInputObject(raw json.RawMessage, value any, allowed ...string) error { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return store.ErrInvalidInput + } + for field := range fields { + if !slices.Contains(allowed, field) { + return store.ErrInvalidInput + } + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil { + return store.ErrInvalidInput + } + return nil +} + +func functionResultInput(event decodedInputEvent) (store.Input, error) { + if event.CallID == "" || event.TurnID == "" || event.Success == nil { + return store.Input{}, store.ErrInvalidInput + } + if len(event.Error) > 0 && !bytes.Equal(bytes.TrimSpace(event.Error), []byte("null")) { + var message string + if json.Unmarshal(event.Error, &message) != nil { + return store.Input{}, store.ErrInvalidInput + } + } + if err := validateFunctionOutput(event.Output); err != nil { + return store.Input{}, err + } + result, err := json.Marshal(struct { + Success bool `json:"success"` + Error json.RawMessage `json:"error,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + }{*event.Success, event.Error, event.Output}) + if err != nil { + return store.Input{}, err + } + payload, err := json.Marshal(store.FunctionResultInput{TurnID: event.TurnID, CallID: event.CallID, Result: result}) + return store.Input{Kind: "tool_result", Payload: payload}, err +} + +func validateFunctionOutput(raw json.RawMessage) error { + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + return nil + } + var parts []json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return store.ErrInvalidInput + } + for _, part := range parts { + var value struct { + Type string `json:"type"` + Text *string `json:"text"` + ImageURL *string `json:"image_url"` + } + if json.Unmarshal(part, &value) != nil { + return store.ErrInvalidInput + } + field := "text" + switch value.Type { + case "input_text": + if value.Text == nil { + return store.ErrInvalidInput + } + case "input_image": + field = "image_url" + if value.ImageURL == nil { + return store.ErrInvalidInput + } + default: + return store.ErrInvalidInput + } + if err := decodeInputObject(part, &value, "type", field); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/api/function_inputs_test.go b/services/agents-api/internal/api/function_inputs_test.go new file mode 100644 index 000000000..99042a2b1 --- /dev/null +++ b/services/agents-api/internal/api/function_inputs_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func submitResultRequest(t *testing.T, body string, failure error) (*httptest.ResponseRecorder, *inputRecorder) { + t.Helper() + recorder := &inputRecorder{err: failure} + h, _, _ := testHandler(t, WithExecution(recorder)) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/session/events", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w, recorder +} + +func TestPublicFunctionResultsPreserveOptionalValues(t *testing.T) { + for _, fields := range []string{ + `"success":false`, `"success":true,"error":null,"output":null`, + `"success":false,"error":"","output":""`, `"success":true,"output":[]`, + `"success":false,"error":"failure","output":[{"type":"input_text","text":""},{"type":"input_image","image_url":"data:image/png;base64,AA=="},{"type":"input_text","text":"last"}]`, + } { + body := `{"events":[{"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call",` + fields + `}]}` + w, recorder := submitResultRequest(t, body, nil) + if w.Code != 204 || w.Body.Len() != 0 || len(recorder.inputs) != 1 { + t.Fatal(w.Code, w.Body, recorder.inputs) + } + var input store.FunctionResultInput + if json.Unmarshal(recorder.inputs[0].Payload, &input) != nil || input.TurnID != "turn" || input.CallID != "call" { + t.Fatal(input) + } + var actual, expected map[string]any + _ = json.Unmarshal(input.Result, &actual) + _ = json.Unmarshal([]byte(`{`+fields+`}`), &expected) + a, _ := json.Marshal(actual) + b, _ := json.Marshal(expected) + if string(a) != string(b) { + t.Fatalf("got %s want %s", a, b) + } + } +} + +func TestPublicFunctionResultsRejectMalformedVariantsBeforeAdmission(t *testing.T) { + prefix := `"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call"` + for _, event := range []string{ + `{` + prefix + `}`, `{` + prefix + `,"success":null}`, `{` + prefix + `,"success":"true"}`, + `{` + prefix + `,"success":true,"input":null}`, `{` + prefix + `,"success":true,"error":{}}`, + `{` + prefix + `,"success":true,"output":{}}`, `{` + prefix + `,"success":true,"output":false}`, + `{` + prefix + `,"success":true,"output":[null]}`, `{` + prefix + `,"success":true,"output":[{"type":"input_text"}]}`, + `{` + prefix + `,"success":true,"output":[{"type":"input_text","text":null}]}`, + `{` + prefix + `,"success":true,"output":[{"type":"input_text","text":"x","image_url":null}]}`, + `{` + prefix + `,"success":true,"output":[{"type":"input_image"}]}`, + `{` + prefix + `,"success":true,"output":[{"type":"input_image","image_url":null}]}`, + `{` + prefix + `,"success":true,"output":[{"type":"input_image","image_url":"url","text":"x"}]}`, + `{"type":"agent.session.input.tool_result","turn_id":"turn","success":true}`, + `{"type":"agent.session.input.tool_result","call_id":"call","success":true}`, + `{"type":"agent.session.input.cancel","success":null}`, `{"type":"agent.session.input.cancel","input":null}`, + `{"type":"agent.session.input.message","input":[],"output":null}`, + } { + w, recorder := submitResultRequest(t, `{"events":[{"type":"agent.session.input.cancel"},`+event+`]}`, nil) + if w.Code != 400 || recorder.inputs != nil { + t.Fatal(event, w.Code, w.Body, recorder.inputs) + } + } +} + +func TestPublicFunctionTurnConflictIsNotServerFailure(t *testing.T) { + w, _ := submitResultRequest(t, `{"events":[{"type":"agent.session.input.tool_result","turn_id":"turn","call_id":"call","success":true}]}`, store.ErrTurnConflict) + if w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"turn_conflict"`) { + t.Fatal(w.Code, w.Body) + } +} diff --git a/services/agents-api/internal/api/function_state_test.go b/services/agents-api/internal/api/function_state_test.go new file mode 100644 index 000000000..90c391efa --- /dev/null +++ b/services/agents-api/internal/api/function_state_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "encoding/json" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestFunctionStateEventsUseTheirOwnSnapshot(t *testing.T) { + session := store.Session{ID: "session", CreatedAt: time.Now(), Metadata: map[string]string{}, + Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"none"}}`), + RequiredActions: []v1.FunctionCallAction{{CallID: "stale"}}, + } + for _, arguments := range []string{`{"n":9007199254740993}`, `null`, `[1,"value"]`, `"value"`, `false`} { + action := v1.FunctionCallAction{Type: "function_call", CallID: "call", Name: "lookup", TurnID: "turn", Arguments: json.RawMessage(arguments)} + change := store.SessionChange{Event: v1.SessionEvent{Type: "agent.session.requires_action", EventID: "event", SessionID: "session"}, Turn: &store.Turn{ID: "turn", Status: store.TurnWaiting}, RequiredActions: []v1.FunctionCallAction{action}} + event, err := streamResponse(session, change, "") + if err != nil || event.Session.Status != "requires_action" { + t.Fatal(event, err) + } + actual, _ := json.Marshal(event.Session.RequiredActions) + want, _ := json.Marshal(change.RequiredActions) + if string(actual) != string(want) { + t.Fatal("function action projection changed", string(actual), string(want)) + } + raw, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + if len(wire) != 3 || wire["session_id"] != nil || wire["turn_id"] != nil || wire["session"] == nil { + t.Fatal("unexpected public fields", string(raw)) + } + change.RequiredActions = nil + change.Turn.Status = store.TurnInProgress + change.Event.Type = "agent.session.in_progress" + event, err = streamResponse(session, change, "") + if err != nil || event.Session.Status != "in_progress" || event.Session.RequiredActions == nil || len(event.Session.RequiredActions) != 0 { + t.Fatal("stale actions leaked", event, err) + } + } +} diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go new file mode 100644 index 000000000..dcfd2c7c9 --- /dev/null +++ b/services/agents-api/internal/api/handler.go @@ -0,0 +1,337 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type ResourceStore interface { + AgentStore + EnvironmentTemplateStore + VaultStore + CredentialStore + GetEnvironment(context.Context, string, string) (store.Environment, error) + ListItems(context.Context, string, string, string, int, bool) (store.ItemPage, error) + GetTurn(context.Context, string, string, string) (store.Turn, error) + ListTurns(context.Context, string, string, string, int, bool) (store.TurnPage, error) + CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) + FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) + GetSession(context.Context, string, string) (store.Session, error) + DeleteSession(context.Context, string, string) error + UpdateSessionMetadata(context.Context, string, string, map[string]string) (store.Session, error) + ListSessions(context.Context, string, string, int, bool, *string) (store.SessionPage, error) +} + +type Handler struct { + policy execution.Policy + store ResourceStore + auth *Authenticator + harnesses map[string]bool + engine string + inputs InputSubmitter + executorURL string + hostedEnvironments bool + directoryReader EnvironmentDirectoryReader + fileWriter EnvironmentFileWriter + sourceFiles SourceFileStore + artifacts SessionArtifactStore +} + +func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { + if s == nil || auth == nil || !store.ValidEngine(engine) { + return nil, errors.New("resource store, authentication and a valid execution engine are required") + } + h := &Handler{store: s, auth: auth, engine: engine} + for _, option := range options { + option(h) + } + router := chi.NewRouter() + router.Use(log.HTTPMiddleware) + router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + router.Group(func(r chi.Router) { + r.Use(h.authenticateProject) + r.Post("/v1/files", h.createSourceFile) + r.Get("/v1/files", h.listSourceFiles) + r.Get("/v1/files/{file_id}", h.getSourceFile) + r.Get("/v1/files/{file_id}/content", h.sourceFileContent) + r.Delete("/v1/files/{file_id}", h.deleteSourceFile) + }) + router.Route("/v1", func(r chi.Router) { + r.Use(h.authenticate) + r.Post("/vaults", h.createVault) + r.Get("/vaults", h.listVaults) + r.Get("/vaults/{vault_id}", h.getVault) + r.Delete("/vaults/{vault_id}", h.deleteVault) + r.Post("/vaults/{vault_id}/credentials", h.createCredential) + r.Get("/vaults/{vault_id}/credentials", h.listCredentials) + r.Get("/vaults/{vault_id}/credentials/{credential_id}", h.getCredential) + r.Post("/vaults/{vault_id}/credentials/{credential_id}", h.updateCredential) + r.Delete("/vaults/{vault_id}/credentials/{credential_id}", h.deleteCredential) + r.Post("/agents", h.createAgent) + r.Get("/agents", h.listAgents) + r.Get("/agents/{agent_id}", h.getAgent) + r.Post("/agents/{agent_id}", h.updateAgent) + r.Delete("/agents/{agent_id}", h.deleteAgent) + r.Post("/agents/environments/templates", h.createEnvironmentTemplate) + r.Get("/agents/environments/templates", h.listEnvironmentTemplates) + r.Get("/agents/environments/templates/{environment_template_id}", h.getEnvironmentTemplate) + r.Post("/agents/environments/templates/{environment_template_id}", h.updateEnvironmentTemplate) + r.Delete("/agents/environments/templates/{environment_template_id}", h.deleteEnvironmentTemplate) + r.Get("/agents/environments/{environment_id}", h.getEnvironment) + r.Get("/agents/environments/{environment_id}/files", h.listEnvironmentFiles) + r.Post("/agents/environments/{environment_id}/files", h.createEnvironmentFile) + r.Post("/agents/sessions", h.createSession) + r.Get("/agents/sessions", h.listSessions) + r.Get("/agents/sessions/{session_id}", h.getSession) + r.Post("/agents/sessions/{session_id}", h.updateSession) + r.Delete("/agents/sessions/{session_id}", h.deleteSession) + r.Post("/agents/sessions/{session_id}/events", h.createEvents) + r.Get("/agents/sessions/{session_id}/events", h.streamEvents) + r.Get("/agents/sessions/{session_id}/items", h.listItems) + r.Get("/agents/sessions/{session_id}/turns", h.listTurns) + r.Get("/agents/sessions/{session_id}/turns/{turn_id}", h.getTurn) + r.Get("/agents/sessions/{session_id}/artifacts", h.listSessionArtifacts) + r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}", h.getSessionArtifact) + r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", h.sessionArtifactContent) + r.Delete("/agents/sessions/{session_id}/artifacts/{artifact_id}", h.deleteSessionArtifact) + r.NotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "unsupported_operation", "This API operation is not supported.") + }) + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusMethodNotAllowed, "unsupported_operation", "This API method is not supported.") + }) + }) + return router, nil +} + +// createSession atomically reserves or admits initial text with the Session. +// @Summary Create an execution Session +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and remaining unsupported startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. +// @Tags Sessions +// @Accept json +// @Produce json,text/event-stream +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param Idempotency-Key header string false "Creation retry key, up to 128 bytes" +// @Param body body v1.CreateSessionRequest true "Session configuration" +// @Success 200 {object} v1.Session +// @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions [post] +func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session creation does not accept query parameters.") + return + } + var request decodedSessionRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "Request exceeds 16 MiB.") + } else { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + } + return + } + if err := decoder.Decode(new(any)); err != io.EOF { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must contain exactly one JSON object.") + return + } + input, err := request.validated() + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request fields have invalid types or null values.") + return + } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } + initialInputs, err := initialSessionInputs(input.Input) + if err != nil { + writeStoreError(w, r, err) + return + } + creationRequest, err := sessionCreationRequest(input, initialInputs) + if err != nil { + writeStoreError(w, r, err) + return + } + if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + return + } + if err := h.resolveTemplateEnvironment(r.Context(), tenantID(r), &input); err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + writeStoreError(w, r, err) + } + return + } + var saved *v1.SavedAgent + if input.AgentID != nil { + resource, err := h.lookupAgent(r.Context(), tenantID(r), *input.AgentID) + if err != nil { + if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + return + } + writeStoreError(w, r, err) + return + } + saved = &v1.SavedAgent{ID: resource.ID} + if err := json.Unmarshal(resource.Configuration, &saved.SavedAgentConfiguration); err != nil { + writeStoreError(w, r, err) + return + } + } + configuration, err := resolve(input, tenantID(r), key, saved) + if err == nil { + configuration, err = h.bindSessionCredentials(r.Context(), tenantID(r), configuration) + if err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + writeStoreError(w, r, err) + } + return + } + } + selectedEngine := h.engine + if err == nil { + selectedEngine, err = h.sessionHarness(configuration) + } + if err == nil && input.XAgentsCore != nil { + err = input.XAgentsCore.ModelProvider.ValidateHarness(selectedEngine) + if err == nil && input.Environment.Type != "openai_hosted" { + err = fmt.Errorf("caller model credentials currently require a hosted environment") + } + } + if err == nil { + if invalid := h.policy.ValidateSessionConfiguration(selectedEngine, configuration); invalid != nil { + err = fmt.Errorf("Harness %s does not support the requested Agent/environment configuration: %w", selectedEngine, invalid) + } + } + if err != nil { + if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + return + } + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + return + } + if input.Environment.Type == "self_hosted" && (h.inputs == nil || h.executorURL == "") { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Self-hosted execution is not configured on this service.") + return + } + if input.Environment.Type == "openai_hosted" && (!h.hostedEnvironments || h.inputs == nil) { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Hosted execution is not configured on this service.") + return + } + var provider *v1.ModelProviderInput + if input.XAgentsCore != nil { + provider = input.XAgentsCore.ModelProvider + } + createInput := store.CreateSessionInput{ + ModelProvider: provider, + Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, + Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, + } + if input.Stream { + h.createSessionStream(w, r, createInput) + return + } + create := h.store.CreateSession + if len(initialInputs) > 0 || input.Environment.Type == "openai_hosted" { + if h.inputs == nil { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution input is not enabled on this service.") + return + } + create = h.inputs.CreateSession + } + session, err := create(r.Context(), tenantID(r), createInput) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondSession(w, r, session) +} + +// @Summary Retrieve an execution Session +// @Description Returns supported none, self_hosted and basic openai_hosted Session environments. Self-hosted pending input can require a caller connection before a Turn exists. Hosted initial provisioning remains idle until a Turn starts; connection observations are not native execution readiness. +// @Tags Sessions +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Success 200 {object} v1.Session +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id} [get] +func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session retrieval does not accept query parameters.") + return + } + session, err := h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondSession(w, r, session) +} + +func (h *Handler) respondSession(w http.ResponseWriter, r *http.Request, session store.Session) { + response, err := sessionResponse(session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +// @Summary List execution Sessions +// @Description Cursor and results are scoped to the authenticated execution tenant. Optional agent_id matches the immutable root Agent ID, including inline Agents and historical Sessions whose saved source was updated or deleted. Omission lists all Agents. Returns the same Environment and pending-input activity projection as Session retrieval, including self_hosted Sessions. +// @Tags Sessions +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id query string false "Root Agent ID whose Sessions to return" +// @Param after query string false "Last Session ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SessionList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions [get] +func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r, "agent_id") + if !ok { + return + } + var agentID *string + if values, present := r.URL.Query()["agent_id"]; present { + agentID = &values[0] + } + page, err := h.store.ListSessions(r.Context(), tenantID(r), options.after, options.limit, options.ascending, agentID) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.SessionList{Data: make([]v1.Session, 0, len(page.Sessions)), HasMore: page.NextCursor != ""} + for _, session := range page.Sessions { + item, err := sessionResponse(session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + response.Data = append(response.Data, item) + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/handler_test.go b/services/agents-api/internal/api/handler_test.go new file mode 100644 index 000000000..dbf08bcf7 --- /dev/null +++ b/services/agents-api/internal/api/handler_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type recordingStore struct { + ResourceStore + tenant string + input store.CreateSessionInput +} + +func (s *recordingStore) GetSession(ctx context.Context, tenant, id string) (store.Session, error) { + if s.ResourceStore != nil { + return s.ResourceStore.GetSession(ctx, tenant, id) + } + return store.Session{ID: id, TenantID: tenant, Configuration: json.RawMessage(`{"environment":{"type":"none"}}`)}, nil +} + +func (s *recordingStore) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { + return store.SessionCreation{}, store.ErrNotFound +} + +func (s *recordingStore) CreateSession(_ context.Context, tenant string, input store.CreateSessionInput) (store.Session, error) { + s.tenant, s.input = tenant, input + return store.Session{ID: uuid.NewString(), TenantID: tenant, Metadata: input.Metadata, Configuration: input.Configuration, CreatedAt: time.Unix(1700000000, 0)}, nil +} + +func testHandler(t *testing.T, options ...Option) (http.Handler, *recordingStore, string) { + t.Helper() + tenant := uuid.NewString() + hash := sha256.Sum256([]byte("test-api-key")) + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(hash[:]), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + s := &recordingStore{} + h, err := NewHandler(s, auth, "codex", options...) + if err != nil { + t.Fatal(err) + } + return h, s, tenant +} + +func TestHTTPConfigurationAndTenantIdentity(t *testing.T) { + h, s, tenant := testHandler(t) + body := `{"agent":{"model":"requested-model","instructions":"Keep this."},"environment":{"type":"none"},"metadata":{"tenant_id":"untrusted-tenant"}}` + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Idempotency-Key", "retry-key") + request.Header.Set("X-Tenant-ID", "untrusted-tenant") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + if w.Code != http.StatusOK || s.tenant != tenant || s.input.Engine != "codex" || s.input.IdempotencyKey != "retry-key" { + t.Fatalf("request = %d %s; tenant=%s, engine=%s", w.Code, w.Body, s.tenant, s.input.Engine) + } + var response v1.Session + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.Agent.Model != "requested-model" || response.Object != "agent.session" || response.Status != "idle" || response.CreatedAt != 1700000000 { + t.Fatalf("invalid response: %s, %v", w.Body, err) + } + if response.RequiredActions == nil || response.VaultIDs == nil || response.Agent.Tools == nil { + t.Fatal("upstream list fields must be empty arrays, not null") + } +} + +func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { + valid := `{"agent":{"model":"example"},"environment":{"type":"none"}}` + for _, test := range []struct { + name, auth, beta, path, body string + status int + }{ + {"missing auth", "", "agents=v1", "/v1/agents/sessions", valid, 401}, + {"invalid auth", "Bearer wrong", "agents=v1", "/v1/agents/sessions", valid, 401}, + {"missing beta", "Bearer test-api-key", "", "/v1/agents/sessions", valid, 400}, + {"tenant query", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions?tenant_id=other", valid, 400}, + {"tenant body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"tenant_id":"other","agent":`, 1), 400}, + {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), 503}, + {"self-hosted environment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"self_hosted"`, 1), 400}, + {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"input":"run it","agent":`, 1), 503}, + {"stream unavailable", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"stream":true,"agent":`, 1), 503}, + {"unknown saved agent", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"agent_id":"saved","agent":`, 1), 404}, + {"unknown agent option", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"model":`, `"tools":[{}],"model":`, 1), 400}, + {"multiple objects", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", valid + `{}`, 400}, + {"no object", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", `null`, 400}, + {"large body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", `{"agent":{"model":"` + strings.Repeat("x", 16*1024*1024) + `"}}`, 413}, + } { + t.Run(test.name, func(t *testing.T) { + h, s, _ := testHandler(t) + r := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.body)) + r.Header.Set("Authorization", test.auth) + r.Header.Set("OpenAI-Beta", test.beta) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + var response v1.ErrorResponse + if w.Code != test.status || json.Unmarshal(w.Body.Bytes(), &response) != nil || response.Error.Code == "" || s.tenant != "" { + t.Fatalf("response = %d %s, stored tenant = %s", w.Code, w.Body, s.tenant) + } + }) + } +} + +func TestAuthenticatorRejectsInvalidBindings(t *testing.T) { + digest := strings.Repeat("a", 64) + tenant := uuid.NewString() + for _, keys := range [][]APIKey{nil, {{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TenantID: tenant, TokenSHA256: "bad"}}, {{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TenantID: "not-a-tenant", TokenSHA256: digest}}, {{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TenantID: tenant, TokenSHA256: digest}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TenantID: uuid.NewString(), TokenSHA256: digest}}} { + if _, err := NewAuthenticator(keys); err == nil { + t.Fatal("invalid authentication bindings accepted") + } + } +} diff --git a/services/agents-api/internal/api/harness.go b/services/agents-api/internal/api/harness.go new file mode 100644 index 000000000..ade814277 --- /dev/null +++ b/services/agents-api/internal/api/harness.go @@ -0,0 +1,31 @@ +package api + +import ( + "encoding/json" + "fmt" +) + +// WithHarnesses enables explicit selections qualified by this deployment. +func WithHarnesses(kinds []string) Option { + return func(h *Handler) { + h.harnesses = make(map[string]bool, len(kinds)) + for _, kind := range kinds { + h.harnesses[kind] = true + } + } +} + +func (h *Handler) sessionHarness(raw json.RawMessage) (string, error) { + var cfg configuration + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", err + } + if cfg.Agent.XAgentsCore == nil { + return h.engine, nil + } + kind := cfg.Agent.XAgentsCore.Harness + if kind != h.engine && !h.harnesses[kind] { + return "", fmt.Errorf("Harness %s is not enabled on this Core deployment.", kind) + } + return kind, nil +} diff --git a/services/agents-api/internal/api/harness_test.go b/services/agents-api/internal/api/harness_test.go new file mode 100644 index 000000000..f561918e4 --- /dev/null +++ b/services/agents-api/internal/api/harness_test.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestSessionHarnessAdmission(t *testing.T) { + for _, tc := range []struct { + name, extension, extra, environment, engine string + enabled bool + status int + }{ + {"default", "", "", `{"type":"none"}`, "codex", false, 200}, + {"explicit default", `,"x_agents_core":{"harness":"codex"}`, "", `{"type":"none"}`, "codex", false, 200}, + {"claude", `,"x_agents_core":{"harness":"claude_sdk"}`, "", `{"type":"none"}`, "claude_sdk", true, 200}, + {"mcode", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"none"}`, "mcode", true, 200}, + {"unavailable", `,"x_agents_core":{"harness":"claude_sdk"}`, "", `{"type":"none"}`, "", false, 400}, + {"unknown", `,"x_agents_core":{"harness":"other"}`, "", `{"type":"none"}`, "", true, 400}, + {"empty", `,"x_agents_core":{}`, "", `{"type":"none"}`, "", true, 400}, + {"unknown nested", `,"x_agents_core":{"harness":"codex","model":"wrong"}`, "", `{"type":"none"}`, "", true, 400}, + {"claude verbosity", `,"x_agents_core":{"harness":"claude_sdk"}`, `,"text":{"verbosity":"high"}`, `{"type":"none"}`, "", true, 400}, + {"mcode remote", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"}`, "", true, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + var options []Option + if tc.enabled { + options = append(options, WithHarnesses([]string{"claude_sdk", "mcode"})) + } + h, s, _ := testHandler(t, options...) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"fixture"`+tc.extension+tc.extra+`},"environment":`+tc.environment+`}`)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.status || s.input.Engine != tc.engine { + t.Fatalf("status=%d body=%s engine=%s", w.Code, w.Body, s.input.Engine) + } + }) + } +} + +func TestSavedHarnessReplacementAndEffectiveRead(t *testing.T) { + model := "fixture" + resolved, err := resolveSavedAgent(v1.CreateAgentRequest{Model: &model, XAgentsCore: &v1.AgentsCore{Harness: "claude_sdk"}}) + if err != nil { + t.Fatal(err) + } + var cfg v1.SavedAgentConfiguration + if err := json.Unmarshal(resolved.Configuration, &cfg); err != nil { + t.Fatal(err) + } + saved := &v1.SavedAgent{SavedAgentConfiguration: cfg, ID: "agent-saved"} + for _, tc := range []struct{ raw, want string }{ + {`{}`, "claude_sdk"}, {`{"x_agents_core":{"harness":"mcode"}}`, "mcode"}, {`{"x_agents_core":null}`, ""}, + } { + var request decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent_id":"agent-saved","agent":`+tc.raw+`,"environment":{"type":"none"}}`), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + agent, err := resolveSessionAgent(input, saved) + if err != nil { + t.Fatal(err) + } + got := "" + if agent.XAgentsCore != nil { + got = agent.XAgentsCore.Harness + } + if got != tc.want { + t.Fatalf("%s: %q", tc.raw, got) + } + } + update, err := resolveAgentUpdate([]byte(`{"x_agents_core":null}`)) + if err != nil || string(update.Configuration) != `{"x_agents_core":null}` { + t.Fatalf("null clear=%s %v", update.Configuration, err) + } + if saved.XAgentsCore.Harness != "claude_sdk" { + t.Fatal("mutated saved Agent") + } + raw, _ := json.Marshal(configuration{Agent: v1.Agent{ID: "agent", Model: "fixture", XAgentsCore: &v1.AgentsCore{Harness: "claude_sdk"}}, Environment: v1.Environment{Type: "none"}}) + response, err := sessionResponse(store.Session{Engine: "mcode", Configuration: raw}, "") + if err != nil || response.Agent.XAgentsCore.Harness != "mcode" { + t.Fatalf("effective read=%+v %v", response, err) + } +} + +func TestDefaultHarnessPreservesSessionAgentResponse(t *testing.T) { + raw, _ := json.Marshal(configuration{Agent: v1.Agent{ID: "agent", Model: "fixture"}, Environment: v1.Environment{Type: "none"}}) + response, err := sessionResponse(store.Session{Engine: "codex", Configuration: raw}, "") + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(response.Agent) + if err != nil || response.Agent.XAgentsCore != nil || strings.Contains(string(encoded), "x_agents_core") { + t.Fatalf("default selection changed the protocol Agent: %s %v", encoded, err) + } +} diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go new file mode 100644 index 000000000..960d5d36a --- /dev/null +++ b/services/agents-api/internal/api/hosted_environment.go @@ -0,0 +1,139 @@ +package api + +import ( + "bytes" + "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// decodeHostedEnvironment keeps unsupported installations explicit, while +// accepting the protocol's omitted/null/empty defaults for the basic profile. +func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil, store.ErrInvalidInput + } + setup, err := decodeEnvironmentSetup(fields) + if err != nil { + return nil, err + } + env := &v1.Environment{Type: "openai_hosted", Network: &v1.EnvironmentNetworkInput{Access: "enabled"}} + for name, value := range fields { + switch name { + case "type": + case "network": + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + continue + } + var network v1.EnvironmentNetworkInput + if decodeInputObject(value, &network, "access", "allowed_domains") != nil || (network.Access != "enabled" && network.Access != "disabled") || len(network.AllowedDomains) != 0 { + return nil, store.ErrInvalidInput + } + env.Network = &network + case "files": + files, err := decodeInitialFiles(value) + if err != nil { + return nil, err + } + env.Files = initialFileResponse(files) + case "skills": + env.Skills = skillResponse(setup.SkillMetadata()) + case "capability_directories", "plugins": + var list []json.RawMessage + if json.Unmarshal(value, &list) != nil || len(list) != 0 { + return nil, store.ErrInvalidInput + } + case "env", "setup_commands": + // Confidential values remain in the separate initialization snapshot. + case "packages": + packages := setup.PackageMetadata() + env.Packages = &packages + default: + return nil, store.ErrInvalidInput + } + } + return env, nil +} + +// Hosted metadata describes API-managed initial installations, not workspace inventory. +func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironment, error) { + cfg, err := storedEnvironment(environment.Configuration) + if err != nil || cfg.Type != "openai_hosted" { + return v1.SessionEnvironment{}, store.ErrInvalidInput + } + empty := []json.RawMessage{} + files := cfg.Files + if files == nil { + files = []json.RawMessage{} + } + directories := []string{} + return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, + Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: []string{}}, + Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &cfg.Skills}, nil +} + +// WithHostedEnvironments enables admission only for an operator-composed, +// qualified managed Runtime deployment. Native capability flags cannot enable it. +func WithHostedEnvironments() Option { + return func(h *Handler) { h.hostedEnvironments = true } +} + +func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil, store.ErrInvalidInput + } + var kind string + if json.Unmarshal(fields["type"], &kind) != nil { + return nil, store.ErrInvalidInput + } + if kind != "openai_hosted" { + return decodeSessionEnvironment(raw) + } + // Confidential fields must never appear in the persisted public snapshot. + for _, field := range []string{"env", "setup_commands"} { + if _, exists := fields[field]; exists { + return nil, store.ErrInvalidInput + } + } + var files []json.RawMessage + if value, exists := fields["files"]; exists { + if json.Unmarshal(value, &files) != nil || len(files) > 50 { + return nil, store.ErrInvalidInput + } + for _, entry := range files { + var metadata store.InitialFileMetadata + if decodeInputObject(entry, &metadata, "id", "type", "path", "file_id", "size_bytes") != nil || metadata.ID == "" || metadata.SizeBytes == nil || *metadata.SizeBytes < 0 || *metadata.SizeBytes > store.MaxInitialFileBytes { + return nil, store.ErrInvalidInput + } + if metadata.Type != "inline" && metadata.Type != "file_id" { + return nil, store.ErrInvalidInput + } + } + } + if value, ok := fields["initialization"]; ok { + var initialized bool + if json.Unmarshal(value, &initialized) != nil || !initialized { + return nil, store.ErrInvalidInput + } + delete(fields, "initialization") + } + skills, err := storedSkills(fields["skills"]) + if err != nil { + return nil, err + } + delete(fields, "skills") + delete(fields, "files") + base, err := json.Marshal(fields) + if err != nil { + return nil, err + } + cfg, err := decodeHostedEnvironment(base) + if err != nil { + return nil, err + } + cfg.Files = files + cfg.Skills = skills + return cfg, nil +} diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go new file mode 100644 index 000000000..a48e2e34e --- /dev/null +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { + for _, raw := range []string{ + `{"type":"openai_hosted"}`, + `{"type":"openai_hosted","packages":{"system":["jq","libpq-dev"]}}`, + `{"type":"openai_hosted","network":null,"env":null,"files":null,"packages":null,"plugins":null,"skills":null,"setup_commands":null,"capability_directories":null}`, + `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":null},"env":{},"files":[],"packages":{"npm":[],"python":null,"system":[]},"plugins":[],"skills":[],"setup_commands":[],"capability_directories":[]}`, + } { + got, err := decodeSessionEnvironment(json.RawMessage(raw)) + if err != nil || got.Network == nil || got.Network.Access != "enabled" { + t.Fatal("hosted defaults changed", got, err) + } + } + got, err := decodeSessionEnvironment(json.RawMessage(`{"type":"openai_hosted","network":{"access":"disabled"}}`)) + if err != nil || got.Network.Access != "disabled" { + t.Fatal("explicit policy changed", got, err) + } + for _, field := range []string{ + `"network":{}`, `"network":{"access":null}`, `"network":{"access":"restricted"}`, + `"network":{"access":"disabled","allowed_domains":["example.com"]}`, `"network":{"access":"enabled","unknown":true}`, + `"env":{"SECRET":null}`, `"files":[{}]`, `"packages":{"system":[null]}`, + `"packages":{"unknown":[]}`, `"plugins":[{}]`, `"skills":[{}]`, `"setup_commands":["echo test"]`, + `"capability_directories":["/workspace"]`, `"template_id":"template"`, `"workspace_directory":"/workspace"`, + `"files":{}`, `"env":[]`, `"packages":[]`, `"network":[]`, `"unknown":null`, + } { + if _, err := decodeSessionEnvironment(json.RawMessage(`{"type":"openai_hosted",` + field + `}`)); err == nil { + t.Fatal("unsupported installation or invalid input accepted", field) + } + } +} + +func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing.T) { + s := environmentSession() + s.Configuration = json.RawMessage(`{"agent":{"id":"agent","model":"model"},"environment":{"type":"openai_hosted"}}`) + s.Environment.Configuration = json.RawMessage(`{"type":"openai_hosted"}`) + s.EnvironmentInputActivity = nil + result, err := sessionResponse(s, "") + if err != nil { + t.Fatal(err) + } + if result.Status != "idle" || len(result.RequiredActions) != 0 { + t.Fatal("hosted preparation requires a caller connection", result) + } + raw, err := json.Marshal(result.Environment) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if json.Unmarshal(raw, &got) != nil { + t.Fatal("invalid environment JSON") + } + expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}, "system": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} + if !reflect.DeepEqual(got, expected) { + t.Fatal("hosted response shape changed", string(raw)) + } + for _, mutate := range []func(*store.Environment){ + func(e *store.Environment) { e.TenantID = "foreign" }, + func(e *store.Environment) { e.SessionID = "other" }, + func(e *store.Environment) { + e.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`) + }, + } { + invalid := s + environment := *s.Environment + invalid.Environment = &environment + mutate(invalid.Environment) + if _, err := sessionResponse(invalid, ""); err == nil { + t.Fatal("hosted association mismatch accepted") + } + } +} + +// The execution owner must see idle creation as well as initial-input creation. +// Resource persistence alone cannot validate the configured managed deployment. +func TestHostedCreationUsesExecutionAdmissionWithoutInitialInput(t *testing.T) { + for _, stream := range []bool{false, true} { + recorder := &hostedCreationRecorder{} + handler, fixture := environmentCreationHandler(t, "codex", WithHostedEnvironments(), WithExecution(recorder)) + body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t}`, stream) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + // The recorder deliberately rejects both creation methods. Its rejection + // proves admission was used; the resource fixture must remain untouched. + if recorder.calls != 1 || fixture.input.Engine != "" || fixture.session.ID != "" || response.Code != http.StatusBadRequest { + t.Fatal("idle hosted creation bypassed execution admission", stream, response.Code, response.Body.String()) + } + } +} + +type hostedCreationRecorder struct { + inputRecorder + calls int +} + +func (r *hostedCreationRecorder) CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) { + r.calls++ + return store.Session{}, store.ErrInvalidInput +} +func (r *hostedCreationRecorder) CreateSessionStream(context.Context, string, store.CreateSessionInput) (store.SessionCreation, error) { + r.calls++ + return store.SessionCreation{}, store.ErrInvalidInput +} diff --git a/services/agents-api/internal/api/initial_files.go b/services/agents-api/internal/api/initial_files.go new file mode 100644 index 000000000..852ee018a --- /dev/null +++ b/services/agents-api/internal/api/initial_files.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + files := make([]store.InitialFile, 0, len(entries)) + for _, entry := range entries { + var in struct { + Type string `json:"type"` + Path string `json:"path"` + Data *string `json:"data"` + FileID *string `json:"file_id"` + } + if decodeInputObject(entry, &in, "type", "path", "data", "file_id") != nil { + return nil, store.ErrInvalidInput + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(entry, &fields) + f := store.InitialFile{Type: in.Type, Path: in.Path} + switch in.Type { + case "inline": + if _, exists := fields["file_id"]; exists || in.Data == nil || len(*in.Data) > base64.StdEncoding.EncodedLen(5<<20) { + return nil, store.ErrInvalidInput + } + var err error + f.Data, err = base64.StdEncoding.Strict().DecodeString(*in.Data) + if err != nil { + return nil, store.ErrInvalidInput + } + case "file_id": + if _, exists := fields["data"]; exists || in.FileID == nil { + return nil, store.ErrInvalidInput + } + f.FileID = *in.FileID + default: + return nil, store.ErrInvalidInput + } + files = append(files, f) + } + return files, store.ValidateInitialFiles(files) +} + +func initialFileResponse(files []store.InitialFile) []json.RawMessage { + metadata := make([]store.InitialFileMetadata, 0, len(files)) + for _, file := range files { + m := store.InitialFileMetadata{Type: file.Type, Path: file.Path, FileID: file.FileID} + if file.Type == "inline" { + size := int64(len(file.Data)) + m.SizeBytes = &size + } + metadata = append(metadata, m) + } + return templateFileResponse(metadata) +} + +func templateFileResponse(files []store.InitialFileMetadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(files)) + for _, file := range files { + body, _ := json.Marshal(file) + result = append(result, body) + } + return result +} diff --git a/services/agents-api/internal/api/initial_files_test.go b/services/agents-api/internal/api/initial_files_test.go new file mode 100644 index 000000000..88c353dd4 --- /dev/null +++ b/services/agents-api/internal/api/initial_files_test.go @@ -0,0 +1,43 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func TestInitialFilesDecodeAndConfidentialMetadata(t *testing.T) { + canary := "initial-private-canary" + raw := `{"type":"openai_hosted","files":[{"type":"inline","path":"/workspace/input/data.txt","data":"` + base64.StdEncoding.EncodeToString([]byte(canary)) + `"},{"type":"file_id","path":"/workspace/source","file_id":"file-source"}]}` + input, err := (decodedSessionRequest{Environment: json.RawMessage(raw)}).validated() + if err != nil || len(input.initialFiles) != 2 || string(input.initialFiles[0].Data) != canary { + t.Fatal("initial files decode", err) + } + safe, _ := json.Marshal(input.Environment) + if strings.Contains(string(safe), canary) || strings.Contains(string(safe), base64.StdEncoding.EncodeToString([]byte(canary))) || strings.Contains(string(safe), `"data"`) { + t.Fatal("confidential data entered ordinary configuration") + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || !strings.Contains(string(intent), `"data"`) { + t.Fatal("creation intent lost confidential input identity") + } + for _, files := range []string{ + `[{"type":"inline","path":"/workspace/a","data":null}]`, + `[{"type":"inline","path":"/workspace/a","data":"notbase64"}]`, + `[{"type":"inline","path":"/workspace/../secret","data":""}]`, + `[{"type":"inline","path":"/tmp/secret","data":""}]`, + `[{"type":"inline","path":"/workspace/a","data":"","file_id":null}]`, + `[{"type":"file_id","path":"/workspace/a","file_id":"x","data":null}]`, + `[{"type":"inline","path":"/workspace/a","data":""},{"type":"inline","path":"/workspace/a","data":""}]`, + } { + if _, err := decodeInitialFiles(json.RawMessage(files)); err == nil { + t.Fatal("invalid initial files accepted", files) + } + } + for _, value := range []string{"null", "[]", `[{"type":"inline","path":"/workspace/a","data":""}]`} { + if _, _, _, err := decodeTemplateEnvironment(json.RawMessage(`{"type":"openai_hosted","environment_template_id":"template","files":` + value + `}`)); err == nil { + t.Fatal("unconfirmed template file override accepted") + } + } +} diff --git a/services/agents-api/internal/api/inputs.go b/services/agents-api/internal/api/inputs.go new file mode 100644 index 000000000..6685f232c --- /dev/null +++ b/services/agents-api/internal/api/inputs.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type InputSubmitter interface { + CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) + SubmitInputs(context.Context, string, string, string, []store.Input) ([]store.InputReceipt, error) +} + +type Option func(*Handler) + +// WithExecution enables durable admission when the service owns an execution worker. +func WithExecution(s InputSubmitter) Option { return func(h *Handler) { h.inputs = s } } + +// @Summary Submit Session input events +// @Description For environment none, atomically accepts text messages, cancellation and function results. Messages steer active work or start a queued Turn. The supported self_hosted and openai_hosted profiles accept text-only batches. Under the Session lock, matching retries retain their original target; new active messages append to the current Turn, while idle messages reserve work and wait up to the original five-minute connection/admission deadline. Return 204 only after durable admission, without claiming native application; active messages create no Turn or reservation. Cancellation-only prepared-environment batches use existing durable cancellation admission and return 204 without waiting for native exit; a new cancellation conflicts while a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment batches reuse existing scoped result admission and application receipts without creating a Turn or bypassing a pending reservation. Mixed prepared-environment batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors; exact hosted failure mapping is unverified. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and executor URL settings. Disconnecting the waiting HTTP request does not cancel retained work or restart its deadline. Retry keys identify the whole ordered batch. Function output accepts text or ordered text/image parts subject to engine support; Claude SDK currently accepts text results only. Message images are not supported yet. +// @Tags Sessions +// @Accept json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param Idempotency-Key header string false "Retry key, up to 128 bytes" +// @Param session_id path string true "Session ID" +// @Param body body v1.CreateEventsRequest true "Ordered input events" +// @Success 204 +// @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/events [post] +func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { + if h.inputs == nil { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") + return + } + if len(r.URL.Query()) != 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Event submission does not accept query parameters.") + return + } + var request struct { + Events []json.RawMessage `json:"events"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + var large *http.MaxBytesError + if errors.As(err, &large) { + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "Request exceeds 1 MiB.") + } else { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid Session input event request.") + } + return + } + if decoder.Decode(new(any)) != io.EOF { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must contain exactly one JSON object.") + return + } + inputs, err := executionInputs(request.Events) + if err != nil { + writeStoreError(w, r, err) + return + } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } + sessionID := chi.URLParam(r, "session_id") + if err := h.setEnvironmentInputWriteDeadline(w, r, sessionID); err != nil { + writeStoreError(w, r, err) + return + } + if _, err := h.inputs.SubmitInputs(r.Context(), tenantID(r), sessionID, key, inputs); err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func executionInputs(events []json.RawMessage) ([]store.Input, error) { + if len(events) == 0 || len(events) > 64 { + return nil, store.ErrInvalidInput + } + inputs := make([]store.Input, 0, len(events)) + for _, raw := range events { + event, err := decodeInputEvent(raw) + if err != nil { + return nil, err + } + switch event.Type { + case "agent.session.input.cancel": + if event.Input != nil { + return nil, store.ErrInvalidInput + } + inputs = append(inputs, store.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)}) + case "agent.session.input.tool_result": + input, err := functionResultInput(event) + if err != nil { + return nil, err + } + inputs = append(inputs, input) + case "agent.session.input.message": + if len(event.Input) == 0 { + return nil, store.ErrInvalidInput + } + for _, message := range event.Input { + if message.Role != "user" || (message.Type != "" && message.Type != "message") || len(message.Content) == 0 { + return nil, store.ErrInvalidInput + } + var text strings.Builder + for _, content := range message.Content { + if content.Type != "input_text" { + return nil, store.ErrInvalidInput + } + text.WriteString(content.Text) + } + if strings.TrimSpace(text.String()) == "" { + return nil, store.ErrInvalidInput + } + } + payload, err := json.Marshal(event) + if err != nil { + return nil, err + } + inputs = append(inputs, store.Input{Kind: "message", Payload: payload}) + default: + return nil, store.ErrInvalidInput + } + } + return inputs, nil +} diff --git a/services/agents-api/internal/api/inputs_test.go b/services/agents-api/internal/api/inputs_test.go new file mode 100644 index 000000000..d860cacb5 --- /dev/null +++ b/services/agents-api/internal/api/inputs_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type inputRecorder struct { + ResourceStore + tenant, session, key string + inputs []store.Input + err error +} + +func (s *inputRecorder) SubmitInputs(_ context.Context, tenant, session, key string, inputs []store.Input) ([]store.InputReceipt, error) { + s.tenant, s.session, s.key, s.inputs = tenant, session, key, inputs + return nil, s.err +} + +func TestPublicInputAdmission(t *testing.T) { + recorder := &inputRecorder{} + h, _, tenant := testHandler(t, WithExecution(recorder)) + body := `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"First"}]},{"role":"user","content":[{"type":"input_text","text":"Second"}]}]},{"type":"agent.session.input.cancel"}]}` + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/session-id/events", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", "batch-key") + r.Header.Set("X-Tenant-ID", "forged") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 204 || w.Body.Len() != 0 || recorder.tenant != tenant || recorder.session != "session-id" || recorder.key != "batch-key" { + t.Fatalf("response=%d %s recorder=%+v", w.Code, w.Body, recorder) + } + if len(recorder.inputs) != 2 || recorder.inputs[0].Kind != "message" || recorder.inputs[1].Kind != "cancel" { + t.Fatal(recorder.inputs) + } + var stored map[string]json.RawMessage + if err := json.Unmarshal(recorder.inputs[0].Payload, &stored); err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stored["input"]), "Second") { + t.Fatal("individual user messages lost") + } +} + +func TestPublicInputRejectsUnsupportedOrMalformedBatch(t *testing.T) { + for _, body := range []string{ + `null`, `{}`, `{"events":[]}`, `{"events":[{"type":"agent.session.input.tool_result","call_id":"x"}]}`, + `{"events":[{"type":"agent.session.input.message","input":[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]}]}`, + `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_image","image_url":"https://example.com/a.png"}]}]}]}`, + `{"events":[{"type":"agent.session.input.cancel","input":[]}]}`, + `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":" "}]}]}]}`, + `{"events":[{"type":"agent.session.input.cancel"}]} {}`, + } { + recorder := &inputRecorder{} + h, _, _ := testHandler(t, WithExecution(recorder)) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/id/events", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 400 || recorder.inputs != nil { + t.Fatalf("accepted %s: %d %s", body, w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go new file mode 100644 index 000000000..8bd6c23e7 --- /dev/null +++ b/services/agents-api/internal/api/items.go @@ -0,0 +1,33 @@ +package api + +import ( + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" + "net/http" +) + +// @Summary List persisted execution Items +// @Description Returns supported message and tool Items in first-observation order. Native engine fields are projected explicitly; unfinished Items on terminal Turns are incomplete. Cursors belong to the same tenant and Session. +// @Tags Items +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param after query string false "Last Item ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.ItemList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/items [get] +func (h *Handler) listItems(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.store.ListItems(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.ItemList{Data: page.Items, HasMore: page.HasMore}) +} diff --git a/services/agents-api/internal/api/items_test.go b/services/agents-api/internal/api/items_test.go new file mode 100644 index 000000000..0c95e6e24 --- /dev/null +++ b/services/agents-api/internal/api/items_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type itemReadStore struct { + ResourceStore + tenant, session, cursor string + limit int + ascending bool +} + +func (s *itemReadStore) ListItems(_ context.Context, tenant, session, cursor string, limit int, asc bool) (store.ItemPage, error) { + s.tenant, s.session, s.cursor, s.limit, s.ascending = tenant, session, cursor, limit, asc + return store.ItemPage{Items: []v1.Item{}, HasMore: false}, nil +} +func TestItemRouteUsesAuthenticationAndSharedPagination(t *testing.T) { + h, record, tenant := testHandler(t) + s := &itemReadStore{} + record.ResourceStore = s + request := func(query, token string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/session/items"+query, nil) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + if w := request("?after=last&limit=2&order=asc", "test-api-key"); w.Code != 200 || s.tenant != tenant || s.session != "session" || s.cursor != "last" || s.limit != 2 || !s.ascending { + t.Fatal(w.Code, w.Body, s) + } + if w := request("", "test-api-key"); w.Code != 200 || s.limit != 20 || s.ascending || w.Body.String() != "{\"data\":[],\"has_more\":false}\n" { + t.Fatal(w.Code, w.Body, s) + } + for _, q := range []string{"?limit=0", "?limit=101", "?order=bad", "?limit=2&limit=3", "?tenant_id=other"} { + if w := request(q, "test-api-key"); w.Code != 400 { + t.Fatal(q, w.Code) + } + } + if w := request("", "invalid"); w.Code != 401 { + t.Fatal(w.Code) + } +} diff --git a/services/agents-api/internal/api/json_request.go b/services/agents-api/internal/api/json_request.go new file mode 100644 index 000000000..636017861 --- /dev/null +++ b/services/agents-api/internal/api/json_request.go @@ -0,0 +1,25 @@ +package api + +import ( + "errors" + "io" + "net/http" +) + +func readJSONBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { + return readJSONBodyLimit(w, r, 1024*1024, "Request exceeds 1 MiB.") +} + +func readJSONBodyLimit(w http.ResponseWriter, r *http.Request, limit int64, message string) ([]byte, bool) { + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) + if err == nil { + return raw, true + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", message) + } else { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must contain one JSON object.") + } + return nil, false +} diff --git a/services/agents-api/internal/api/mcp_configuration.go b/services/agents-api/internal/api/mcp_configuration.go new file mode 100644 index 000000000..490571cfb --- /dev/null +++ b/services/agents-api/internal/api/mcp_configuration.go @@ -0,0 +1,81 @@ +package api + +import ( + "encoding/json" + "errors" + "net/url" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { + var input v1.MCPToolInput + if decodeInputObject(raw, &input, "type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required") != nil { + return nil, errors.New("Invalid MCP tool fields.") + } + if input.Type != "mcp" || input.ServerLabel == nil || strings.TrimSpace(*input.ServerLabel) == "" { + return nil, errors.New("MCP tools require type=mcp and a nonempty server_label.") + } + if input.ConnectionOrigin == nil || *input.ConnectionOrigin != "service" { + return nil, errors.New("MCP currently requires explicit connection_origin=service.") + } + if input.CredentialID != nil && *input.CredentialID == "" { + return nil, errors.New("MCP credential_id must be null or a nonempty string.") + } + required, err := optionalBoolean(input.Required, false) + if err != nil { + return nil, errors.New("MCP required must be a boolean.") + } + if !emptyMCPObject(input.RequestMetadata) { + return nil, errors.New("Nonempty MCP request_metadata is not supported yet.") + } + var transport struct { + Type string `json:"type"` + ServerURL *string `json:"server_url"` + Headers json.RawMessage `json:"headers"` + } + if decodeInputObject(input.Transport, &transport, "type", "server_url", "headers") != nil || transport.Type != "http" || transport.ServerURL == nil { + return nil, errors.New("MCP currently supports HTTP transport only.") + } + u, err := url.Parse(*transport.ServerURL) + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { + return nil, errors.New("MCP server_url must be an absolute HTTP(S) URL without credentials, query or fragment.") + } + if !emptyMCPObject(transport.Headers) { + return nil, errors.New("Nonempty MCP headers are not supported yet.") + } + var allowed *[]string + if len(input.AllowedTools) != 0 { + var values *[]*string + if json.Unmarshal(input.AllowedTools, &values) != nil { + return nil, errors.New("MCP allowed_tools must be null or an array of tool names.") + } + if values != nil { + names := make([]string, 0, len(*values)) + for _, name := range *values { + if name == nil || *name == "" { + return nil, errors.New("MCP allowed_tools requires nonempty string names.") + } + names = append(names, *name) + } + allowed = &names + } + } + tool := v1.MCPTool{Type: "mcp", ServerLabel: *input.ServerLabel, + Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: *transport.ServerURL}, + AllowedTools: allowed, Required: required, ConnectionOrigin: "service", CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} + if saved { + headers := map[string]string{} + tool.Transport.Headers = &headers + } + return json.Marshal(tool) +} + +func emptyMCPObject(raw json.RawMessage) bool { + if len(raw) == 0 { + return true + } + var value map[string]json.RawMessage + return json.Unmarshal(raw, &value) == nil && len(value) == 0 +} diff --git a/services/agents-api/internal/api/mcp_configuration_test.go b/services/agents-api/internal/api/mcp_configuration_test.go new file mode 100644 index 000000000..c9a17c640 --- /dev/null +++ b/services/agents-api/internal/api/mcp_configuration_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +const publicMCP = `{"type":"mcp","server_label":"records","connection_origin":"service","transport":{"type":"http","server_url":"https://mcp.example.test/tools"}}` + +func TestMCPRequiredSavedAndEffectiveConfiguration(t *testing.T) { + for _, value := range []string{"true", "false"} { + input := strings.TrimSuffix(publicMCP, "}") + `,"required":` + value + `}` + saved, err := resolveMCPTool(json.RawMessage(input), true) + if err != nil { + t.Fatal(err) + } + effective, err := resolveSessionTools([]json.RawMessage{saved}) + if err != nil || len(effective) != 1 { + t.Fatal("effective declaration failed", err) + } + for _, raw := range []json.RawMessage{saved, effective[0]} { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || string(fields["required"]) != value { + t.Fatal("required initialization changed", string(raw)) + } + } + } +} + +func TestMCPResourceTransportProjections(t *testing.T) { + for _, saved := range []bool{false, true} { + raw, err := resolveMCPTool(json.RawMessage(publicMCP), saved) + if err != nil { + t.Fatal(err) + } + var actual map[string]any + if err := json.Unmarshal(raw, &actual); err != nil { + t.Fatal(err) + } + transport := map[string]any{"type": "http", "server_url": "https://mcp.example.test/tools"} + if saved { + transport["headers"] = map[string]any{} + } + expected := map[string]any{"type": "mcp", "server_label": "records", "transport": transport, + "connection_origin": "service", "required": false, "allowed_tools": nil, + "credential_id": nil, "request_metadata": map[string]any{}} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("saved=%v: unexpected pinned MCP resource: %s", saved, raw) + } + } +} + +func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { + for _, allowed := range []string{"null", "[]", `["lookup","fail"]`} { + var input map[string]json.RawMessage + if err := json.Unmarshal([]byte(publicMCP), &input); err != nil { + t.Fatal(err) + } + input["allowed_tools"] = json.RawMessage(allowed) + input["credential_id"], input["request_metadata"], input["required"] = json.RawMessage("null"), json.RawMessage("null"), json.RawMessage("false") + encoded, _ := json.Marshal(input) + resolved, err := resolveMCPTool(encoded, false) + if err != nil { + t.Fatal(err) + } + var output map[string]json.RawMessage + if err := json.Unmarshal(resolved, &output); err != nil || string(output["allowed_tools"]) != allowed { + t.Fatalf("allow-list presence changed: %s, %v", resolved, err) + } + } +} + +func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { + for name, replacement := range map[string]map[string]json.RawMessage{ + "origin missing": {"connection_origin": nil}, + "origin null": {"connection_origin": json.RawMessage("null")}, + "environment origin": {"connection_origin": json.RawMessage(`"environment"`)}, + "required type": {"required": json.RawMessage(`"true"`)}, + "required null": {"required": json.RawMessage("null")}, + "empty credential": {"credential_id": json.RawMessage(`""`)}, + "credential type": {"credential_id": json.RawMessage(`3`)}, + "metadata": {"request_metadata": json.RawMessage(`{"private-marker":"value"}`)}, + "null tool name": {"allowed_tools": json.RawMessage(`[null]`)}, + "wrong allow-list": {"allowed_tools": json.RawMessage(`"lookup"`)}, + "inline authorization": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","authorization":"private-marker"}`)}, + "headers": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","headers":{"Authorization":"private-marker"}}`)}, + "URL credentials": {"transport": json.RawMessage(`{"type":"http","server_url":"https://private-marker@mcp.example.test"}`)}, + "URL query": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test/?token=private-marker"}`)}, + "stdio": {"transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, + } { + t.Run(name, func(t *testing.T) { + var input map[string]json.RawMessage + if err := json.Unmarshal([]byte(publicMCP), &input); err != nil { + t.Fatal(err) + } + for key, value := range replacement { + if value == nil { + delete(input, key) + } else { + input[key] = value + } + } + encoded, _ := json.Marshal(input) + if _, err := resolveMCPTool(encoded, false); err == nil || strings.Contains(err.Error(), "private-marker") { + t.Fatalf("unsupported MCP input was accepted or disclosed: %v", err) + } + }) + } +} + +func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) { + saved, err := resolveMCPTool(json.RawMessage(publicMCP), true) + if err != nil { + t.Fatal(err) + } + function := json.RawMessage(`{"type":"function","name":"application","description":"An application callback","parameters":{"type":"object"},"defer_loading":false}`) + tools, err := resolveSessionTools([]json.RawMessage{saved, function}) + if err != nil || len(tools) != 2 { + t.Fatalf("mixed declaration: %v", err) + } + if strings.Contains(string(tools[0]), "headers") || string(tools[1]) != string(function) { + t.Fatalf("Session transport or declared order changed: %s", tools) + } + if _, err := resolveSessionTools([]json.RawMessage{saved, function, saved}); err == nil { + t.Fatal("duplicate MCP server labels were admitted") + } +} diff --git a/services/agents-api/internal/api/pagination.go b/services/agents-api/internal/api/pagination.go new file mode 100644 index 000000000..b3385e0fe --- /dev/null +++ b/services/agents-api/internal/api/pagination.go @@ -0,0 +1,58 @@ +package api + +import ( + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "strings" +) + +type pageOptions struct { + after string + limit int + ascending bool +} + +func readPage(w http.ResponseWriter, r *http.Request, extraKeys ...string) (pageOptions, bool) { + return readPageSize(w, r, true, extraKeys...) +} + +func readPageSize(w http.ResponseWriter, r *http.Request, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { + return readPageQuery(w, r.URL.Query(), rejectLarger, extraKeys...) +} + +func readPageQuery(w http.ResponseWriter, q url.Values, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { + return readPageQueryLimits(w, q, 20, 100, rejectLarger, extraKeys...) +} + +func readPageQueryLimits(w http.ResponseWriter, q url.Values, defaultLimit, maxLimit int, rejectLarger bool, extraKeys ...string) (pageOptions, bool) { + keys := append([]string{"after", "limit", "order"}, extraKeys...) + for key, values := range q { + if !slices.Contains(keys, key) || len(values) != 1 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Supported list parameters are "+strings.Join(keys[:len(keys)-1], ", ")+" and "+keys[len(keys)-1]+", each supplied once.") + return pageOptions{}, false + } + } + limit, order := defaultLimit, q.Get("order") + if raw, ok := q["limit"]; ok { + var err error + var requested int64 + requested, err = strconv.ParseInt(raw[0], 10, 64) + if err != nil || requested < 1 || (rejectLarger && requested > int64(maxLimit)) { + message := "limit must be a positive 64-bit integer." + if rejectLarger { + message = fmt.Sprintf("limit must be between 1 and %d.", maxLimit) + } + writeError(w, http.StatusBadRequest, "invalid_request", message) + return pageOptions{}, false + } + limit = int(min(requested, int64(maxLimit))) + } + if order != "" && order != "asc" && order != "desc" { + writeError(w, http.StatusBadRequest, "invalid_request", "order must be asc or desc.") + return pageOptions{}, false + } + return pageOptions{after: strings.TrimSpace(q.Get("after")), limit: limit, ascending: order == "asc"}, true +} diff --git a/services/agents-api/internal/api/pagination_test.go b/services/agents-api/internal/api/pagination_test.go new file mode 100644 index 000000000..f90e9392a --- /dev/null +++ b/services/agents-api/internal/api/pagination_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http/httptest" + "testing" +) + +func TestPageSizePolicy(t *testing.T) { + for _, test := range []struct { + query string + cappedOK, strictOK bool + limit int + }{ + {"", true, true, 20}, {"limit=1", true, true, 1}, {"limit=100", true, true, 100}, + {"limit=101", true, false, 100}, {"limit=9223372036854775807", true, false, 100}, + {"limit=9223372036854775808", false, false, 0}, {"limit=0", false, false, 0}, + {"limit=-1", false, false, 0}, {"limit=1.5", false, false, 0}, {"limit=", false, false, 0}, + {"limit=null", false, false, 0}, {"limit=2&limit=3", false, false, 0}, {"order=invalid", false, false, 0}, + {"tenant_id=other", false, false, 0}, + } { + t.Run(test.query, func(t *testing.T) { + for _, strict := range []bool{true, false} { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/agents?"+test.query, nil) + page, ok := readPageSize(w, r, strict) + want := test.cappedOK + if strict { + want = test.strictOK + } + if ok != want || (ok && page.limit != test.limit) || (!ok && w.Code != 400) { + t.Fatalf("strict=%t page=%+v ok=%t status=%d", strict, page, ok, w.Code) + } + } + }) + } +} diff --git a/services/agents-api/internal/api/saved_configuration.go b/services/agents-api/internal/api/saved_configuration.go new file mode 100644 index 000000000..2b5368305 --- /dev/null +++ b/services/agents-api/internal/api/saved_configuration.go @@ -0,0 +1,129 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "slices" + "unicode/utf8" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func resolveSavedAgent(input v1.CreateAgentRequest) (store.CreateAgentInput, error) { + if input.Model == nil { + return store.CreateAgentInput{}, errors.New("model is required and must be a string.") + } + return resolveSavedFields(input) +} + +// Update requests reuse field validation without requiring an omitted model. +func resolveSavedFields(input v1.CreateAgentRequest) (store.CreateAgentInput, error) { + if input.Name != nil && utf8.RuneCountInString(*input.Name) > 128 { + return store.CreateAgentInput{}, errors.New("name must be at most 128 characters.") + } + metadata, err := stringMetadata(input.Metadata) + if err != nil { + return store.CreateAgentInput{}, errors.New("metadata values must be strings.") + } + if err := validateMetadata(metadata); err != nil { + return store.CreateAgentInput{}, err + } + if err := input.XAgentsCore.Validate(); err != nil { + return store.CreateAgentInput{}, err + } + cfg := v1.SavedAgentConfiguration{XAgentsCore: input.XAgentsCore, Name: input.Name, Instructions: input.Instructions, ServiceTier: "auto"} + if input.Model != nil { + cfg.Model = *input.Model + } + cfg.MultiAgent, err = resolveSavedMultiAgent(input.MultiAgent) + if err != nil { + return store.CreateAgentInput{}, err + } + if input.ServiceTier != nil { + if !slices.Contains([]string{"auto", "default", "flex", "priority", "fast"}, *input.ServiceTier) { + return store.CreateAgentInput{}, errors.New("service_tier must be auto, default, flex, priority or fast.") + } + cfg.ServiceTier = *input.ServiceTier + } + if input.Reasoning != nil { + cfg.Reasoning = *input.Reasoning + if cfg.Reasoning.Effort != nil && !slices.Contains([]string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, *cfg.Reasoning.Effort) { + return store.CreateAgentInput{}, errors.New("reasoning.effort is not a supported protocol value.") + } + if cfg.Reasoning.Summary != nil && !slices.Contains([]string{"concise", "detailed", "auto"}, *cfg.Reasoning.Summary) { + return store.CreateAgentInput{}, errors.New("reasoning.summary must be concise, detailed or auto.") + } + } + // Model-derived effort resolution is a recorded gap. Do not manufacture a + // default from the operator's execution engine or another model's catalog. + cfg.Text, err = resolveSavedText(input.Text) + if err != nil { + return store.CreateAgentInput{}, err + } + cfg.Tools, err = resolveSavedTools(input.Tools) + if err != nil { + return store.CreateAgentInput{}, err + } + configuration, err := json.Marshal(cfg) + return store.CreateAgentInput{Metadata: metadata, Configuration: configuration}, err +} + +func resolveSavedMultiAgent(raw json.RawMessage) (v1.MultiAgentConfig, error) { + result := v1.MultiAgentConfig{} + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return result, nil + } + var input struct { + Enabled *bool `json:"enabled"` + Max json.RawMessage `json:"max_concurrent_subagents"` + } + if decodeInputObject(raw, &input, "enabled", "max_concurrent_subagents") != nil || input.Enabled == nil { + return result, errors.New("multi_agent requires enabled as a boolean.") + } + maximum := uint32(6) + if len(input.Max) > 0 && (bytes.Equal(bytes.TrimSpace(input.Max), []byte("null")) || json.Unmarshal(input.Max, &maximum) != nil || maximum == 0) { + return result, errors.New("max_concurrent_subagents must be an integer from 1 to 4294967295.") + } + result.Enabled = *input.Enabled + if result.Enabled { + value := int(maximum) + result.MaxConcurrentSubagents = &value + } + return result, nil +} + +func resolveSavedText(input *v1.SavedAgentTextInput) (v1.SavedAgentText, error) { + result := v1.SavedAgentText{Format: v1.SavedAgentTextFormat{Type: "text"}, Verbosity: "medium"} + if input == nil { + return result, nil + } + // Reuse the Session verbosity policy without its narrower format admission. + text, err := resolveText(&v1.TextConfigInput{Verbosity: input.Verbosity}) + if err != nil { + return result, err + } + result.Verbosity = text.Verbosity + if len(input.Format) == 0 || bytes.Equal(bytes.TrimSpace(input.Format), []byte("null")) { + return result, nil + } + result.Format = v1.SavedAgentTextFormat{} + if decodeInputObject(input.Format, &result.Format, "type", "schema") != nil { + return result, errors.New("text.format must be a supported format object.") + } + switch result.Format.Type { + case "text": + if len(result.Format.Schema) > 0 { + return result, errors.New("text format does not accept schema.") + } + case "json_schema": + var schema map[string]json.RawMessage + if json.Unmarshal(result.Format.Schema, &schema) != nil || schema == nil { + return result, errors.New("json_schema format requires a schema object.") + } + default: + return result, errors.New("text.format.type must be text or json_schema.") + } + return result, nil +} diff --git a/services/agents-api/internal/api/saved_tools.go b/services/agents-api/internal/api/saved_tools.go new file mode 100644 index 000000000..f00f9d21d --- /dev/null +++ b/services/agents-api/internal/api/saved_tools.go @@ -0,0 +1,66 @@ +package api + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, 0, len(input)) + for _, raw := range input { + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &kind) != nil { + return nil, errors.New("tools must contain tool objects.") + } + var value json.RawMessage + switch kind.Type { + case "function": + var function v1.FunctionToolInput + if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { + return nil, errors.New("Invalid function tool fields.") + } + resolved, _, err := resolveFunction(function) + if err != nil { + return nil, err + } + value = resolved + case "tool_search": + if decodeInputObject(raw, &kind, "type") != nil { + return nil, errors.New("tool_search only accepts type.") + } + value, _ = json.Marshal(kind) + case "programmatic_tool_calling": + var input struct { + Type string `json:"type"` + Enabled json.RawMessage `json:"enabled"` + } + if decodeInputObject(raw, &input, "type", "enabled") != nil { + return nil, errors.New("Invalid programmatic_tool_calling fields.") + } + enabled, err := optionalBoolean(input.Enabled, true) + if err != nil { + return nil, errors.New("programmatic_tool_calling.enabled must be a boolean.") + } + value, _ = json.Marshal(struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + }{kind.Type, enabled}) + case "mcp": + resolved, err := resolveMCPTool(raw, true) + if err != nil { + return nil, err + } + value = resolved + case "web_search": + return nil, errors.New("Persisted web_search configuration is not implemented yet.") + default: + return nil, errors.New("Unknown persisted tool type.") + } + tools = append(tools, value) + } + return tools, nil +} diff --git a/services/agents-api/internal/api/session_agent.go b/services/agents-api/internal/api/session_agent.go new file mode 100644 index 000000000..bb22ebb85 --- /dev/null +++ b/services/agents-api/internal/api/session_agent.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// Resolve wire defaults before admitting the effective execution configuration. +// A saved resource remains usable with other executors even when this one cannot +// execute its options. Overrides replace whole fields; they never mutate it. +func resolveSessionAgent(input sessionRequest, saved *v1.SavedAgent) (v1.Agent, error) { + request := v1.CreateAgentRequest{} + if agent := input.Agent; agent != nil { + request.XAgentsCore = agent.XAgentsCore + request.Model, request.Instructions = agent.Model, agent.Instructions + request.MultiAgent, request.Reasoning, request.ServiceTier = agent.MultiAgent, agent.Reasoning, agent.ServiceTier + request.Text, request.Tools = agent.Text, agent.Tools + } + if saved != nil && request.Model == nil { + request.Model = &saved.Model + } + if request.Model == nil { + return v1.Agent{}, errors.New("agent.model is required without agent_id.") + } + resolved, err := resolveSavedAgent(request) + if err != nil { + return v1.Agent{}, err + } + var override v1.SavedAgentConfiguration + if err := json.Unmarshal(resolved.Configuration, &override); err != nil { + return v1.Agent{}, err + } + cfg := override + if saved != nil { + cfg = saved.SavedAgentConfiguration + for field := range input.agentFields { + switch field { + case "x_agents_core": + cfg.XAgentsCore = override.XAgentsCore + case "model": + cfg.Model = override.Model + case "instructions": + cfg.Instructions = override.Instructions + case "multi_agent": + cfg.MultiAgent = override.MultiAgent + case "reasoning": + cfg.Reasoning = override.Reasoning + case "service_tier": + cfg.ServiceTier = override.ServiceTier + case "text": + cfg.Text = override.Text + case "tools": + cfg.Tools = override.Tools + } + } + } + return admitSessionAgent(cfg) +} + +func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { + if strings.TrimSpace(cfg.Model) == "" { + return v1.Agent{}, errors.New("Execution currently requires a nonempty model.") + } + if cfg.MultiAgent.Enabled || cfg.MultiAgent.MaxConcurrentSubagents != nil { + return v1.Agent{}, errors.New("Enabled multi_agent execution is not supported by this service yet.") + } + if cfg.Reasoning.Effort != nil || cfg.Reasoning.Summary != nil { + return v1.Agent{}, errors.New("Explicit reasoning execution options are not supported by this service yet.") + } + if cfg.ServiceTier != "auto" { + return v1.Agent{}, errors.New("Execution currently supports service_tier=auto only.") + } + if cfg.Text.Format.Type != "text" || len(cfg.Text.Format.Schema) > 0 { + return v1.Agent{}, errors.New("Execution currently supports text.format.type=text only.") + } + text, err := resolveText(&v1.TextConfigInput{Verbosity: &cfg.Text.Verbosity}) + if err != nil { + return v1.Agent{}, err + } + tools, err := resolveSessionTools(cfg.Tools) + if err != nil { + return v1.Agent{}, err + } + return v1.Agent{XAgentsCore: cfg.XAgentsCore, Model: cfg.Model, Name: cfg.Name, Instructions: cfg.Instructions, + MultiAgent: cfg.MultiAgent, Reasoning: cfg.Reasoning, ServiceTier: cfg.ServiceTier, + Text: text, Tools: tools}, nil +} diff --git a/services/agents-api/internal/api/session_artifacts.go b/services/agents-api/internal/api/session_artifacts.go new file mode 100644 index 000000000..32aad8332 --- /dev/null +++ b/services/agents-api/internal/api/session_artifacts.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "io" + "net/http" + "path" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type SessionArtifactStore interface { + GetSessionArtifact(context.Context, string, string, string) (store.SessionArtifact, error) + ListSessionArtifacts(context.Context, string, string, string, string, int, bool) (store.ArtifactPage, error) + ReadSessionArtifact(context.Context, string, string, string, func(store.SessionArtifact, io.Reader) error) error + DeleteSessionArtifact(context.Context, string, string, string) error +} + +func WithSessionArtifacts(s SessionArtifactStore) Option { + return func(h *Handler) { h.artifacts = s } +} + +func (h *Handler) artifactsReady(w http.ResponseWriter, r *http.Request, list bool) bool { + if h.artifacts == nil { + writeError(w, http.StatusServiceUnavailable, "artifact_storage_unavailable", "Artifact storage is unavailable.") + return false + } + if !list && r.URL.RawQuery != "" { + writeStoreError(w, r, store.ErrInvalidInput) + return false + } + return true +} + +// @Summary List immutable Session artifacts +// @Description Lists published outputs independently of Environment availability. Sorting uses publication time and ID. The local default page size is 20; exact upstream defaults and error parity remain unverified. +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param environment_id query string false "Producing Environment ID" +// @Param after query string false "Last immutable artifact ID" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Publication order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SessionArtifactList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts [get] +func (h *Handler) listSessionArtifacts(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w, r, true) { + return + } + options, ok := readPage(w, r, "environment_id") + if !ok { + return + } + page, err := h.artifacts.ListSessionArtifacts(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), r.URL.Query().Get("environment_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.SessionArtifactList{Data: make([]v1.SessionArtifact, 0, len(page.Artifacts)), HasMore: page.NextCursor != ""} + for _, artifact := range page.Artifacts { + response.Data = append(response.Data, artifactResponse(artifact)) + } + writeJSON(w, http.StatusOK, response) +} + +// @Summary Retrieve immutable artifact metadata +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {object} v1.SessionArtifact +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [get] +func (h *Handler) getSessionArtifact(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w, r, false) { + return + } + artifact, err := h.artifacts.GetSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, artifactResponse(artifact)) +} + +// @Summary Delete a published artifact +// @Description Deletes the published copy without modifying its original workspace file. Already admitted content reads may finish; later reads reject. +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {object} v1.SessionArtifactDeleted +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [delete] +func (h *Handler) deleteSessionArtifact(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w, r, false) { + return + } + id := chi.URLParam(r, "artifact_id") + if err := h.artifacts.DeleteSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SessionArtifactDeleted{ID: id, Object: "agent.session.artifact.deleted", Deleted: true}) +} + +// @Summary Download immutable artifact bytes +// @Description Streams stored bytes after tenant and Session authorization, including after Environment expiration. Exact upstream headers and Range behavior remain unverified. +// @Tags Artifacts +// @Produce octet-stream +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {file} binary +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id}/content [get] +func (h *Handler) sessionArtifactContent(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w, r, false) { + return + } + serveStoredContent(w, r, func(ctx context.Context, consume func(string, int64, io.Reader) error) error { + return h.artifacts.ReadSessionArtifact(ctx, tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id"), func(a store.SessionArtifact, body io.Reader) error { + return consume(path.Base(a.Path), a.SizeBytes, body) + }) + }) +} + +func artifactResponse(a store.SessionArtifact) v1.SessionArtifact { + return v1.SessionArtifact{ID: a.ID, CreatedAt: a.CreatedAt.Unix(), EnvironmentID: a.EnvironmentID, + Object: "agent.session.artifact", Path: a.Path, SessionID: a.SessionID, SizeBytes: a.SizeBytes, TurnID: a.TurnID} +} diff --git a/services/agents-api/internal/api/session_artifacts_test.go b/services/agents-api/internal/api/session_artifacts_test.go new file mode 100644 index 000000000..4a57706e9 --- /dev/null +++ b/services/agents-api/internal/api/session_artifacts_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type artifactFixture struct { + artifact store.SessionArtifact + tenant, session, id, environment, cursor string + limit int + ascending bool + err error + calls int +} + +func (f *artifactFixture) GetSessionArtifact(_ context.Context, tenant, session, id string) (store.SessionArtifact, error) { + f.calls++ + f.tenant, f.session, f.id = tenant, session, id + return f.artifact, f.err +} + +func (f *artifactFixture) ListSessionArtifacts(_ context.Context, tenant, session, environment, cursor string, limit int, ascending bool) (store.ArtifactPage, error) { + f.calls++ + f.tenant, f.session, f.environment, f.cursor, f.limit, f.ascending = tenant, session, environment, cursor, limit, ascending + return store.ArtifactPage{Artifacts: []store.SessionArtifact{f.artifact}, NextCursor: f.artifact.ID}, f.err +} + +func (f *artifactFixture) ReadSessionArtifact(ctx context.Context, tenant, session, id string, consume func(store.SessionArtifact, io.Reader) error) error { + a, err := f.GetSessionArtifact(ctx, tenant, session, id) + if err != nil { + return err + } + return consume(a, bytes.NewReader([]byte{0, 255, 1})) +} + +func (f *artifactFixture) DeleteSessionArtifact(ctx context.Context, tenant, session, id string) error { + _, err := f.GetSessionArtifact(ctx, tenant, session, id) + return err +} + +type artifactResponseRecorder struct{ *httptest.ResponseRecorder } + +func (*artifactResponseRecorder) SetWriteDeadline(time.Time) error { return nil } + +func TestSessionArtifactRoutesAndPublicProjection(t *testing.T) { + f := &artifactFixture{artifact: store.SessionArtifact{ID: "artifact", SessionID: "session", EnvironmentID: "environment", TurnID: "turn", Path: "/workspace/outputs/a.bin", SizeBytes: 3, CreatedAt: time.Unix(123, 456)}} + h, _, tenant := testHandler(t, WithSessionArtifacts(f)) + request := func(method, suffix, beta string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/v1/agents/sessions/session/artifacts"+suffix, nil) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", beta) + r.Header.Set("X-Tenant-ID", "untrusted") + w := &artifactResponseRecorder{httptest.NewRecorder()} + h.ServeHTTP(w, r) + return w.ResponseRecorder + } + w := request("GET", "/artifact", "agents=v1") + var got v1.SessionArtifact + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Object != "agent.session.artifact" || got.CreatedAt != 123 || got.SizeBytes != 3 || got.Path != f.artifact.Path || got.TurnID != "turn" || got.EnvironmentID != "environment" || got.SessionID != "session" || got.ID != "artifact" { + t.Fatalf("metadata projection: %d %s", w.Code, w.Body) + } + if f.tenant != tenant || f.session != "session" || f.id != "artifact" { + t.Fatalf("untrusted request scope: %+v", f) + } + w = request("GET", "?environment_id=environment&after=previous&limit=1&order=asc", "agents=v1") + var page v1.SessionArtifactList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || !page.HasMore || len(page.Data) != 1 || f.environment != "environment" || f.cursor != "previous" || f.limit != 1 || !f.ascending { + t.Fatalf("filtered page: %d %s %+v", w.Code, w.Body, f) + } + w = request("GET", "/artifact/content", "agents=v1") + if w.Code != 200 || !bytes.Equal(w.Body.Bytes(), []byte{0, 255, 1}) || w.Header().Get("Content-Type") != "application/octet-stream" || w.Header().Get("Content-Length") != "3" { + t.Fatalf("content: %d %s %v", w.Code, w.Body, w.Header()) + } + w = request("DELETE", "/artifact", "agents=v1") + var deleted v1.SessionArtifactDeleted + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &deleted) != nil || !deleted.Deleted || deleted.ID != "artifact" || deleted.Object != "agent.session.artifact.deleted" { + t.Fatalf("delete: %d %s", w.Code, w.Body) + } + for _, suffix := range []string{"?limit=0", "?limit=101", "?order=random", "?environment_id=a&environment_id=b", "?limit=1&limit=2", "?unknown=x", "/artifact?unknown=x", "/artifact/content?unknown=x"} { + before := f.calls + if w := request("GET", suffix, "agents=v1"); w.Code != 400 || f.calls != before { + t.Fatalf("invalid query reached storage: %s %d", suffix, w.Code) + } + } + for _, route := range []struct{ method, suffix string }{{"GET", ""}, {"GET", "/artifact"}, {"GET", "/artifact/content"}, {"DELETE", "/artifact"}} { + before := f.calls + if w := request(route.method, route.suffix, ""); w.Code != 400 || f.calls != before { + t.Fatalf("missing Beta accepted: %s %s %d", route.method, route.suffix, w.Code) + } + f.err = store.ErrNotFound + if w := request(route.method, route.suffix, "agents=v1"); w.Code != 404 { + t.Fatalf("not-found mapping: %s %s %d", route.method, route.suffix, w.Code) + } + } + if w := request(http.MethodPost, "", "agents=v1"); w.Code != 405 { + t.Fatalf("invented create route: %d", w.Code) + } +} diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go new file mode 100644 index 000000000..c6d3322fd --- /dev/null +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -0,0 +1,67 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { + if input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + return nil, nil + } + agentID := "" + if input.AgentID != nil { + agentID = *input.AgentID + } + var environment any = input.Environment + if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() { + environment = input.originalEnvironment + if len(input.originalEnvironment) == 0 { + environment = input.templateEnvironment + } + } + return json.Marshal(struct { + Execution *v1.SessionExecutionInput `json:"x_agents_core,omitempty"` + AgentID string `json:"agent_id"` + Agent map[string]json.RawMessage `json:"agent,omitempty"` + Environment any `json:"environment"` + Metadata map[string]string `json:"metadata,omitempty"` + VaultIDs []string `json:"vault_ids,omitempty"` + InitialInputs []store.Input `json:"initial_inputs,omitempty"` + }{input.XAgentsCore, agentID, input.agentFields, environment, input.Metadata, input.VaultIDs, initial}) +} + +func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, key string, request json.RawMessage, stream bool) bool { + if len(request) == 0 { + return false + } + result, err := h.store.FindSessionCreation(r.Context(), tenantID(r), key, request, sessionCreator(r)) + if errors.Is(err, store.ErrNotFound) { + return false + } + if err != nil { + writeStoreError(w, r, err) + return true + } + if stream { + events, ok := h.store.(eventStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") + return true + } + h.respondSessionCreationStream(w, r, events, result) + } else { + session, err := h.store.GetSession(r.Context(), tenantID(r), result.Session.ID) + if err != nil { + writeStoreError(w, r, err) + } else { + h.respondSession(w, r, session) + } + } + return true +} diff --git a/services/agents-api/internal/api/session_creation_stream.go b/services/agents-api/internal/api/session_creation_stream.go new file mode 100644 index 000000000..f03851b77 --- /dev/null +++ b/services/agents-api/internal/api/session_creation_stream.go @@ -0,0 +1,60 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type sessionStreamCreator interface { + CreateSessionStream(context.Context, string, store.CreateSessionInput) (store.SessionCreation, error) +} + +func (h *Handler) createSessionStream(w http.ResponseWriter, r *http.Request, input store.CreateSessionInput) { + events, ok := h.store.(eventStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") + return + } + var source any = h.store + var config configuration + if err := json.Unmarshal(input.Configuration, &config); err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if len(input.InitialInputs) > 0 || config.Environment.Type == "openai_hosted" { + if h.inputs == nil { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution input is not enabled on this service.") + return + } + source = h.inputs + } + creator, ok := source.(sessionStreamCreator) + if !ok { + writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Streaming creation is unavailable.") + return + } + result, err := creator.CreateSessionStream(r.Context(), tenantID(r), input) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondSessionCreationStream(w, r, events, result) +} + +func (h *Handler) respondSessionCreationStream(w http.ResponseWriter, r *http.Request, events eventStore, result store.SessionCreation) { + response, err := sessionResponse(result.Session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + var initial *v1.SessionEvent + if result.Created { + initial = &v1.SessionEvent{Type: "agent.session.created", EventID: uuid.NewString(), Session: &response} + } + h.serveSessionEvents(w, r, events, result.Session, result.Cursor, initial) +} diff --git a/services/agents-api/internal/api/session_credentials.go b/services/agents-api/internal/api/session_credentials.go new file mode 100644 index 000000000..6b9459bc8 --- /dev/null +++ b/services/agents-api/internal/api/session_credentials.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type mcpCredentialResolver interface { + ResolveMCPCredentials(context.Context, string, []string, []store.MCPCredentialRequest) ([]store.MCPCredentialBinding, error) +} + +func (h *Handler) bindSessionCredentials(ctx context.Context, tenant string, raw json.RawMessage) (json.RawMessage, error) { + var cfg configuration + if json.Unmarshal(raw, &cfg) != nil { + return nil, store.ErrInvalidInput + } + var requests []store.MCPCredentialRequest + required := len(cfg.VaultIDs) > 0 + for _, rawTool := range cfg.Agent.Tools { + var tool v1.MCPTool + if json.Unmarshal(rawTool, &tool) != nil { + return nil, store.ErrInvalidInput + } + if tool.Type == "mcp" { + requests = append(requests, store.MCPCredentialRequest{ServerLabel: tool.ServerLabel, ServerURL: tool.Transport.ServerURL, CredentialID: tool.CredentialID}) + required = required || tool.CredentialID != nil + } + } + if !required { + return raw, nil + } + resolver, ok := h.store.(mcpCredentialResolver) + if !ok { + return nil, store.ErrCredentialStorageUnavailable + } + bindings, err := resolver.ResolveMCPCredentials(ctx, tenant, cfg.VaultIDs, requests) + if err != nil { + return nil, err + } + cfg.MCPCredentials = bindings + return json.Marshal(cfg) +} + +// Attached inline requests need recorded caller intent before reading mutable +// Vault contents. Other inline requests retain their resolved/default identity. +func inlineCredentialIntent(input sessionRequest) bool { + if len(input.VaultIDs) > 0 { + return true + } + var tools []struct { + Type string `json:"type"` + CredentialID *string `json:"credential_id"` + } + if json.Unmarshal(input.agentFields["tools"], &tools) == nil { + for _, tool := range tools { + if tool.Type == "mcp" && tool.CredentialID != nil { + return true + } + } + } + return false +} diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/agents-api/internal/api/session_credentials_test.go new file mode 100644 index 000000000..5d74e2361 --- /dev/null +++ b/services/agents-api/internal/api/session_credentials_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { + for _, saved := range []bool{false, true} { + input := strings.TrimSuffix(publicMCP, "}") + `,"credential_id":"unresolved-reference"}` + raw, err := resolveMCPTool(json.RawMessage(input), saved) + var output v1.MCPTool + if err != nil || json.Unmarshal(raw, &output) != nil || output.CredentialID == nil || *output.CredentialID != "unresolved-reference" { + t.Fatal("saved/effective schema resolved or lost the caller credential reference", err) + } + } +} + +func TestSessionVaultTypesAndCreationIntent(t *testing.T) { + for _, field := range []string{"", `,"vault_ids":null`, `,"vault_ids":[]`, `,"vault_ids":["vault"]`, `,"vault_ids":[null]`, `,"vault_ids":[3]`, `,"vault_ids":{}`, `,"vault_ids":"vault"`} { + var decoded decodedSessionRequest + err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"none"}`+field+`}`), &decoded) + if err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + invalid := strings.Contains(field, "[null]") || strings.Contains(field, "[3]") || strings.Contains(field, "{}") || strings.Contains(field, `:"vault"`) + if (err != nil) != invalid { + t.Fatal("Vault IDs type/default decision differed", field) + } + if invalid { + continue + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || (len(intent) != 0) != (len(input.VaultIDs) > 0) { + t.Fatal("attached inline intent missing or unrelated inline identity changed", err) + } + } + var request decodedSessionRequest + body := `{"agent":{"model":"model","tools":[` + publicMCP + `]},"environment":{"type":"none"},"vault_ids":["vault"]}` + if err := json.Unmarshal([]byte(body), &request); err != nil { + t.Fatal(err) + } + input, _ := request.validated() + first, _ := sessionCreationRequest(input, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"original"}`)}}) + input.Stream = true + second, _ := sessionCreationRequest(input, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"original"}`)}}) + if !reflect.DeepEqual(first, second) { + t.Fatal("streaming changed credential-bound creation identity") + } + input.VaultIDs = []string{"other"} + changed, _ := sessionCreationRequest(input, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"original"}`)}}) + if reflect.DeepEqual(first, changed) { + t.Fatal("changed attachment reused caller intent") + } +} + +func TestSessionProjectionExcludesResolvedMCPSelection(t *testing.T) { + var tool v1.MCPTool + if json.Unmarshal([]byte(publicMCP), &tool) != nil { + t.Fatal("invalid fixture") + } + encodedTool, _ := json.Marshal(tool) + cfg := configuration{Agent: v1.Agent{ID: "agent", Model: "model", Tools: []json.RawMessage{encodedTool}}, Environment: v1.Environment{Type: "none"}, VaultIDs: []string{"attached"}, + MCPCredentials: []store.MCPCredentialBinding{{ServerLabel: "records", ServerURL: tool.Transport.ServerURL, VaultID: "attached", CredentialID: "private-selection", AuthType: "static_bearer"}}} + raw, _ := json.Marshal(cfg) + response, err := sessionResponse(store.Session{Configuration: raw}, "") + if err != nil || !reflect.DeepEqual(response.VaultIDs, cfg.VaultIDs) { + t.Fatal("public attachments lost", err) + } + public, _ := json.Marshal(response) + if strings.Contains(string(public), "private-selection") || strings.Contains(string(public), "mcp_credentials") || !strings.Contains(string(public), `"credential_id":null`) { + t.Fatal("public projection exposed implicit selection or changed caller reference") + } +} diff --git a/services/agents-api/internal/api/session_deletion.go b/services/agents-api/internal/api/session_deletion.go new file mode 100644 index 000000000..4f3e76fda --- /dev/null +++ b/services/agents-api/internal/api/session_deletion.go @@ -0,0 +1,47 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// @Summary Delete an execution Session +// @Description Removes the Session and its history from the public API. Active work receives a cancellation request; confirmation does not guarantee native execution has stopped. Internal records and native history are retained pending separate physical cleanup. Missing/repeated deletion locally returns 404; exact hosted errors and overlapping stream timing remain unverified. +// @Tags Sessions +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Success 200 {object} v1.SessionDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id} [delete] +func (h *Handler) deleteSession(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session deletion does not accept query parameters.") + return + } + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session deletion does not accept a request body.") + return + } + id := chi.URLParam(r, "session_id") + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil { + writeStoreError(w, r, store.ErrNotFound) + return + } + if err := h.store.DeleteSession(r.Context(), tenantID(r), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SessionDeleted{ID: parsed.String(), Object: "agent.session.deleted", Deleted: true}) +} diff --git a/services/agents-api/internal/api/session_environment_http_test.go b/services/agents-api/internal/api/session_environment_http_test.go new file mode 100644 index 000000000..6aea2dc2f --- /dev/null +++ b/services/agents-api/internal/api/session_environment_http_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentHTTPFixture struct{ streamFixture } + +func (f *environmentHTTPFixture) ListSessions(_ context.Context, tenant, _ string, _ int, _ bool, _ *string) (store.SessionPage, error) { + if tenant != f.session.TenantID { + return store.SessionPage{}, store.ErrNotFound + } + return store.SessionPage{Sessions: []store.Session{f.session}}, nil +} + +func (f *environmentHTTPFixture) UpdateSessionMetadata(ctx context.Context, tenant, session string, metadata map[string]string) (store.Session, error) { + value, err := f.GetSession(ctx, tenant, session) + value.Metadata = metadata + return value, err +} + +func TestSelfHostedSessionHTTPReadListMetadataAndLiveStream(t *testing.T) { + session := environmentSession() + session.TenantID = uuid.NewString() + session.Environment.TenantID = session.TenantID + session.EnvironmentInputActivity = &store.EnvironmentInputActivity{ + Status: "requires_action", EnvironmentID: session.Environment.ID, LastActiveAt: time.Unix(1700000100, 0), + } + fixture := &environmentHTTPFixture{streamFixture{session: session, changes: []store.SessionChange{{ + Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.requires_action", EventID: "activity", SessionID: session.ID}, + EnvironmentInputActivity: session.EnvironmentInputActivity, + }}}} + auth, err := NewAuthenticator([]APIKey{{ + OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", + TokenSHA256: device.HashCredential("key"), TenantID: session.TenantID, + }}) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandler(fixture, auth, "codex", WithEnvironmentRemoteURL(environmentOrigin)) + if err != nil { + t.Fatal(err) + } + want, err := sessionResponse(session, environmentOrigin) + if err != nil { + t.Fatal(err) + } + check := func(value v1.Session) { + t.Helper() + value.Metadata = want.Metadata + actual, _ := json.Marshal(value) + expected, _ := json.Marshal(want) + if string(actual) != string(expected) { + t.Fatal("Session projections differ", string(actual), string(expected)) + } + } + for _, request := range []struct{ method, path, body string }{ + {http.MethodGet, "/v1/agents/sessions/session", ""}, + {http.MethodGet, "/v1/agents/sessions", ""}, + {http.MethodPost, "/v1/agents/sessions/session", `{"metadata":{"label":"updated"}}`}, + } { + r := httptest.NewRequest(request.method, request.path, strings.NewReader(request.body)) + r.Host = "untrusted-host.example" + r.Header.Set("Authorization", "Bearer key") + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatal(request.path, w.Code, w.Body.String()) + } + var value v1.Session + if request.path == "/v1/agents/sessions" { + var list v1.SessionList + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil || len(list.Data) != 1 { + t.Fatal(w.Body.String(), err) + } + value = list.Data[0] + } else if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil { + t.Fatal(err) + } + check(value) + } + server := httptest.NewServer(handler) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + r, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v1/agents/sessions/session/events", nil) + r.Host = "untrusted-host.example" + r.Header.Set("Authorization", "Bearer key") + r.Header.Set("OpenAI-Beta", "agents=v1") + response, err := server.Client().Do(r) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.Header.Get("Content-Type") != "text/event-stream" { + t.Fatal("self-hosted live stream unavailable", response.StatusCode) + } + scanner := bufio.NewScanner(response.Body) + for scanner.Scan() { + if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok { + var event v1.SessionEvent + if err := json.Unmarshal([]byte(data), &event); err != nil || event.Session == nil { + t.Fatal(data, err) + } + check(*event.Session) + return + } + } + t.Fatal("stream ended before pending activity", scanner.Err()) +} diff --git a/services/agents-api/internal/api/session_environment_test.go b/services/agents-api/internal/api/session_environment_test.go new file mode 100644 index 000000000..03d854748 --- /dev/null +++ b/services/agents-api/internal/api/session_environment_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +const environmentOrigin = "https://executor.example" + +func environmentSession() store.Session { + return store.Session{ + ID: "session", TenantID: "tenant", CreatedAt: time.Unix(1700000000, 0), Metadata: map[string]string{}, + Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"self_hosted"},"daemon":{"credential":"private"}}`), + Environment: &store.Environment{ + ID: "environment", SessionID: "session", TenantID: "tenant", Status: "pending", + Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":["/remote/capabilities"],"id":"forged","remote_url":"https://secret@private","env":{"SECRET":"private"},"setup_commands":["private"]}`), + }, + } +} + +func TestSessionEnvironmentUsesSafeStoredAssociation(t *testing.T) { + session := environmentSession() + response, err := sessionResponse(session, environmentOrigin) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(response.Environment) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if json.Unmarshal(raw, &fields) != nil || !reflect.DeepEqual(fields, map[string]any{ + "id": "environment", "type": "self_hosted", "remote_url": environmentOrigin, + "capability_directories": []any{"/remote/capabilities"}, "workspace_directory": "/remote/workspace", + }) { + t.Fatal("unsafe or inaccurate environment output", string(raw)) + } + if response.Status != "idle" || len(response.RequiredActions) != 0 { + t.Fatal("offline idle environment requested compute", response) + } + session.Environment.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":null}`) + response, err = sessionResponse(session, environmentOrigin) + if err != nil || response.Environment.CapabilityDirectories == nil || *response.Environment.CapabilityDirectories == nil { + t.Fatal("missing capability paths must project as an empty array", response, err) + } + for _, change := range []func(*store.Session){ + func(s *store.Session) { s.Environment = nil }, + func(s *store.Session) { s.Environment.ID = "" }, + func(s *store.Session) { s.Environment.TenantID = "foreign" }, + func(s *store.Session) { s.Environment.SessionID = "other" }, + func(s *store.Session) { s.Environment.Configuration = json.RawMessage(`{"type":"self_hosted"}`) }, + func(s *store.Session) { + s.Configuration = json.RawMessage(`{"agent":{"id":"agent","model":"model"},"environment":{"type":"openai_hosted"}}`) + }, + } { + invalid := environmentSession() + change(&invalid) + if _, err := sessionResponse(invalid, environmentOrigin); err == nil { + t.Fatal("invalid environment association or unsupported hosted output accepted") + } + } + if _, err := sessionResponse(session, ""); err == nil { + t.Fatal("self-hosted output invented a remote URL") + } +} + +func TestEnvironmentInputActivitySnapshotsIgnoreCurrentTurnAndActivity(t *testing.T) { + session := environmentSession() + session.LastTurn = &store.Turn{ID: "old-failure", Status: store.TurnFailed, CompletedAt: time.Unix(1700000200, 0)} + session.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "requires_action", EnvironmentID: "environment", LastActiveAt: time.Unix(1700000300, 0)} + session.RequiredActions = []v1.FunctionCallAction{{Type: "function_call", CallID: "stale"}} + session.Usage = json.RawMessage(`{"input_tokens":999,"output_tokens":0,"total_tokens":999}`) + for _, status := range []string{"requires_action", "idle", "failed"} { + activity := &store.EnvironmentInputActivity{Status: status, LastActiveAt: time.Unix(1700000100, 0)} + if status == "requires_action" { + activity.EnvironmentID = "environment" + } + change := store.SessionChange{ + Event: v1.SessionEvent{Type: "agent.session." + status, EventID: "event", SessionID: session.ID}, + EnvironmentInputActivity: activity, + } + event, err := streamResponse(session, change, environmentOrigin) + if err != nil || event.Session == nil { + t.Fatal(event, err) + } + value := event.Session + if value.Status != status || (value.Error != nil) != (status == "failed") || value.Usage != nil || value.LastActiveAt != 1700000100 || value.RequiredActions == nil { + t.Fatal("activity borrowed current Session state", value) + } + raw, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if json.Unmarshal(raw, &wire) != nil || len(wire) != 3 || wire["session"] == nil || wire["turn_id"] != nil || wire["session_id"] != nil { + t.Fatal("invalid pre-Turn event fields", string(raw)) + } + actions, _ := json.Marshal(value.RequiredActions) + if status == "requires_action" && string(actions) != `[{"environment_id":"environment","type":"environment_connection"}]` { + t.Fatal("function fields leaked into environment action", string(actions)) + } + if status != "requires_action" && string(actions) != `[]` { + t.Fatal("settled action leaked", string(actions)) + } + if status == "failed" && *value.Error != "The initial input timed out waiting for the environment connection." { + t.Fatal("unsafe or missing initial failure message", value.Error) + } + } + change := store.SessionChange{ + Event: v1.SessionEvent{Type: "agent.session.in_progress", EventID: "promotion"}, + Turn: &store.Turn{ID: "promoted", Status: store.TurnInProgress, CreatedAt: time.Unix(1700000400, 0)}, + } + event, err := streamResponse(session, change, environmentOrigin) + if err != nil || event.Session.Status != "in_progress" || len(event.Session.RequiredActions) != 0 || event.Session.LastActiveAt != 1700000400 { + t.Fatal("current pending activity leaked into promoted Turn snapshot", event, err) + } +} diff --git a/services/agents-api/internal/api/session_initial_input.go b/services/agents-api/internal/api/session_initial_input.go new file mode 100644 index 000000000..de70a65c4 --- /dev/null +++ b/services/agents-api/internal/api/session_initial_input.go @@ -0,0 +1,32 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func initialSessionInputs(raw json.RawMessage) ([]store.Input, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + if raw[0] == '"' { + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return nil, store.ErrInvalidInput + } + raw, _ = json.Marshal([]v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: text}}}}) + } + // Preserve the array's original fields for the shared strict message decoder. + event, err := json.Marshal(struct { + Type string `json:"type"` + Input json.RawMessage `json:"input"` + }{Type: "agent.session.input.message", Input: raw}) + if err != nil { + return nil, store.ErrInvalidInput + } + return executionInputs([]json.RawMessage{event}) +} diff --git a/services/agents-api/internal/api/session_initial_input_test.go b/services/agents-api/internal/api/session_initial_input_test.go new file mode 100644 index 000000000..8b892135e --- /dev/null +++ b/services/agents-api/internal/api/session_initial_input_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInitialInputUsesExecutionAdmissionAndSharedMessageValidation(t *testing.T) { + for _, value := range []string{`"First"`, `[{"role":"user","content":[{"type":"input_text","text":"First"}]}]`} { + execution := &recordingStore{} + recorder := &inputRecorder{ResourceStore: execution} + h, idle, tenant := testHandler(t, WithExecution(recorder)) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"test-model"},"environment":{"type":"none"},"input":`+value+`}`)) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", "create-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || idle.tenant != "" || execution.tenant != tenant || execution.input.IdempotencyKey != "create-key" || len(execution.input.InitialInputs) != 1 || recorder.inputs != nil { + t.Fatal(w.Code, w.Body, execution.input) + } + expected, err := executionInputs([]json.RawMessage{json.RawMessage(`{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"First"}]}]}`)}) + if err != nil || string(expected[0].Payload) != string(execution.input.InitialInputs[0].Payload) { + t.Fatal(execution.input, err) + } + } + for _, value := range []string{`0`, `true`, `{}`, `[]`, `" "`, `[{"role":"user","unknown":true,"content":[{"type":"input_text","text":"x"}]}]`, `[{"role":"user","content":[{"type":"input_text","text":"x","unknown":true}]}]`, `[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]`} { + if _, err := initialSessionInputs(json.RawMessage(value)); err == nil { + t.Fatal("invalid initial input accepted", value) + } + } + for _, value := range []json.RawMessage{nil, json.RawMessage(` null `)} { + if got, err := initialSessionInputs(value); err != nil || got != nil { + t.Fatal(got, err) + } + } +} diff --git a/services/agents-api/internal/api/session_metadata.go b/services/agents-api/internal/api/session_metadata.go new file mode 100644 index 000000000..f69588773 --- /dev/null +++ b/services/agents-api/internal/api/session_metadata.go @@ -0,0 +1,91 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Update execution Session metadata +// @Description Omit metadata to leave it unchanged, send null or {} to clear it, or supply an object to replace all pairs. Up to 16 string pairs, with keys at most 64 characters and values at most 512 characters. Execution configuration and activity are unchanged. Returns the same safe Environment and pending-input activity projection as Session retrieval. +// @Tags Sessions +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param body body v1.UpdateSessionRequest true "Session metadata" +// @Success 200 {object} v1.Session +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id} [post] +func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session updates do not accept query parameters.") + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var request struct { + Metadata json.RawMessage `json:"metadata"` + } + if err := decodeInputObject(raw, &request, "metadata"); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + return + } + var session store.Session + var err error + if len(request.Metadata) == 0 { + session, err = h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + } else { + var values map[string]*string + if err := json.Unmarshal(request.Metadata, &values); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "metadata must be null or an object with string values.") + return + } + var metadata map[string]string + metadata, err = stringMetadata(values) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "metadata values must be strings.") + return + } + if err := validateMetadata(metadata); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + session, err = h.store.UpdateSessionMetadata(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), metadata) + } + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondSession(w, r, session) +} + +func stringMetadata(values map[string]*string) (map[string]string, error) { + metadata := make(map[string]string, len(values)) + for key, value := range values { + if value == nil { + return nil, store.ErrInvalidInput + } + metadata[key] = *value + } + return metadata, nil +} + +func validateMetadata(metadata map[string]string) error { + if len(metadata) > 16 { + return errors.New("metadata supports at most 16 pairs.") + } + for key, value := range metadata { + if utf8.RuneCountInString(key) > 64 || utf8.RuneCountInString(value) > 512 { + return errors.New("metadata keys must be at most 64 characters and values at most 512 characters.") + } + } + return nil +} diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go new file mode 100644 index 000000000..50ad12750 --- /dev/null +++ b/services/agents-api/internal/api/session_request.go @@ -0,0 +1,91 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Keep explicit null until validation for fields whose Go zero values would +// otherwise erase it. The embedded wire type retains strict nested decoding. +type decodedSessionRequest struct { + v1.CreateSessionRequest + Input json.RawMessage `json:"input"` + Agent json.RawMessage `json:"agent"` + AgentID json.RawMessage `json:"agent_id"` + Environment json.RawMessage `json:"environment"` + Stream json.RawMessage `json:"stream"` + Metadata map[string]*string `json:"metadata"` + VaultIDs json.RawMessage `json:"vault_ids"` +} + +type sessionRequest struct { + initialFiles []store.InitialFile + initialization store.EnvironmentSetup + originalEnvironment json.RawMessage + v1.CreateSessionRequest + Input json.RawMessage + templateID string + templateEnvironment json.RawMessage + agentFields map[string]json.RawMessage +} + +func (request decodedSessionRequest) validated() (sessionRequest, error) { + input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} + var vaultIDs []*string + if len(request.VaultIDs) != 0 && json.Unmarshal(request.VaultIDs, &vaultIDs) != nil { + return input, store.ErrInvalidInput + } + input.VaultIDs = make([]string, 0, len(vaultIDs)) + for _, id := range vaultIDs { + if id == nil { + return input, store.ErrInvalidInput + } + input.VaultIDs = append(input.VaultIDs, *id) + } + input.originalEnvironment = request.Environment + var environmentFields map[string]json.RawMessage + if json.Unmarshal(request.Environment, &environmentFields) != nil { + return input, store.ErrInvalidInput + } + var err error + input.initialization, err = decodeEnvironmentSetup(environmentFields) + if err != nil { + return input, err + } + input.initialFiles, err = decodeInitialFiles(environmentFields["files"]) + if err != nil { + return input, err + } + input.Environment, input.templateID, input.templateEnvironment, err = decodeTemplateEnvironment(request.Environment) + if err != nil { + return input, err + } + if len(request.Agent) > 0 { + if decodeInputObject(request.Agent, &input.Agent, "model", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + return input, store.ErrInvalidInput + } + if err := json.Unmarshal(request.Agent, &input.agentFields); err != nil { + return input, store.ErrInvalidInput + } + if _, supplied := input.agentFields["model"]; supplied && input.Agent.Model == nil { + return input, store.ErrInvalidInput + } + } + if len(request.AgentID) > 0 { + var id string + if bytes.Equal(bytes.TrimSpace(request.AgentID), []byte("null")) || json.Unmarshal(request.AgentID, &id) != nil { + return input, store.ErrInvalidInput + } + input.AgentID = &id + } + if len(request.Stream) > 0 { + if bytes.Equal(bytes.TrimSpace(request.Stream), []byte("null")) || json.Unmarshal(request.Stream, &input.Stream) != nil { + return input, store.ErrInvalidInput + } + } + input.Metadata, err = stringMetadata(request.Metadata) + return input, err +} diff --git a/services/agents-api/internal/api/session_request_test.go b/services/agents-api/internal/api/session_request_test.go new file mode 100644 index 000000000..7624f28c6 --- /dev/null +++ b/services/agents-api/internal/api/session_request_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestSessionCreateFieldPresence(t *testing.T) { + // Pinned SessionCreateParams: stream and agent_id are not nullable; + // metadata is nullable, but its values must be strings. + for _, tc := range []struct { + name, fields string + status int + metadata map[string]string + }{ + {"omitted", ``, 200, nil}, + {"false stream", `,"stream":false`, 200, nil}, + {"null stream", `,"stream":null`, 400, nil}, + {"whitespace null stream", `,"stream": null `, 400, nil}, + {"string stream", `,"stream":"false"`, 400, nil}, + {"numeric stream", `,"stream":0`, 400, nil}, + {"null agent ID", `,"agent_id":null`, 400, nil}, + {"numeric agent ID", `,"agent_id":0`, 400, nil}, + {"null metadata", `,"metadata":null`, 200, nil}, + {"empty metadata", `,"metadata":{}`, 200, nil}, + {"string metadata", `,"metadata":{"empty":"","label":"中文🧪"}`, 200, map[string]string{"empty": "", "label": "中文🧪"}}, + {"null metadata value", `,"metadata":{"label":null}`, 400, nil}, + {"mixed metadata values", `,"metadata":{"empty":"","label":null}`, 400, nil}, + {"numeric metadata value", `,"metadata":{"label":0}`, 400, nil}, + {"array metadata", `,"metadata":[]`, 400, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + handler, saved, _ := testHandler(t) + body := `{"agent":{"model":"example"},"environment":{"type":"none"}` + tc.fields + `}` + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != tc.status { + t.Fatalf("status = %d, want %d: %s", response.Code, tc.status, response.Body) + } + if tc.status != http.StatusOK { + if saved.tenant != "" { + t.Fatal("invalid request reached persistence") + } + var failure v1.ErrorResponse + if json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code != "invalid_request" { + t.Fatalf("invalid error response: %s", response.Body) + } + return + } + if saved.tenant == "" || !maps.Equal(saved.input.Metadata, tc.metadata) { + t.Fatalf("saved metadata = %#v, want %#v", saved.input.Metadata, tc.metadata) + } + var session v1.Session + if json.Unmarshal(response.Body.Bytes(), &session) != nil || !maps.Equal(session.Metadata, tc.metadata) { + t.Fatalf("metadata response changed: %s", response.Body) + } + }) + } +} diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go new file mode 100644 index 000000000..b5f09221c --- /dev/null +++ b/services/agents-api/internal/api/session_response.go @@ -0,0 +1,113 @@ +package api + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// WithEnvironmentRemoteURL uses the composition's validated executor origin for self-hosted requests and output. +func WithEnvironmentRemoteURL(origin string) Option { + return func(h *Handler) { h.executorURL = origin } +} + +func sessionResponse(session store.Session, executorURL string) (v1.Session, error) { + var cfg configuration + if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" || cfg.Agent.Model == "" { + return v1.Session{}, errors.New("unsupported stored session configuration") + } + if cfg.Agent.XAgentsCore != nil && session.Engine != "" { + cfg.Agent.XAgentsCore = &v1.AgentsCore{Harness: session.Engine} + } + environment, err := sessionEnvironment(session, cfg.Environment.Type, executorURL) + if err != nil { + return v1.Session{}, err + } + response := v1.Session{ + ID: session.ID, Agent: cfg.Agent, Environment: environment, Usage: tokenUsage(session.Usage), + CreatedAt: session.CreatedAt.Unix(), LastActiveAt: session.CreatedAt.Unix(), + Metadata: session.Metadata, Object: "agent.session", Status: "idle", + RequiredActions: []v1.RequiredAction{}, VaultIDs: append([]string{}, cfg.VaultIDs...), + } + if turn := session.LastTurn; turn != nil { + active := turn.CreatedAt + if turn.StartedAt.After(active) { + active = turn.StartedAt + } + if turn.CompletedAt.After(active) { + active = turn.CompletedAt + } + response.LastActiveAt = active.Unix() + switch turn.Status { + case store.TurnQueued, store.TurnInProgress, store.TurnWaiting: + response.Status = "in_progress" + if turn.CancelRequestedAt.IsZero() && len(session.RequiredActions) > 0 { + response.Status = "requires_action" + for _, action := range session.RequiredActions { + response.RequiredActions = append(response.RequiredActions, v1.RequiredAction{ + Type: action.Type, Arguments: action.Arguments, CallID: action.CallID, Name: action.Name, TurnID: action.TurnID, + }) + } + } + case store.TurnFailed: + response.Status = "failed" + message := "The execution could not complete." + response.Error = &message + } + } + if activity := session.EnvironmentInputActivity; activity != nil { + if activity.Status != "idle" && activity.Status != "requires_action" && activity.Status != "failed" { + return v1.Session{}, errors.New("unsupported stored environment input activity") + } + response.Status, response.Error = activity.Status, nil + if activity.Status == "failed" { + message := "The initial input timed out waiting for the environment connection." + if activity.Failure == "environment_unavailable" { + message = "The environment is no longer available for this input." + } + response.Error = &message + } + response.LastActiveAt = activity.LastActiveAt.Unix() + response.RequiredActions = []v1.RequiredAction{} + if activity.Status == "requires_action" { + if activity.EnvironmentID == "" || activity.EnvironmentID != environment.ID { + return v1.Session{}, errors.New("invalid stored environment connection action") + } + response.RequiredActions = append(response.RequiredActions, v1.RequiredAction{Type: "environment_connection", EnvironmentID: activity.EnvironmentID}) + } + } + return response, nil +} + +func sessionEnvironment(session store.Session, kind, executorURL string) (v1.SessionEnvironment, error) { + if kind == "none" { + return v1.SessionEnvironment{Type: "none"}, nil + } + environment := session.Environment + if (kind != "self_hosted" && kind != "openai_hosted") || environment == nil || environment.ID == "" || environment.SessionID != session.ID || environment.TenantID != session.TenantID { + return v1.SessionEnvironment{}, errors.New("unsupported stored session environment") + } + if kind == "openai_hosted" { + return hostedSessionEnvironment(*environment) + } + if executorURL == "" { + return v1.SessionEnvironment{}, errors.New("self-hosted executor origin unavailable") + } + var cfg struct { + Type string `json:"type"` + CapabilityDirectories []string `json:"capability_directories"` + WorkspaceDirectory string `json:"workspace_directory"` + } + if err := json.Unmarshal(environment.Configuration, &cfg); err != nil || cfg.Type != kind || cfg.WorkspaceDirectory == "" { + return v1.SessionEnvironment{}, errors.New("unsupported stored session environment configuration") + } + if cfg.CapabilityDirectories == nil { + cfg.CapabilityDirectories = []string{} + } + return v1.SessionEnvironment{ + Type: kind, ID: environment.ID, CapabilityDirectories: &cfg.CapabilityDirectories, + RemoteURL: executorURL, WorkspaceDirectory: cfg.WorkspaceDirectory, + }, nil +} diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go new file mode 100644 index 000000000..3c0668a01 --- /dev/null +++ b/services/agents-api/internal/api/session_template.go @@ -0,0 +1,71 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Validate the reference and inline shape without looking up mutable resources. +// Caller intent remains available before any reference is resolved. +func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, json.RawMessage, error) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil, "", nil, store.ErrInvalidInput + } + reference, supplied := fields["environment_template_id"] + if !supplied { + environment, err := decodeSessionEnvironment(raw) + return environment, "", nil, err + } + var id, kind string + if json.Unmarshal(reference, &id) != nil || id == "" || json.Unmarshal(fields["type"], &kind) != nil || kind != "openai_hosted" { + return nil, "", nil, store.ErrInvalidInput + } + // Explicit null override semantics are unconfirmed; do not guess inheritance. + if value, exists := fields["network"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, "", nil, store.ErrInvalidInput + } + for _, name := range []string{"files", "env", "setup_commands", "packages", "skills"} { + if _, supplied := fields[name]; supplied { + return nil, "", nil, store.ErrInvalidInput + } + } + delete(fields, "environment_template_id") + inline, err := json.Marshal(fields) + if err != nil { + return nil, "", nil, err + } + environment, err := decodeHostedEnvironment(inline) + return environment, id, raw, err +} + +func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, input *sessionRequest) error { + if input.templateID == "" { + return nil + } + template, files, err := h.store.ResolveEnvironmentTemplate(ctx, tenant, input.templateID) + if err != nil { + return err + } + var fields map[string]json.RawMessage + if json.Unmarshal(input.templateEnvironment, &fields) != nil { + return store.ErrInvalidInput + } + if _, supplied := fields["network"]; !supplied { + input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess} + } else if template.NetworkAccess == "disabled" && input.Environment.Network.Access != "disabled" { + return store.ErrInvalidInput + } + input.initialization = template.Initialization + input.Environment.Skills = skillResponse(template.Skills) + packages := template.Initialization.PackageMetadata() + input.Environment.Packages = &packages + input.initialFiles = files + input.Environment.Files = initialFileResponse(files) + // Runtime sees only the effective ordinary hosted configuration. + return nil +} diff --git a/services/agents-api/internal/api/session_tools.go b/services/agents-api/internal/api/session_tools.go new file mode 100644 index 000000000..9f33f6d09 --- /dev/null +++ b/services/agents-api/internal/api/session_tools.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, len(input)) + functions := make([]v1.FunctionToolInput, 0, len(input)) + positions := make([]int, 0, len(input)) + servers := map[string]bool{} + for i, raw := range input { + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &kind) != nil { + return nil, errors.New("Invalid execution tool configuration.") + } + switch kind.Type { + case "mcp": + resolved, err := resolveMCPTool(raw, false) + if err != nil { + return nil, err + } + var tool v1.MCPTool + if json.Unmarshal(resolved, &tool) != nil || servers[tool.ServerLabel] { + return nil, errors.New("Execution requires distinct MCP server labels.") + } + servers[tool.ServerLabel] = true + tools[i] = resolved + case "function": + var function v1.FunctionToolInput + if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { + return nil, errors.New("Invalid execution function fields.") + } + functions = append(functions, function) + positions = append(positions, i) + default: + return nil, errors.New("Execution currently supports non-deferred functions and the service-origin HTTP MCP profile only.") + } + } + resolved, err := resolveFunctions(functions) + if err != nil { + return nil, err + } + for i, value := range resolved { + tools[positions[i]] = value + } + return tools, nil +} diff --git a/services/agents-api/internal/api/source_files.go b/services/agents-api/internal/api/source_files.go new file mode 100644 index 000000000..e63248981 --- /dev/null +++ b/services/agents-api/internal/api/source_files.go @@ -0,0 +1,95 @@ +package api + +import ( + "context" + "io" + "net/http" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type SourceFileStore interface { + CreateSourceFile(context.Context, string, func(io.Writer) (store.SourceFileUpload, error)) (store.SourceFile, error) + GetSourceFile(context.Context, string, string) (store.SourceFile, error) + ListSourceFiles(context.Context, string, string, int, bool, *string) (store.SourceFilePage, error) + ReadSourceFile(context.Context, string, string, func(store.SourceFile, io.Reader) error) error + DeleteSourceFile(context.Context, string, string) error +} + +func WithSourceFiles(s SourceFileStore) Option { + return func(h *Handler) { h.sourceFiles = s } +} + +func (h *Handler) sourceFilesAvailable(w http.ResponseWriter) bool { + if h.sourceFiles == nil { + writeError(w, http.StatusServiceUnavailable, "file_storage_unavailable", "Source file storage is unavailable.") + return false + } + return true +} + +func (h *Handler) sourceFilesReady(w http.ResponseWriter, r *http.Request) bool { + if !h.sourceFilesAvailable(w) { + return false + } + if r.URL.RawQuery != "" { + writeStoreError(w, r, store.ErrInvalidInput) + return false + } + return true +} + +// @Summary Retrieve source file metadata +// @Description Returns immutable project-owned user_data file metadata. No Beta header is required. Other purposes, expiration and full hosted status/error semantics remain unimplemented or unverified. +// @Tags Files +// @Produce json +// @Security BearerAuth +// @Param file_id path string true "Source file ID" +// @Success 200 {object} v1.SourceFile +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files/{file_id} [get] +func (h *Handler) getSourceFile(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + file, err := h.sourceFiles.GetSourceFile(ctx, tenantID(r), chi.URLParam(r, "file_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, sourceFileResponse(file)) +} + +// @Summary Delete a source file +// @Description Atomically deletes project-owned metadata and stored bytes. Already-admitted reads or copies may finish. Workspace copies remain independent. Historical WAL/backups are not erased. No Beta header is required; exact hosted concurrent deletion/error semantics remain unverified. +// @Tags Files +// @Produce json +// @Security BearerAuth +// @Param file_id path string true "Source file ID" +// @Success 200 {object} v1.SourceFileDeleted +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files/{file_id} [delete] +func (h *Handler) deleteSourceFile(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + id := chi.URLParam(r, "file_id") + if err := h.sourceFiles.DeleteSourceFile(ctx, tenantID(r), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SourceFileDeleted{ID: id, Object: "file", Deleted: true}) +} + +func sourceFileResponse(file store.SourceFile) v1.SourceFile { + return v1.SourceFile{ID: file.ID, Object: "file", Bytes: file.SizeBytes, + CreatedAt: file.CreatedAt.Unix(), Filename: file.Filename, + Purpose: file.Purpose, Status: "processed"} +} diff --git a/services/agents-api/internal/api/source_files_content.go b/services/agents-api/internal/api/source_files_content.go new file mode 100644 index 000000000..5848c44cd --- /dev/null +++ b/services/agents-api/internal/api/source_files_content.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "io" + "mime" + "net/http" + "strconv" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Download source file bytes +// @Description Streams an authorized immutable source snapshot. Already-admitted reads may finish after deletion; later reads reject. No Beta header is required. Range requests and exact hosted headers/error behavior are not implemented or verified. +// @Tags Files +// @Produce octet-stream +// @Security BearerAuth +// @Param file_id path string true "Source file ID" +// @Success 200 {file} binary +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files/{file_id}/content [get] +func (h *Handler) sourceFileContent(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesReady(w, r) { + return + } + serveStoredContent(w, r, func(ctx context.Context, consume func(string, int64, io.Reader) error) error { + return h.sourceFiles.ReadSourceFile(ctx, tenantID(r), chi.URLParam(r, "file_id"), func(file store.SourceFile, body io.Reader) error { + return consume(file.Filename, file.SizeBytes, body) + }) + }) +} + +func serveStoredContent(w http.ResponseWriter, r *http.Request, read func(context.Context, func(string, int64, io.Reader) error) error) { + deadline := time.Now().Add(sourceTransferTimeout) + if http.NewResponseController(w).SetWriteDeadline(deadline) != nil { + writeError(w, http.StatusServiceUnavailable, "file_transfer_unavailable", "Bounded file transfer is unavailable.") + return + } + ctx, cancel := context.WithDeadline(r.Context(), deadline) + defer cancel() + started := false + err := read(ctx, func(filename string, size int64, body io.Reader) error { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + started = true + w.WriteHeader(http.StatusOK) + n, err := io.CopyBuffer(w, body, make([]byte, 256<<10)) + if err == nil && n != size { + err = io.ErrUnexpectedEOF + } + return err + }) + if err != nil { + if started { + panic(http.ErrAbortHandler) + } + writeStoreError(w, r, err) + } +} diff --git a/services/agents-api/internal/api/source_files_list.go b/services/agents-api/internal/api/source_files_list.go new file mode 100644 index 000000000..d29365089 --- /dev/null +++ b/services/agents-api/internal/api/source_files_list.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary List source files +// @Description Lists project-owned Files without reading their bodies. Supports the pinned after, limit, order and purpose query surface. The limit defaults to 10000 and must be 1–10000. Equal creation times use ID ordering. Current storage contains only user_data; exact hosted default order, invalid-cursor errors and concurrent-page behavior remain unverified. No Beta header is required. +// @Tags Files +// @Produce json +// @Security BearerAuth +// @Param after query string false "Last File ID from the previous page" +// @Param limit query integer false "Maximum page size, 1–10000" default(10000) minimum(1) maximum(10000) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param purpose query string false "Only return Files with this purpose" +// @Success 200 {object} v1.SourceFileList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files [get] +func (h *Handler) listSourceFiles(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesAvailable(w) { + return + } + options, purpose, ok := readSourceFilePage(w, r) + if !ok { + return + } + page, err := h.sourceFiles.ListSourceFiles(r.Context(), tenantID(r), options.after, options.limit, options.ascending, purpose) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.SourceFileList{Object: "list", Data: make([]v1.SourceFile, 0, len(page.Files)), HasMore: page.NextCursor != ""} + for _, file := range page.Files { + response.Data = append(response.Data, sourceFileResponse(file)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} + +func readSourceFilePage(w http.ResponseWriter, r *http.Request) (pageOptions, *string, bool) { + q := r.URL.Query() + options, ok := readPageQueryLimits(w, q, 10000, 10000, true, "purpose") + if !ok { + return pageOptions{}, nil, false + } + values, present := q["purpose"] + if !present { + return options, nil, true + } + return options, &values[0], true +} diff --git a/services/agents-api/internal/api/source_files_list_test.go b/services/agents-api/internal/api/source_files_list_test.go new file mode 100644 index 000000000..e95a216fc --- /dev/null +++ b/services/agents-api/internal/api/source_files_list_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestSourceFileListParametersAndEnvelope(t *testing.T) { + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := newSourceFileServer(t, h) + + status, raw := sourceRequest(t, server, http.MethodGet, "/v1/files", "files-key", "", nil) + var empty map[string]any + if status != http.StatusOK || json.Unmarshal(raw, &empty) != nil { + t.Fatalf("default list: %d %s", status, raw) + } + wantEmpty := map[string]any{"object": "list", "data": []any{}, "has_more": false, "first_id": nil, "last_id": nil} + if !reflect.DeepEqual(empty, wantEmpty) || f.listCalls != 1 || f.listAfter != "" || f.listLimit != 10000 || f.listAsc || f.listPurpose != nil { + t.Fatalf("default list changed: %s calls=%d after=%q limit=%d asc=%t purpose=%v", raw, f.listCalls, f.listAfter, f.listLimit, f.listAsc, f.listPurpose) + } + + file := store.SourceFile{ID: "file-00000000-0000-0000-0000-000000000001", Filename: "one.bin", Purpose: "user_data", SizeBytes: 3, CreatedAt: time.Unix(123, 0)} + f.listPage = store.SourceFilePage{Files: []store.SourceFile{file}, NextCursor: file.ID} + status, raw = sourceRequest(t, server, http.MethodGet, "/v1/files?after="+file.ID+"&limit=7&order=asc&purpose=user_data", "files-key", "", nil) + var page v1.SourceFileList + if status != http.StatusOK || json.Unmarshal(raw, &page) != nil { + t.Fatalf("parameterized list: %d %s", status, raw) + } + if !reflect.DeepEqual(page.Data, []v1.SourceFile{sourceFileResponse(file)}) || !page.HasMore || page.FirstID == nil || *page.FirstID != file.ID || page.LastID == nil || *page.LastID != file.ID { + t.Fatalf("page projection changed: %+v", page) + } + if f.listAfter != file.ID || f.listLimit != 7 || !f.listAsc || f.listPurpose == nil || *f.listPurpose != "user_data" { + t.Fatalf("parameters changed: after=%q limit=%d asc=%t purpose=%v", f.listAfter, f.listLimit, f.listAsc, f.listPurpose) + } +} + +func TestSourceFileListRejectsInvalidQueriesBeforeStorage(t *testing.T) { + for _, query := range []string{ + "limit=", "limit=0", "limit=10001", "limit=1.5", "limit=1&limit=2", + "order=invalid", "after=a&after=b", "purpose=a&purpose=b", "tenant_id=foreign", + } { + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := newSourceFileServer(t, h) + status, _ := sourceRequest(t, server, http.MethodGet, "/v1/files?"+query, "files-key", "", nil) + if status != http.StatusBadRequest || f.listCalls != 0 { + t.Fatalf("invalid %q: status=%d calls=%d", query, status, f.listCalls) + } + } +} + +func TestSourceFileListMapsStorageErrors(t *testing.T) { + f := &sourceFilesFixture{listErr: store.ErrNotFound} + h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := newSourceFileServer(t, h) + status, _ := sourceRequest(t, server, http.MethodGet, "/v1/files?after=file-missing", "files-key", "", nil) + if status != http.StatusNotFound || f.listCalls != 1 { + t.Fatalf("storage error: status=%d calls=%d", status, f.listCalls) + } +} + +func newSourceFileServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} diff --git a/services/agents-api/internal/api/source_files_test.go b/services/agents-api/internal/api/source_files_test.go new file mode 100644 index 000000000..f6555a49a --- /dev/null +++ b/services/agents-api/internal/api/source_files_test.go @@ -0,0 +1,243 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type sourceFilesFixture struct { + mu sync.Mutex + tenant string + file store.SourceFile + data []byte + reads int + listPage store.SourceFilePage + listErr error + listCalls int + listAfter string + listLimit int + listAsc bool + listPurpose *string +} + +func (f *sourceFilesFixture) CreateSourceFile(_ context.Context, tenant string, upload func(io.Writer) (store.SourceFileUpload, error)) (store.SourceFile, error) { + var body bytes.Buffer + input, err := upload(&body) + if err != nil { + return store.SourceFile{}, err + } + f.mu.Lock() + defer f.mu.Unlock() + f.tenant, f.data = tenant, body.Bytes() + f.file = store.SourceFile{ID: "file-" + uuid.NewString(), Filename: input.Filename, Purpose: input.Purpose, SizeBytes: int64(body.Len()), CreatedAt: time.Unix(123, 0)} + return f.file, nil +} + +func (f *sourceFilesFixture) GetSourceFile(_ context.Context, tenant, id string) (store.SourceFile, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.file.ID != id || f.tenant != tenant { + return store.SourceFile{}, store.ErrNotFound + } + return f.file, nil +} + +func (f *sourceFilesFixture) ListSourceFiles(_ context.Context, tenant, after string, limit int, ascending bool, purpose *string) (store.SourceFilePage, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tenant, f.listAfter, f.listLimit, f.listAsc, f.listPurpose = tenant, after, limit, ascending, purpose + f.listCalls++ + return f.listPage, f.listErr +} + +func (f *sourceFilesFixture) ReadSourceFile(ctx context.Context, tenant, id string, consume func(store.SourceFile, io.Reader) error) error { + file, err := f.GetSourceFile(ctx, tenant, id) + if err != nil { + return err + } + f.mu.Lock() + f.reads++ + data := bytes.Clone(f.data) + f.mu.Unlock() + return consume(file, bytes.NewReader(data)) +} + +func (f *sourceFilesFixture) DeleteSourceFile(ctx context.Context, tenant, id string) error { + if _, err := f.GetSourceFile(ctx, tenant, id); err != nil { + return err + } + f.mu.Lock() + defer f.mu.Unlock() + f.file = store.SourceFile{} + f.data = nil + return nil +} + +func sourceMultipart(t *testing.T, fields []string, data []byte) ([]byte, string) { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + for _, field := range fields { + if field == "file" { + part, err := w.CreateFormFile("file", "source.bin") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + } else { + name, value, _ := strings.Cut(field, "=") + if err := w.WriteField(name, value); err != nil { + t.Fatal(err) + } + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return body.Bytes(), w.FormDataContentType() +} + +func sourceRequest(t *testing.T, server *httptest.Server, method, path, key, contentType string, body []byte) (int, []byte) { + t.Helper() + r, err := http.NewRequestWithContext(t.Context(), method, server.URL+path, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + r.Header.Set("Authorization", "Bearer "+key) + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + resp, err := server.Client().Do(r) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, data +} + +func TestSourceFilesPublicLifecycleAndEnvironmentCopy(t *testing.T) { + for _, fields := range [][]string{{"file", "purpose=user_data"}, {"purpose=user_data", "file"}} { + f := &sourceFilesFixture{} + h, env := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := httptest.NewServer(h) + t.Cleanup(server.Close) + data := []byte{0, 1, 255, 7} + body, contentType := sourceMultipart(t, fields, data) + status, raw := sourceRequest(t, server, "POST", "/v1/files", "files-key", contentType, body) + var file v1.SourceFile + if status != 200 || json.Unmarshal(raw, &file) != nil || file.Bytes != int64(len(data)) || file.Object != "file" || file.Purpose != "user_data" || file.Status != "processed" || file.ExpiresAt != nil { + t.Fatalf("upload: %d %s", status, raw) + } + for _, path := range []string{"/v1/files/" + file.ID, "/v1/files/" + file.ID + "/content"} { + if status, _ := sourceRequest(t, server, "GET", path, "other-key", "", nil); status != 404 { + t.Fatalf("foreign source: %d", status) + } + } + if status, got := sourceRequest(t, server, "GET", "/v1/files/"+file.ID+"/content", "files-key", "", nil); status != 200 || !bytes.Equal(got, data) { + t.Fatalf("content: %d %v", status, got) + } + copyBody := `{"type":"file_id","file_id":"` + file.ID + `","path":"/workspace/source.bin"}` + if status, _ := sourceRequest(t, server, "POST", "/v1/agents/environments/"+env.environment.ID+"/files", "files-key", "application/json", []byte(copyBody)); status != 400 { + t.Fatal("Agents Beta requirement changed", status) + } + if got := requestCreateEnvironmentFile(h, env.environment.ID, copyBody, "files-key"); got.Code != 200 || !bytes.Equal(env.data, data) || env.writes != 1 { + t.Fatalf("copy: %d %s", got.Code, got.Body) + } + if status, _ := sourceRequest(t, server, "DELETE", "/v1/files/"+file.ID, "other-key", "", nil); status != 404 { + t.Fatal("foreign deletion allowed") + } + status, raw = sourceRequest(t, server, "DELETE", "/v1/files/"+file.ID, "files-key", "", nil) + var deleted v1.SourceFileDeleted + if status != 200 || json.Unmarshal(raw, &deleted) != nil || !deleted.Deleted || deleted.ID != file.ID || deleted.Object != "file" { + t.Fatalf("delete: %d %s", status, raw) + } + if got := requestCreateEnvironmentFile(h, env.environment.ID, copyBody, "files-key"); got.Code != 404 || env.writes != 1 || !bytes.Equal(env.data, data) { + t.Fatal("deleted source reused or prior copy changed", got.Code) + } + } +} + +func TestSourceFilesRejectIncompleteOrUnsupportedMultipart(t *testing.T) { + for _, fields := range [][]string{{"file"}, {"purpose=user_data"}, {"file", "file", "purpose=user_data"}, {"file", "purpose=user_data", "purpose=user_data"}, {"file", "purpose=batch"}, {"file", "purpose=user_data", "expires_after[seconds]=3600"}, {"file", "purpose=user_data", "extra=x"}} { + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := httptest.NewServer(h) + body, contentType := sourceMultipart(t, fields, []byte("discard")) + status, _ := sourceRequest(t, server, "POST", "/v1/files", "files-key", contentType, body) + server.Close() + if status != 400 || f.file.ID != "" { + t.Fatalf("invalid multipart accepted %v: %d", fields, status) + } + } + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, WithSourceFiles(f)) + server := httptest.NewServer(h) + defer server.Close() + body, contentType := sourceMultipart(t, []string{"purpose=user_data", "file"}, []byte("truncated")) + if status, _ := sourceRequest(t, server, "POST", "/v1/files", "files-key", contentType, body[:len(body)-20]); status != 400 || f.file.ID != "" { + t.Fatal("truncated body committed", status) + } + if status, _ := sourceRequest(t, server, "POST", "/v1/files", "invalid", contentType, body); status != 401 || f.file.ID != "" { + t.Fatal("unauthenticated upload admitted", status) + } +} + +func TestEnvironmentSourceCopyEnforcesScopeUnionAndSize(t *testing.T) { + f := &sourceFilesFixture{file: store.SourceFile{ID: "file-" + uuid.NewString(), SizeBytes: proto.WorkspaceWriteMaxBytes + 1}} + h, env := environmentFileCreateHandler(t, WithSourceFiles(f)) + f.tenant = env.environment.TenantID + body := `{"type":"file_id","file_id":"` + f.file.ID + `","path":"/workspace/source.bin"}` + if got := requestCreateEnvironmentFile(h, env.environment.ID, body, "other-key"); got.Code != 404 || f.reads != 0 { + t.Fatal("source read before Environment authority") + } + if got := requestCreateEnvironmentFile(h, env.environment.ID, body, "files-key"); got.Code != 413 || env.writes != 0 { + t.Fatal("oversized source dispatched", got.Code) + } + for _, extra := range []string{`,"data":null`, `,"data":""`} { + invalid := strings.TrimSuffix(body, "}") + extra + "}" + if got := requestCreateEnvironmentFile(h, env.environment.ID, invalid, "files-key"); got.Code != 400 || env.writes != 0 { + t.Fatal("mixed union admitted", got.Code) + } + } +} + +func TestSourceContentDoesNotCompleteTruncatedResponse(t *testing.T) { + f := &sourceFilesFixture{file: store.SourceFile{ID: "file-" + uuid.NewString(), Filename: "source.bin", SizeBytes: 3}, data: []byte("x")} + h, env := environmentFileCreateHandler(t, WithSourceFiles(f)) + f.tenant = env.environment.TenantID + server := httptest.NewServer(h) + defer server.Close() + r, err := http.NewRequestWithContext(t.Context(), "GET", server.URL+"/v1/files/"+f.file.ID+"/content", nil) + if err != nil { + t.Fatal(err) + } + r.Header.Set("Authorization", "Bearer files-key") + resp, err := server.Client().Do(r) + if err != nil { + return + } + defer resp.Body.Close() + if _, err := io.ReadAll(resp.Body); err == nil { + t.Fatal("truncated content completed as a successful response") + } +} diff --git a/services/agents-api/internal/api/source_files_upload.go b/services/agents-api/internal/api/source_files_upload.go new file mode 100644 index 000000000..153336ab1 --- /dev/null +++ b/services/agents-api/internal/api/source_files_upload.go @@ -0,0 +1,109 @@ +package api + +import ( + "context" + "errors" + "io" + "mime" + "net/http" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +const sourceTransferTimeout = 5 * time.Minute + +// @Summary Upload a source file +// @Description Accepts one multipart file and purpose=user_data in either order, with a private 512 MiB content limit and 64 KiB envelope allowance. Commits only after the entire request validates. The source is project-owned, independent of Sessions and workspace copies. No Beta header is required. Other purposes, expires_after, listing, resumable Uploads, quotas/rate-limit and complete hosted error/status parity remain unsupported or unverified. +// @Tags Files +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param file formData file true "Source bytes" +// @Param purpose formData string true "user_data" Enums(user_data) +// @Success 200 {object} v1.SourceFile +// @Failure 400,401,413,500,503 {object} v1.ErrorResponse +// @Router /files [post] +func (h *Handler) createSourceFile(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesReady(w, r) { + return + } + deadline := time.Now().Add(sourceTransferTimeout) + controller := http.NewResponseController(w) + if controller.SetReadDeadline(deadline) != nil || controller.SetWriteDeadline(deadline) != nil { + writeError(w, http.StatusServiceUnavailable, "file_transfer_unavailable", "Bounded file transfer is unavailable.") + return + } + ctx, cancel := context.WithDeadline(r.Context(), deadline) + defer cancel() + r.Body = http.MaxBytesReader(w, r.Body, store.MaxSourceFileBytes+(64<<10)) + file, err := h.sourceFiles.CreateSourceFile(ctx, tenantID(r), func(dst io.Writer) (store.SourceFileUpload, error) { + return readSourceUpload(r, dst) + }) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, sourceFileResponse(file)) +} + +func readSourceUpload(r *http.Request, dst io.Writer) (store.SourceFileUpload, error) { + var input store.SourceFileUpload + if r.Header.Get("Content-Encoding") != "" { + return input, store.ErrInvalidInput + } + multi, err := r.MultipartReader() + if err != nil { + return input, store.ErrInvalidInput + } + seen := make(map[string]bool, 2) + buffer := make([]byte, 256<<10) + for { + part, err := multi.NextRawPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return input, sourceUploadError(err) + } + kind, attrs, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")) + name := attrs["name"] + if err != nil || kind != "form-data" || seen[name] || (name != "file" && name != "purpose") || part.Header.Get("Content-Transfer-Encoding") != "" { + return input, store.ErrInvalidInput + } + seen[name] = true + if name == "file" { + input.Filename = attrs["filename"] + if _, err := io.CopyBuffer(dst, part, buffer); err != nil { + return input, sourceUploadError(err) + } + } else { + if _, exists := attrs["filename"]; exists { + return input, store.ErrInvalidInput + } + value, err := io.ReadAll(io.LimitReader(part, 65)) + if err != nil || len(value) > 64 { + return input, store.ErrInvalidInput + } + input.Purpose = string(value) + } + if err := part.Close(); err != nil { + return input, sourceUploadError(err) + } + } + if _, err := io.CopyBuffer(io.Discard, r.Body, buffer); err != nil { + return input, sourceUploadError(err) + } + if !seen["file"] || !seen["purpose"] || input.Purpose != "user_data" { + return input, store.ErrInvalidInput + } + return input, nil +} + +func sourceUploadError(err error) error { + var limit *http.MaxBytesError + if errors.As(err, &limit) || errors.Is(err, store.ErrSourceFileTooLarge) { + return store.ErrSourceFileTooLarge + } + return store.ErrInvalidInput +} diff --git a/services/agents-api/internal/api/stream.go b/services/agents-api/internal/api/stream.go new file mode 100644 index 000000000..3817a814b --- /dev/null +++ b/services/agents-api/internal/api/stream.go @@ -0,0 +1,155 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type eventStore interface { + SessionEventCursor(context.Context, string, string) (int64, error) + ListSessionEvents(context.Context, string, string, int64) ([]store.SessionChange, error) +} + +// @Summary Stream live Session events +// @Description Live-only events, including command output fragments from capable Codex peers as agent.output.command_execution_output.delta with stable Item/output indexes. Native text conversion and output quotas apply; completion snapshots remain authoritative. Reconnect through Session, Turn and Items reads; missed events are not replayed. A lagging stream closes with an error when its bounded buffer is exceeded. Session activity includes immutable pending-input connection actions before Turn creation; self_hosted environments use the same safe output as Session retrieval. +// @Tags Events +// @Produce text/event-stream +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Success 200 {object} v1.SessionEvent +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/events [get] +func (h *Handler) streamEvents(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Live streams do not accept query parameters.") + return + } + events, ok := h.store.(eventStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") + return + } + id, tenant := chi.URLParam(r, "session_id"), tenantID(r) + session, err := h.store.GetSession(r.Context(), tenant, id) + if err != nil { + writeStoreError(w, r, err) + return + } + if _, err = sessionResponse(session, h.executorURL); err != nil { + writeStoreError(w, r, err) + return + } + cursor, err := events.SessionEventCursor(r.Context(), tenant, id) + if err != nil { + writeStoreError(w, r, err) + return + } + h.serveSessionEvents(w, r, events, session, cursor, nil) +} + +func (h *Handler) serveSessionEvents(w http.ResponseWriter, r *http.Request, events eventStore, session store.Session, cursor int64, initial *v1.SessionEvent) { + id, tenant := session.ID, tenantID(r) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + controller := http.NewResponseController(w) + write := func(data []byte) error { + if err := controller.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { + return err + } + if _, err := w.Write(data); err != nil { + return err + } + return controller.Flush() + } + if err := write([]byte(": connected\n\n")); err != nil { + return + } + emit := func(event v1.SessionEvent) error { + payload, err := json.Marshal(event) + if err != nil { + writeStreamFailure(write, id) + return err + } + return write([]byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event.Type, payload))) + } + if initial != nil { + if err := emit(*initial); err != nil { + return + } + } + poll := time.NewTicker(100 * time.Millisecond) + defer poll.Stop() + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + changes, err := events.ListSessionEvents(r.Context(), tenant, id, cursor) + if errors.Is(err, store.ErrNotFound) { + return + } + if err != nil { + writeStreamFailure(write, id) + return + } + for _, change := range changes { + event, err := streamResponse(session, change, h.executorURL) + if err != nil { + writeStreamFailure(write, id) + return + } + if err := emit(event); err != nil { + return + } + cursor = change.Sequence + } + if len(changes) > 0 { + continue + } + select { + case <-r.Context().Done(): + return + case <-poll.C: + case <-heartbeat.C: + if err := write([]byte(": keepalive\n\n")); err != nil { + return + } + } + } +} + +func streamResponse(session store.Session, change store.SessionChange, executorURL string) (v1.SessionEvent, error) { + event := change.Event + if change.Turn == nil && change.EnvironmentInputActivity == nil { + return event, nil + } + if change.Turn != nil && strings.HasPrefix(event.Type, "agent.session.turn.") { + turn, err := turnResponse(session, *change.Turn) + event.Turn = &turn + return event, err + } + event.SessionID = "" + session.RequiredActions = change.RequiredActions + session.LastTurn, session.Usage = change.Turn, change.SessionUsage + session.EnvironmentInputActivity = change.EnvironmentInputActivity + value, err := sessionResponse(session, executorURL) + event.Session = &value + return event, err +} + +func writeStreamFailure(write func([]byte) error, session string) { + event := v1.SessionEvent{Type: "error", EventID: uuid.NewString(), SessionID: session, + Error: &v1.StreamError{Code: "stream_interrupted", Type: "server_error", Message: "The live stream was interrupted. Reconnect and retrieve the Session and its saved Items to recover."}} + payload, _ := json.Marshal(event) + _ = write([]byte(fmt.Sprintf("event: error\ndata: %s\n\n", payload))) +} diff --git a/services/agents-api/internal/api/stream_test.go b/services/agents-api/internal/api/stream_test.go new file mode 100644 index 000000000..8f3338b2a --- /dev/null +++ b/services/agents-api/internal/api/stream_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type streamFixture struct { + ResourceStore + session store.Session + mu sync.Mutex + changes []store.SessionChange + gap bool + cursors []int64 +} + +func (f *streamFixture) GetSession(_ context.Context, tenant, id string) (store.Session, error) { + if tenant != f.session.TenantID || id != f.session.ID { + return store.Session{}, store.ErrNotFound + } + return f.session, nil +} + +func (f *streamFixture) SessionEventCursor(context.Context, string, string) (int64, error) { + return 10, nil +} + +func (f *streamFixture) ListSessionEvents(_ context.Context, _, _ string, cursor int64) ([]store.SessionChange, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.cursors = append(f.cursors, cursor) + if f.gap { + return nil, store.ErrStreamGap + } + changes := f.changes + f.changes = nil + return changes, nil +} + +func TestLiveStreamAuthDisconnectRecoveryAndServerDeadline(t *testing.T) { + f := &streamFixture{session: store.Session{ID: uuid.NewString(), TenantID: uuid.NewString(), CreatedAt: time.Now(), Metadata: map[string]string{}, + Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"none"}}`)}} + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential("key"), TenantID: f.session.TenantID}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential("foreign"), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(f, auth, "codex") + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}, 8) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { done <- struct{}{} }() + h.ServeHTTP(w, r) + })) + server.Config.WriteTimeout = 50 * time.Millisecond + server.Start() + defer server.Close() + request := func(key string) *http.Response { + t.Helper() + r, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/agents/sessions/"+f.session.ID+"/events", nil) + r.Header.Set("Authorization", "Bearer "+key) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Last-Event-ID", "old-history") + response, err := server.Client().Do(r) + if err != nil { + t.Fatal(err) + } + return response + } + for _, pair := range []struct { + key string + status int + }{{"invalid", 401}, {"foreign", 404}} { + response := request(pair.key) + _ = response.Body.Close() + if response.StatusCode != pair.status || response.Header.Get("Content-Type") == "text/event-stream" { + t.Fatal("authentication happened after stream headers", response.StatusCode) + } + <-done + } + response := request("key") + reader := bufio.NewReader(response.Body) + if line, err := reader.ReadString('\n'); err != nil || line != ": connected\n" { + t.Fatal(line, err) + } + time.Sleep(100 * time.Millisecond) + f.mu.Lock() + f.gap = true + f.mu.Unlock() + rest, err := io.ReadAll(reader) + _ = response.Body.Close() + if err != nil || !strings.Contains(string(rest), `"code":"stream_interrupted"`) || !strings.Contains(string(rest), "event: error") { + t.Fatal("deadline or gap handling failed", string(rest), err) + } + <-done + f.mu.Lock() + f.gap = false + cursors := append([]int64(nil), f.cursors...) + f.mu.Unlock() + if len(cursors) == 0 || cursors[0] != 10 { + t.Fatal("stream replayed history", cursors) + } + response = request("key") + _ = response.Body.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("disconnect did not release the handler") + } + + // An unread large event must hit a write deadline instead of retaining a handler indefinitely. + response = request("key") + f.mu.Lock() + text := strings.Repeat("x", 16*1024*1024) + f.changes = []store.SessionChange{{Sequence: 11, Event: v1.SessionEvent{Type: "agent.session.turn.output_text.delta", EventID: "large", SessionID: f.session.ID, Delta: &text}}} + f.mu.Unlock() + select { + case <-done: + case <-time.After(8 * time.Second): + t.Error("slow reader retained the handler") + } + _ = response.Body.Close() +} diff --git a/services/agents-api/internal/api/text_configuration.go b/services/agents-api/internal/api/text_configuration.go new file mode 100644 index 000000000..b7fac7b6c --- /dev/null +++ b/services/agents-api/internal/api/text_configuration.go @@ -0,0 +1,25 @@ +package api + +import ( + "errors" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func resolveText(input *v1.TextConfigInput) (v1.TextConfig, error) { + text := v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: "medium"} + if input == nil { + return text, nil + } + if input.Format != nil && input.Format.Type != "text" { + return text, errors.New("This service currently supports text.format.type=text only.") + } + if input.Verbosity != nil { + switch *input.Verbosity { + case "low", "medium", "high": + text.Verbosity = *input.Verbosity + default: + return text, errors.New("text.verbosity must be low, medium or high.") + } + } + return text, nil +} diff --git a/services/agents-api/internal/api/text_configuration_test.go b/services/agents-api/internal/api/text_configuration_test.go new file mode 100644 index 000000000..ad396026d --- /dev/null +++ b/services/agents-api/internal/api/text_configuration_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestTextConfigurationHTTP(t *testing.T) { + for _, tc := range []struct{ text, want string }{ + {``, "medium"}, {`,"text":null`, "medium"}, {`,"text":{}`, "medium"}, + {`,"text":{"verbosity":null,"format":null}`, "medium"}, + {`,"text":{"verbosity":"low"}`, "low"}, {`,"text":{"verbosity":"medium"}`, "medium"}, + {`,"text":{"verbosity":"high","format":{"type":"text"}}`, "high"}, + } { + t.Run(tc.text, func(t *testing.T) { + h, s, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example"`+tc.text+`},"environment":{"type":"none"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + if response.Code != 200 { + t.Fatal(response.Code, response.Body) + } + var got v1.Session + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + var saved configuration + if err := json.Unmarshal(s.input.Configuration, &saved); err != nil { + t.Fatal(err) + } + want := v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: tc.want} + if got.Agent.Text != want || saved.Agent.Text != want { + t.Fatal(got.Agent.Text, saved.Agent.Text) + } + }) + } + for _, invalid := range []string{`{"verbosity":""}`, `{"verbosity":"verbose"}`, `{"verbosity":4}`, `{"format":{}}`, `{"format":{"type":"json_schema","schema":{}}}`, `{"format":{"type":"text","extra":true}}`, `{"unknown":true}`} { + h, s, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example","text":`+invalid+`},"environment":{"type":"none"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + if response.Code != 400 || s.tenant != "" { + t.Fatalf("accepted invalid config %s: %d", invalid, response.Code) + } + } +} diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go new file mode 100644 index 000000000..5b27c59e7 --- /dev/null +++ b/services/agents-api/internal/api/turns.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Retrieve an execution Turn +// @Tags Turns +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param turn_id path string true "Turn ID" +// @Success 200 {object} v1.Turn +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/turns/{turn_id} [get] +func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Turn retrieval does not accept query parameters.") + return + } + sessionID := chi.URLParam(r, "session_id") + turn, err := h.store.GetTurn(r.Context(), tenantID(r), sessionID, chi.URLParam(r, "turn_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := turnResponse(session, turn) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +// @Summary List execution Turns +// @Description Returns persisted state in creation order. The cursor belongs to the same Session and tenant. Usage contains the latest recorded complete token breakdown; missing measurements remain null. +// @Tags Turns +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param after query string false "Last Turn ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.TurnList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/turns [get] +func (h *Handler) listTurns(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok { + return + } + sessionID := chi.URLParam(r, "session_id") + session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + page, err := h.store.ListTurns(r.Context(), tenantID(r), sessionID, options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.TurnList{Data: make([]v1.Turn, 0, len(page.Turns)), HasMore: page.NextCursor != ""} + for _, turn := range page.Turns { + item, err := turnResponse(session, turn) + if err != nil { + writeStoreError(w, r, err) + return + } + response.Data = append(response.Data, item) + } + writeJSON(w, http.StatusOK, response) +} + +func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { + var cfg configuration + if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" { + return v1.Turn{}, errors.New("missing stored agent identity") + } + response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} + if turn.Status == store.TurnFailed { + // Native errors can contain secrets; publish a stable category without raw diagnostics. + response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} + } + return response, nil +} + +func unixTime(value time.Time) *int64 { + if value.IsZero() { + return nil + } + seconds := value.Unix() + return &seconds +} diff --git a/services/agents-api/internal/api/turns_test.go b/services/agents-api/internal/api/turns_test.go new file mode 100644 index 000000000..de4d6f5fc --- /dev/null +++ b/services/agents-api/internal/api/turns_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type turnReadStore struct { + ResourceStore + tenant, sessionID, turnID, cursor string + limit int + ascending bool + session store.Session + turn store.Turn +} + +func (s *turnReadStore) GetSession(_ context.Context, tenant, id string) (store.Session, error) { + s.tenant, s.sessionID = tenant, id + return s.session, nil +} +func (s *turnReadStore) GetTurn(_ context.Context, tenant, session, id string) (store.Turn, error) { + s.tenant, s.sessionID, s.turnID = tenant, session, id + return s.turn, nil +} +func (s *turnReadStore) ListTurns(_ context.Context, tenant, session, cursor string, limit int, asc bool) (store.TurnPage, error) { + s.tenant, s.sessionID, s.cursor, s.limit, s.ascending = tenant, session, cursor, limit, asc + return store.TurnPage{Turns: []store.Turn{s.turn}, NextCursor: s.turn.ID}, nil +} + +func TestTurnRoutesUseAuthenticatedScopeAndSafeProjection(t *testing.T) { + h, record, tenant := testHandler(t) + s := &turnReadStore{session: store.Session{Configuration: json.RawMessage(`{"agent":{"id":"agent_snapshot"}}`)}, turn: store.Turn{ID: "turn", SessionID: "session", Status: store.TurnFailed, CreatedAt: time.Unix(1700000000, 999), Outcome: json.RawMessage(`{"error":"Bearer SECRET","done":{"metadata":{"password":"SECRET"}}}`)}} + record.ResourceStore = s + request := func(path string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + w := request("/v1/agents/sessions/session/turns/turn") + if w.Code != 200 || s.tenant != tenant || s.sessionID != "session" || s.turnID != "turn" || strings.Contains(w.Body.String(), "SECRET") { + t.Fatalf("unsafe response: %d %s", w.Code, w.Body) + } + var got v1.Turn + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.AgentID != "agent_snapshot" || got.Error == nil || got.Error.Code != "internal_error" || got.CreatedAt != 1700000000 || got.StartedAt != nil || got.CompletedAt != nil || got.Usage != nil { + t.Fatalf("bad projection: %+v", got) + } + if w := request("/v1/agents/sessions/session/turns?after=last&limit=2&order=asc"); w.Code != 200 || s.cursor != "last" || s.limit != 2 || !s.ascending { + t.Fatalf("bad list: %d %s", w.Code, w.Body) + } + if w := request("/v1/agents/sessions/session/turns"); w.Code != 200 || s.limit != 20 || s.ascending { + t.Fatalf("bad defaults: %d", w.Code) + } + for _, query := range []string{"limit=0", "limit=101", "limit=2&limit=3", "order=random", "agent_id=other"} { + if w := request("/v1/agents/sessions/session/turns?" + query); w.Code != 400 { + t.Fatalf("accepted %s: %d", query, w.Code) + } + } + if w := request("/v1/agents/sessions/session/turns/turn?unknown=1"); w.Code != 400 { + t.Fatalf("accepted retrieve query: %d", w.Code) + } + s.session.Configuration = json.RawMessage(`{}`) + if w := request("/v1/agents/sessions/session/turns/turn"); w.Code != 500 || strings.Contains(w.Body.String(), "snapshot") { + t.Fatalf("missing identity: %d %s", w.Code, w.Body) + } +} + +func TestTurnProjectionPreservesLifecycle(t *testing.T) { + session := store.Session{Configuration: json.RawMessage(`{"agent":{"id":"agent_snapshot"}}`)} + for _, status := range []string{store.TurnQueued, store.TurnInProgress, store.TurnWaiting, store.TurnCompleted, store.TurnFailed, store.TurnCancelled} { + turn := store.Turn{Status: status, CreatedAt: time.Unix(1700000000, 0), StartedAt: time.Unix(1700000001, 0), CompletedAt: time.Unix(1700000002, 0)} + got, err := turnResponse(session, turn) + if err != nil || got.Status != status || *got.StartedAt != 1700000001 || *got.CompletedAt != 1700000002 || (got.Error != nil) != (status == store.TurnFailed) { + t.Fatalf("lifecycle %s: %+v %v", status, got, err) + } + } +} diff --git a/services/agents-api/internal/api/usage.go b/services/agents-api/internal/api/usage.go new file mode 100644 index 000000000..a016b69c8 --- /dev/null +++ b/services/agents-api/internal/api/usage.go @@ -0,0 +1,14 @@ +package api + +import ( + "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func tokenUsage(raw json.RawMessage) *v1.TokenUsage { + var usage *v1.TokenUsage + if json.Unmarshal(raw, &usage) != nil { + return nil + } + return usage +} diff --git a/services/agents-api/internal/api/vault_pagination.go b/services/agents-api/internal/api/vault_pagination.go new file mode 100644 index 000000000..9290e11e3 --- /dev/null +++ b/services/agents-api/internal/api/vault_pagination.go @@ -0,0 +1,39 @@ +package api + +import ( + "errors" + "net/http" + "strconv" +) + +func readVaultPage(w http.ResponseWriter, r *http.Request) (pageOptions, []string, bool) { + q := r.URL.Query() + statuses, scalar := q["status"] + array, bracketed := q["status[]"] + if (scalar && bracketed) || (scalar && len(statuses) != 1) { + writeError(w, http.StatusBadRequest, "invalid_request", "Supply status once or use status[] for an array.") + return pageOptions{}, nil, false + } + if bracketed { + statuses = array + } + for _, status := range statuses { + if status != "active" && status != "archived" { + writeError(w, http.StatusBadRequest, "invalid_request", "status must be active or archived.") + return pageOptions{}, nil, false + } + } + q.Del("status") + q.Del("status[]") + // Vault and Credential limits clamp at both ends; other lists retain their policy. + if raw := q["limit"]; len(raw) == 1 { + requested, err := strconv.ParseInt(raw[0], 10, 64) + if err != nil && !errors.Is(err, strconv.ErrRange) { + writeError(w, http.StatusBadRequest, "invalid_request", "limit must be an integer.") + return pageOptions{}, nil, false + } + q.Set("limit", strconv.FormatInt(max(1, min(requested, 100)), 10)) + } + options, ok := readPageQuery(w, q, false) + return options, statuses, ok +} diff --git a/services/agents-api/internal/api/vaults.go b/services/agents-api/internal/api/vaults.go new file mode 100644 index 000000000..c472b6a32 --- /dev/null +++ b/services/agents-api/internal/api/vaults.go @@ -0,0 +1,117 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type VaultStore interface { + CreateVault(context.Context, string, store.CreateVaultInput) (store.Vault, error) + GetVault(context.Context, string, string) (store.Vault, error) + DeleteVault(context.Context, string, string) (string, error) + ListVaults(context.Context, string, string, int, bool, []string) (store.VaultPage, error) +} + +// @Summary Create a Vault +// @Description Creates a project-owned Vault independently of execution. Omitted name stays null; a supplied string is trimmed and must contain 1–256 UTF-8 bytes. Explicit null name is invalid. Omitted/null metadata becomes an empty object; values must be strings. Metadata has a local 64 KiB encoded storage bound. Credentials, Session binding and hosted error/retry parity remain incomplete. +// @Tags Vaults +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.CreateVaultRequest true "Vault name and metadata" +// @Success 200 {object} v1.Vault +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /vaults [post] +func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault creation does not accept query parameters.") + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var request struct { + Name json.RawMessage `json:"name"` + Metadata map[string]*string `json:"metadata"` + } + if decodeInputObject(raw, &request, "name", "metadata") != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + return + } + input := store.CreateVaultInput{} + if len(request.Name) > 0 { + var name *string + if json.Unmarshal(request.Name, &name) != nil || name == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "name must be a string.") + return + } + trimmed, err := normalizedVaultName(*name) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + input.Name = &trimmed + } + var err error + input.Metadata, err = stringMetadata(request.Metadata) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "metadata values must be strings.") + return + } + vault, err := h.store.CreateVault(r.Context(), tenantID(r), input) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, vaultResponse(vault)) +} + +// @Summary Retrieve a Vault +// @Description Reads a Vault owned by the authenticated project without resolving credentials, Sessions or execution devices. Missing and foreign IDs share the same not-found response; exact hosted error semantics remain unverified. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Success 200 {object} v1.Vault +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id} [get] +func (h *Handler) getVault(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault retrieval does not accept query parameters.") + return + } + id := chi.URLParam(r, "vault_id") + if parsed, err := uuid.Parse(id); err != nil || parsed == uuid.Nil { + writeStoreError(w, r, store.ErrNotFound) + return + } + vault, err := h.store.GetVault(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, vaultResponse(vault)) +} + +func vaultResponse(vault store.Vault) v1.Vault { + return v1.Vault{ID: vault.ID, Object: "vault", CreatedAt: vault.CreatedAt.Unix(), Name: vault.Name, Metadata: vault.Metadata} +} + +func normalizedVaultName(name string) (string, error) { + name = strings.TrimSpace(name) + if len(name) == 0 || len(name) > 256 { + return "", errors.New("name must contain 1 to 256 UTF-8 bytes after trimming.") + } + return name, nil +} diff --git a/services/agents-api/internal/api/vaults_delete.go b/services/agents-api/internal/api/vaults_delete.go new file mode 100644 index 000000000..21ee01d05 --- /dev/null +++ b/services/agents-api/internal/api/vaults_delete.go @@ -0,0 +1,43 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary Delete a Vault and all its Credentials +// @Description Atomically removes the authenticated project's Vault and all its stored Credentials without an encryption key, decryption or external requests. Existing Session snapshots, history and recorded retries retain their frozen identities; subsequent credential lookups fail without reselection or anonymous fallback. Already-resolved tokens and running Sessions are not revoked or cancelled. Missing/repeated deletion locally returns 404. Exact hosted archive, post-delete visibility and concurrent/error semantics remain unverified; physical erasure from native history, WAL or backups is not established. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Success 200 {object} v1.VaultDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id} [delete] +func (h *Handler) deleteVault(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault deletion does not accept query parameters.") + return + } + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault deletion does not accept a request body.") + return + } + id, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + deleted, err := h.store.DeleteVault(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.VaultDeleted{ID: deleted, Deleted: true, Object: "vault.deleted"}) +} diff --git a/services/agents-api/internal/api/vaults_delete_test.go b/services/agents-api/internal/api/vaults_delete_test.go new file mode 100644 index 000000000..9604e3ed5 --- /dev/null +++ b/services/agents-api/internal/api/vaults_delete_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func (f *vaultResourceFixture) DeleteVault(_ context.Context, tenant, id string) (string, error) { + f.tenant, f.id, f.calls = tenant, id, f.calls+1 + return f.vault.ID, f.err +} + +func TestVaultDeletionConfirmationAndScope(t *testing.T) { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID, "") + var got map[string]any + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal("deletion failed", w.Code) + } + want := map[string]any{"id": f.vault.ID, "deleted": true, "object": "vault.deleted"} + if !reflect.DeepEqual(got, want) || f.tenant != f.vault.TenantID || f.id != f.vault.ID || f.calls != 1 { + t.Fatal("deletion changed authenticated scope or confirmation") + } +} + +func TestVaultDeletionRejectsBeforeMutation(t *testing.T) { + for _, mode := range []string{"auth", "beta", "query", "body", "null", "invalid", "zero"} { + t.Run(mode, func(t *testing.T) { + h, f := vaultResourceHandler(t) + path, body, status := "/v1/vaults/"+f.vault.ID, "", 400 + switch mode { + case "auth": + status = 401 + case "query": + path += "?tenant_id=untrusted" + case "body": + body = `{"token":"vault-delete-canary"}` + case "null": + body = "null" + case "invalid": + path, status = "/v1/vaults/invalid", 404 + case "zero": + path, status = "/v1/vaults/"+uuid.Nil.String(), 404 + } + r := httptest.NewRequest("DELETE", path, strings.NewReader(body)) + if mode != "auth" { + r.Header.Set("Authorization", "Bearer vault-key") + } + if mode != "beta" { + r.Header.Set("OpenAI-Beta", "agents=v1") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status || f.calls != 0 || strings.Contains(w.Body.String(), "vault-delete-canary") { + t.Fatal("invalid deletion reached storage or exposed input", w.Code) + } + }) + } + for _, tc := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {errors.New("vault-delete-canary"), 500}} { + h, f := vaultResourceHandler(t) + f.err = tc.err + w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID, "") + if w.Code != tc.status || strings.Contains(w.Body.String(), "vault-delete-canary") { + t.Fatal("deletion changed error mapping or exposed storage detail") + } + } +} diff --git a/services/agents-api/internal/api/vaults_list.go b/services/agents-api/internal/api/vaults_list.go new file mode 100644 index 000000000..57aa2973a --- /dev/null +++ b/services/agents-api/internal/api/vaults_list.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// @Summary List Vaults +// @Description Lists project-owned Vaults independently of execution. Includes active and archived records by default. Status accepts a scalar or the SDK's status[] array; mixed encodings and repeated scalars are rejected locally. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted errors and concurrent-page behavior remain unverified. Archive/delete lifecycle is not implemented. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Last Vault ID from the previous page" +// @Param limit query integer false "Requested page size, clamped to 1–100" default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Param status query string false "Scalar status filter" Enums(active,archived) +// @Param status[] query []string false "Array status filter; cannot be combined with status" collectionFormat(multi) Enums(active,archived) +// @Success 200 {object} v1.VaultList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults [get] +func (h *Handler) listVaults(w http.ResponseWriter, r *http.Request) { + options, statuses, ok := readVaultPage(w, r) + if !ok { + return + } + page, err := h.store.ListVaults(r.Context(), tenantID(r), options.after, options.limit, options.ascending, statuses) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.VaultList{Object: "list", Data: make([]v1.Vault, 0, len(page.Vaults)), HasMore: page.NextCursor != ""} + for _, vault := range page.Vaults { + response.Data = append(response.Data, vaultResponse(vault)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/vaults_list_test.go b/services/agents-api/internal/api/vaults_list_test.go new file mode 100644 index 000000000..67e60fdc3 --- /dev/null +++ b/services/agents-api/internal/api/vaults_list_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (f *vaultResourceFixture) ListVaults(_ context.Context, tenant, after string, limit int, ascending bool, statuses []string) (store.VaultPage, error) { + f.tenant, f.calls = tenant, f.calls+1 + f.options, f.statuses = pageOptions{after: after, limit: limit, ascending: ascending}, statuses + return f.page, f.err +} + +func TestVaultListParameters(t *testing.T) { + for _, tc := range []struct { + query string + limit int + statuses []string + }{ + {"", 20, nil}, {"?limit=0", 1, nil}, {"?limit=-8", 1, nil}, {"?limit=3", 3, nil}, + {"?limit=101", 100, nil}, {"?limit=9999999999999999999999999", 100, nil}, + {"?limit=-9999999999999999999999999", 1, nil}, + {"?status=active", 20, []string{"active"}}, {"?status=archived", 20, []string{"archived"}}, + {"?status[]=archived&status[]=active", 20, []string{"archived", "active"}}, + } { + t.Run(tc.query, func(t *testing.T) { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, http.MethodGet, "/v1/vaults"+tc.query, "") + if w.Code != 200 || f.calls != 1 || f.tenant != f.vault.TenantID || f.options.limit != tc.limit || !reflect.DeepEqual(f.statuses, tc.statuses) { + t.Fatalf("list: %d %s; options %+v statuses %v owner %s", w.Code, w.Body.String(), f.options, f.statuses, f.tenant) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + want := map[string]any{"object": "list", "data": []any{}, "has_more": false, "first_id": nil, "last_id": nil} + if !reflect.DeepEqual(body, want) { + t.Fatalf("empty envelope: %s", w.Body.String()) + } + }) + } + for _, query := range []string{"limit=", "limit=null", "limit=1.5", "limit=1&limit=2", "order=invalid", "status=", "status=deleted", "status[]=active&status[]=invalid", "status=active&status=archived", "status=active&status[]=archived", "tenant_id=foreign", "after=a&after=b"} { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, http.MethodGet, "/v1/vaults?"+query, "") + if w.Code != 400 || f.calls != 0 { + t.Fatalf("invalid %s: %d %s calls=%d", query, w.Code, w.Body.String(), f.calls) + } + } +} + +func TestVaultListSafeProjectionAndCursor(t *testing.T) { + h, f := vaultResourceHandler(t) + f.page = store.VaultPage{Vaults: []store.Vault{f.vault}, NextCursor: f.vault.ID} + w := vaultRequest(h, http.MethodGet, "/v1/vaults?order=asc&after="+f.vault.ID, "") + var body v1.VaultList + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != 200 { + t.Fatal(w.Code, w.Body.String(), err) + } + if !reflect.DeepEqual(body.Data, []v1.Vault{vaultResponse(f.vault)}) || !body.HasMore || body.FirstID == nil || *body.FirstID != f.vault.ID || body.LastID == nil || *body.LastID != f.vault.ID || !f.options.ascending || f.options.after != f.vault.ID { + t.Fatalf("page changed: %+v, %+v", body, f.options) + } + f.err = store.ErrNotFound + if w = vaultRequest(h, http.MethodGet, "/v1/vaults?after="+f.vault.ID, ""); w.Code != 404 { + t.Fatal(w.Code, w.Body.String()) + } +} diff --git a/services/agents-api/internal/api/vaults_test.go b/services/agents-api/internal/api/vaults_test.go new file mode 100644 index 000000000..147d2c5f9 --- /dev/null +++ b/services/agents-api/internal/api/vaults_test.go @@ -0,0 +1,155 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type vaultResourceFixture struct { + ResourceStore + vault store.Vault + err error + tenant, id string + calls int + page store.VaultPage + options pageOptions + statuses []string +} + +func (f *vaultResourceFixture) CreateVault(_ context.Context, tenant string, input store.CreateVaultInput) (store.Vault, error) { + f.tenant, f.calls = tenant, f.calls+1 + f.vault.Name, f.vault.Metadata = input.Name, input.Metadata + return f.vault, f.err +} + +func (f *vaultResourceFixture) GetVault(_ context.Context, tenant, id string) (store.Vault, error) { + f.tenant, f.id, f.calls = tenant, id, f.calls+1 + return f.vault, f.err +} + +func vaultResourceHandler(t *testing.T) (http.Handler, *vaultResourceFixture) { + t.Helper() + f := &vaultResourceFixture{vault: store.Vault{ID: uuid.NewString(), TenantID: uuid.NewString(), Metadata: map[string]string{}, CreatedAt: time.Unix(1700000000, 0)}} + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "vault-org", ProjectID: "vault-project", SubjectKind: "user", SubjectID: "vault-owner", TokenSHA256: device.HashCredential("vault-key"), TenantID: f.vault.TenantID}}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(f, auth, "claude_code") + if err != nil { + t.Fatal(err) + } + return h, f +} + +func vaultRequest(h http.Handler, method, path, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer vault-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("X-Tenant-ID", "untrusted-tenant") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestVaultResourceProjectionWithoutExecution(t *testing.T) { + for _, test := range []struct { + body string + name any + metadata map[string]any + }{ + {`{}`, nil, map[string]any{}}, + {`{"metadata":null}`, nil, map[string]any{}}, + {`{"name":" 凭据库 \n","metadata":{"team":"engineering"}}`, "凭据库", map[string]any{"team": "engineering"}}, + {`{"name":" ` + strings.Repeat("界", 85) + `x "}`, strings.Repeat("界", 85) + "x", map[string]any{}}, + } { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, "POST", "/v1/vaults", test.body) + var got map[string]any + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal(w.Code, w.Body) + } + want := map[string]any{"id": f.vault.ID, "object": "vault", "created_at": float64(1700000000), "name": test.name, "metadata": test.metadata} + if !reflect.DeepEqual(got, want) || f.tenant != f.vault.TenantID || f.calls != 1 { + t.Fatal("incorrect resource or authenticated owner", got, f) + } + read := vaultRequest(h, "GET", "/v1/vaults/"+f.vault.ID, "") + if read.Code != 200 || read.Body.String() != w.Body.String() || f.id != f.vault.ID || f.calls != 2 { + t.Fatal("retrieve changed resource", read.Code, read.Body, f) + } + } +} + +func TestVaultResourceInvalidRequestsDoNotReachStore(t *testing.T) { + for _, body := range []string{ + `null`, `[]`, `{} {}`, `{"name":null}`, `{"name":1}`, `{"name":""}`, `{"name":" \n\t "}`, + `{"name":"` + strings.Repeat("界", 85) + `xx"}`, `{"metadata":[]}`, `{"metadata":{"key":null}}`, `{"metadata":{"key":1}}`, + `{"tenant_id":"untrusted"}`, `{"credentials":[]}`, + } { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, "POST", "/v1/vaults", body) + if w.Code != 400 || f.calls != 0 { + t.Fatal("invalid request reached storage", body, w.Code, f.calls) + } + } + for _, test := range []struct { + method, path string + status int + }{ + {"POST", "/v1/vaults?tenant_id=foreign", 400}, + {"GET", "/v1/vaults/not-a-vault", 404}, + {"GET", "/v1/vaults/" + uuid.Nil.String(), 404}, + {"GET", "/v1/vaults/" + uuid.NewString() + "?tenant_id=foreign", 400}, + {"POST", "/v1/agents/vaults", 404}, + } { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, test.method, test.path, `{}`) + if w.Code != test.status || f.calls != 0 { + t.Fatal(test, w.Code, f.calls) + } + } +} + +func TestVaultResourceUsesSharedAuthenticationAndErrors(t *testing.T) { + for _, method := range []string{"POST", "GET"} { + for _, test := range []struct { + auth, beta string + status int + }{{"", "agents=v1", 401}, {"Bearer invalid", "agents=v1", 401}, {"Bearer vault-key", "", 400}} { + h, f := vaultResourceHandler(t) + path := "/v1/vaults" + if method == "GET" { + path += "/" + f.vault.ID + } + r := httptest.NewRequest(method, path, strings.NewReader(`{}`)) + r.Header.Set("Authorization", test.auth) + r.Header.Set("OpenAI-Beta", test.beta) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != test.status || f.calls != 0 { + t.Fatal(method, test, w.Code, f.calls) + } + } + } + for _, test := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {errors.New("private-vault-backend"), 500}} { + h, f := vaultResourceHandler(t) + f.err = test.err + w := vaultRequest(h, "GET", "/v1/vaults/"+f.vault.ID, "") + if w.Code != test.status || strings.Contains(w.Body.String(), "private-vault-backend") { + t.Fatal(w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/credentialcrypto/cipher.go b/services/agents-api/internal/credentialcrypto/cipher.go new file mode 100644 index 000000000..56a2ece6c --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/cipher.go @@ -0,0 +1,111 @@ +// Package credentialcrypto encrypts execution-service credentials at rest. +package credentialcrypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/json" + "errors" + "unicode/utf8" +) + +const ( + formatVersion byte = 1 + bindingDomain = "parsar.agents-api.credential" +) + +var ( + errInvalidKey = errors.New("credentialcrypto: invalid encryption key") + errUnavailable = errors.New("credentialcrypto: cipher unavailable") + errInvalidBinding = errors.New("credentialcrypto: invalid binding") + errInvalidCiphertext = errors.New("credentialcrypto: invalid ciphertext") +) + +// Binding authenticates the credential's owner, identity, purpose and destination. +type Binding struct { + TenantID string `json:"tenant_id"` + VaultID string `json:"vault_id"` + CredentialID string `json:"credential_id"` + AuthType string `json:"auth_type"` + Destination string `json:"destination"` +} + +type Cipher struct { + aead cipher.AEAD +} + +// New requires a 32-byte AES key. The operator must limit each key to at most +// 2^32 encryptions, as required by the standard library's random-nonce GCM. +func New(key []byte) (*Cipher, error) { + if len(key) != 32 { + return nil, errInvalidKey + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, errInvalidKey + } + aead, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return nil, errUnavailable + } + return &Cipher{aead: aead}, nil +} + +// Seal returns version || nonce || ciphertext || tag. The AEAD generates and +// prefixes its own nonce; the caller supplies no nonce or mutable output buffer. +func (c *Cipher) Seal(plaintext []byte, b Binding) ([]byte, error) { + aad, err := additionalData(b) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) seal(plaintext, aad []byte) ([]byte, error) { + if c == nil || c.aead == nil { + return nil, errUnavailable + } + return c.aead.Seal([]byte{formatVersion}, nil, plaintext, aad), nil +} + +// Open returns plaintext only after the ciphertext and complete binding authenticate. +func (c *Cipher) Open(ciphertext []byte, b Binding) ([]byte, error) { + aad, err := additionalData(b) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} + +func (c *Cipher) open(ciphertext, aad []byte) ([]byte, error) { + if c == nil || c.aead == nil { + return nil, errUnavailable + } + if len(ciphertext) < 1+c.aead.Overhead() || ciphertext[0] != formatVersion { + return nil, errInvalidCiphertext + } + plaintext, err := c.aead.Open(nil, nil, ciphertext[1:], aad) + if err != nil { + return nil, errInvalidCiphertext + } + return plaintext, nil +} + +func additionalData(b Binding) ([]byte, error) { + for _, value := range []string{b.TenantID, b.VaultID, b.CredentialID, b.AuthType, b.Destination} { + // JSON replaces invalid UTF-8. Reject it so distinct binding bytes cannot + // collapse to the same authenticated encoding. Plaintext stays opaque. + if value == "" || !utf8.ValidString(value) { + return nil, errInvalidBinding + } + } + aad, err := json.Marshal(struct { + Domain string `json:"domain"` + Version byte `json:"version"` + Binding Binding `json:"binding"` + }{Domain: bindingDomain, Version: formatVersion, Binding: b}) + if err != nil { + return nil, errInvalidBinding + } + return aad, nil +} diff --git a/services/agents-api/internal/credentialcrypto/cipher_test.go b/services/agents-api/internal/credentialcrypto/cipher_test.go new file mode 100644 index 000000000..37704d4e3 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/cipher_test.go @@ -0,0 +1,149 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func testBinding() Binding { + return Binding{ + TenantID: "tenant-a", VaultID: "vault-a", CredentialID: "credential-a", + AuthType: "static_bearer", Destination: "https://mcp.example/tools", + } +} + +func testCipher(t *testing.T, key []byte) *Cipher { + t.Helper() + c, err := New(key) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestCipherRoundTripAcrossInstancesAndFreshness(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, 32) + c := testCipher(t, key) + reopened := testCipher(t, bytes.Clone(key)) + for _, plaintext := range [][]byte{nil, {}, []byte(" token\n"), {0, 0xff, 0xc3, 0x28, 0x80, 0}} { + original := bytes.Clone(plaintext) + first, err := c.Seal(plaintext, testBinding()) + if err != nil { + t.Fatal(err) + } + second, err := c.Seal(plaintext, testBinding()) + if err != nil || bytes.Equal(first, second) { + t.Fatal("repeated encryption did not produce fresh ciphertext") + } + if len(first) != 1+28+len(plaintext) || first[0] != 1 { + t.Fatal("unexpected ciphertext format") + } + for _, encrypted := range [][]byte{first, second} { + copyOfCiphertext := bytes.Clone(encrypted) + got, err := reopened.Open(encrypted, testBinding()) + if err != nil || !bytes.Equal(got, original) { + t.Fatal("new cipher instance did not recover exact plaintext bytes", err) + } + if !bytes.Equal(encrypted, copyOfCiphertext) || !bytes.Equal(plaintext, original) { + t.Fatal("encryption or decryption modified caller-owned input") + } + } + } +} + +func TestCipherRejectsWrongKeyAndModifiedCiphertext(t *testing.T) { + c := testCipher(t, bytes.Repeat([]byte{0x42}, 32)) + sealed, err := c.Seal(bytes.Repeat([]byte("opaque-token"), 4), testBinding()) + if err != nil { + t.Fatal(err) + } + wrongKey := testCipher(t, bytes.Repeat([]byte{0x24}, 32)) + if got, err := wrongKey.Open(sealed, testBinding()); err == nil || got != nil { + t.Fatal("wrong key returned plaintext") + } + for _, offset := range []int{0, 1, 13, len(sealed) - 1} { + changed := bytes.Clone(sealed) + changed[offset] ^= 0x80 + if got, err := c.Open(changed, testBinding()); err == nil || got != nil { + t.Fatalf("modified version/nonce/payload/tag at offset %d accepted", offset) + } + } + for _, truncated := range [][]byte{nil, {}, sealed[:1], sealed[:28], sealed[:len(sealed)-1]} { + if got, err := c.Open(truncated, testBinding()); err == nil || got != nil { + t.Fatal("truncated ciphertext returned plaintext") + } + } +} + +func TestCipherAuthenticatesEveryBindingField(t *testing.T) { + c := testCipher(t, bytes.Repeat([]byte{0x42}, 32)) + sealed, err := c.Seal([]byte("synthetic-private-token"), testBinding()) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"tenant", "vault", "credential", "auth", "destination"} { + t.Run(field, func(t *testing.T) { + changed := testBinding() + switch field { + case "tenant": + changed.TenantID = "another-tenant" + case "vault": + changed.VaultID = "another-vault" + case "credential": + changed.CredentialID = "another-credential" + case "auth": + changed.AuthType = "another-purpose" + case "destination": + changed.Destination = "https://other.example/tools" + } + if got, err := c.Open(sealed, changed); err == nil || got != nil { + t.Fatal("substituted binding returned plaintext") + } + }) + } + first := testBinding() + first.TenantID, first.VaultID = "a", "bc" + sealed, err = c.Seal([]byte("synthetic-private-token"), first) + if err != nil { + t.Fatal(err) + } + second := first + second.TenantID, second.VaultID = "ab", "c" + if got, err := c.Open(sealed, second); err == nil || got != nil { + t.Fatal("ambiguous concatenation of binding fields authenticated") + } +} + +func TestCipherRejectsInvalidConstructionAndBinding(t *testing.T) { + for _, length := range []int{0, 16, 24, 31, 33} { + if c, err := New(bytes.Repeat([]byte{0x42}, length)); err == nil || c != nil { + t.Fatal("incorrect AES key size accepted") + } + } + for _, c := range []*Cipher{nil, {}} { + if got, err := c.Seal([]byte("synthetic-private-token"), testBinding()); err == nil || got != nil { + t.Fatal("unavailable cipher accepted encryption") + } + if got, err := c.Open([]byte("synthetic-ciphertext"), testBinding()); err == nil || got != nil { + t.Fatal("unavailable cipher accepted decryption") + } + } + c := testCipher(t, bytes.Repeat([]byte{0x42}, 32)) + sealed, err := c.Seal([]byte("synthetic-private-token"), testBinding()) + if err != nil { + t.Fatal(err) + } + for index := range 5 { + for _, invalid := range []string{"", string([]byte{0xff})} { + binding := testBinding() + fields := []*string{&binding.TenantID, &binding.VaultID, &binding.CredentialID, &binding.AuthType, &binding.Destination} + *fields[index] = invalid + if got, err := c.Seal([]byte("synthetic-private-token"), binding); err == nil || got != nil { + t.Fatal("invalid binding accepted for encryption") + } + if got, err := c.Open(sealed, binding); err == nil || got != nil { + t.Fatal("invalid binding accepted for decryption") + } + } + } +} diff --git a/services/agents-api/internal/credentialcrypto/environment_file.go b/services/agents-api/internal/credentialcrypto/environment_file.go new file mode 100644 index 000000000..cb079b959 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_file.go @@ -0,0 +1,46 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +// EnvironmentFileBinding keeps confidential bytes scoped to one resource and file. +type EnvironmentFileBinding struct { + TenantID string `json:"tenant_id"` + Resource string `json:"resource"` + OwnerID string `json:"owner_id"` + FileID string `json:"file_id"` +} + +func (c *Cipher) SealEnvironmentFile(plaintext []byte, binding EnvironmentFileBinding) ([]byte, error) { + aad, err := environmentFileData(binding) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenEnvironmentFile(ciphertext []byte, binding EnvironmentFileBinding) ([]byte, error) { + aad, err := environmentFileData(binding) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} + +func environmentFileData(binding EnvironmentFileBinding) ([]byte, error) { + if binding.Resource != "environment_template" && binding.Resource != "session" { + return nil, errInvalidBinding + } + for _, value := range []string{binding.TenantID, binding.OwnerID, binding.FileID} { + if value == "" || !utf8.ValidString(value) { + return nil, errInvalidBinding + } + } + return json.Marshal(struct { + Domain string `json:"domain"` + Version byte `json:"version"` + Binding EnvironmentFileBinding `json:"binding"` + }{"parsar.agents-api.environment-file", formatVersion, binding}) +} diff --git a/services/agents-api/internal/credentialcrypto/environment_file_test.go b/services/agents-api/internal/credentialcrypto/environment_file_test.go new file mode 100644 index 000000000..4a89a49d3 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_file_test.go @@ -0,0 +1,42 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestEnvironmentFileCipherOwnershipAndPurpose(t *testing.T) { + cipher, err := New(bytes.Repeat([]byte{42}, 32)) + if err != nil { + t.Fatal(err) + } + bound := EnvironmentFileBinding{TenantID: "tenant", Resource: "session", OwnerID: "session", FileID: "file"} + plain := []byte("confidential-file-canary\x00\xff") + sealed, err := cipher.SealEnvironmentFile(plain, bound) + if err != nil || bytes.Contains(sealed, plain) { + t.Fatal("invalid confidential encoding", err) + } + got, err := cipher.OpenEnvironmentFile(sealed, bound) + if err != nil || !bytes.Equal(got, plain) { + t.Fatal("roundtrip", err) + } + for _, change := range []func(*EnvironmentFileBinding){ + func(b *EnvironmentFileBinding) { b.TenantID = "foreign" }, + func(b *EnvironmentFileBinding) { b.Resource = "environment_template" }, + func(b *EnvironmentFileBinding) { b.OwnerID = "other-session" }, + func(b *EnvironmentFileBinding) { b.FileID = "other-file" }, + } { + foreign := bound + change(&foreign) + if _, err := cipher.OpenEnvironmentFile(sealed, foreign); err == nil { + t.Fatal("accepted different file owner") + } + } + if _, err := cipher.Open(sealed, Binding{"tenant", "session", "file", "static_bearer", "destination"}); err == nil { + t.Fatal("accepted file as Vault credential") + } + sealed[len(sealed)-1] ^= 1 + if _, err := cipher.OpenEnvironmentFile(sealed, bound); err == nil { + t.Fatal("accepted corrupt file") + } +} diff --git a/services/agents-api/internal/credentialcrypto/environment_setup.go b/services/agents-api/internal/credentialcrypto/environment_setup.go new file mode 100644 index 000000000..c7cbd9b8c --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_setup.go @@ -0,0 +1,46 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +// EnvironmentSetupBinding separates confidential fields and immutable snapshots. +type EnvironmentSetupBinding struct { + TenantID string `json:"tenant_id"` + Resource string `json:"resource"` + OwnerID string `json:"owner_id"` + Field string `json:"field"` +} + +func (c *Cipher) SealEnvironmentSetup(plaintext []byte, binding EnvironmentSetupBinding) ([]byte, error) { + aad, err := environmentSetupData(binding) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenEnvironmentSetup(ciphertext []byte, binding EnvironmentSetupBinding) ([]byte, error) { + aad, err := environmentSetupData(binding) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} + +func environmentSetupData(binding EnvironmentSetupBinding) ([]byte, error) { + if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization" && binding.Field != "skills") { + return nil, errInvalidBinding + } + for _, value := range []string{binding.TenantID, binding.OwnerID} { + if value == "" || !utf8.ValidString(value) { + return nil, errInvalidBinding + } + } + return json.Marshal(struct { + Domain string `json:"domain"` + Version byte `json:"version"` + Binding EnvironmentSetupBinding `json:"binding"` + }{"parsar.agents-api.environment-setup", formatVersion, binding}) +} diff --git a/services/agents-api/internal/credentialcrypto/environment_setup_test.go b/services/agents-api/internal/credentialcrypto/environment_setup_test.go new file mode 100644 index 000000000..d2e410ecc --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_setup_test.go @@ -0,0 +1,31 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestEnvironmentSetupResourceAndFieldBinding(t *testing.T) { + c := testCipher(t, bytes.Repeat([]byte{3}, 32)) + binding := EnvironmentSetupBinding{TenantID: "tenant", Resource: "environment_template", OwnerID: "owner", Field: "env"} + plaintext := []byte("private-configuration") + encrypted, err := c.SealEnvironmentSetup(plaintext, binding) + if err != nil { + t.Fatal(err) + } + got, err := c.OpenEnvironmentSetup(encrypted, binding) + if err != nil || !bytes.Equal(got, plaintext) { + t.Fatal("round trip", err) + } + for _, change := range []func(*EnvironmentSetupBinding){func(b *EnvironmentSetupBinding) { b.TenantID = "other" }, func(b *EnvironmentSetupBinding) { b.Resource = "session" }, func(b *EnvironmentSetupBinding) { b.OwnerID = "other" }, func(b *EnvironmentSetupBinding) { b.Field = "setup_commands" }} { + other := binding + change(&other) + if _, err := c.OpenEnvironmentSetup(encrypted, other); err == nil { + t.Fatal("cross-boundary decryption") + } + } + encrypted[len(encrypted)-1] ^= 1 + if _, err := c.OpenEnvironmentSetup(encrypted, binding); err == nil { + t.Fatal("modified ciphertext accepted") + } +} diff --git a/services/agents-api/internal/credentialcrypto/model_execution.go b/services/agents-api/internal/credentialcrypto/model_execution.go new file mode 100644 index 000000000..61dc204c3 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/model_execution.go @@ -0,0 +1,29 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +func modelExecutionData(tenantID, sessionID string) ([]byte, error) { + if tenantID == "" || sessionID == "" || !utf8.ValidString(tenantID) || !utf8.ValidString(sessionID) { + return nil, errInvalidBinding + } + return json.Marshal([]string{"parsar.agents-api.session-model-execution.v1", tenantID, sessionID}) +} + +func (c *Cipher) SealModelExecution(plaintext []byte, tenantID, sessionID string) ([]byte, error) { + aad, err := modelExecutionData(tenantID, sessionID) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenModelExecution(ciphertext []byte, tenantID, sessionID string) ([]byte, error) { + aad, err := modelExecutionData(tenantID, sessionID) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} diff --git a/services/agents-api/internal/db/queries/agents.sql b/services/agents-api/internal/db/queries/agents.sql new file mode 100644 index 000000000..812973de7 --- /dev/null +++ b/services/agents-api/internal/db/queries/agents.sql @@ -0,0 +1,31 @@ +-- name: CreateAgent :one +INSERT INTO agents (id, tenant_id, metadata, configuration) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetAgent :one +SELECT * FROM agents WHERE tenant_id = $1 AND id = $2; + +-- name: ListAgents :many +SELECT * FROM agents +WHERE tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: LockAgent :one +SELECT * FROM agents WHERE tenant_id = $1 AND id = $2 FOR UPDATE; + +-- name: UpdateAgent :one +UPDATE agents SET configuration = $3, metadata = $4, updated_at = clock_timestamp() +WHERE tenant_id = $1 AND id = $2 +RETURNING *; + +-- name: DeleteAgent :one +DELETE FROM agents WHERE tenant_id = $1 AND id = $2 RETURNING id; diff --git a/services/agents-api/internal/db/queries/devices.sql b/services/agents-api/internal/db/queries/devices.sql new file mode 100644 index 000000000..55da02c9f --- /dev/null +++ b/services/agents-api/internal/db/queries/devices.sql @@ -0,0 +1,58 @@ +-- name: CreateDevice :one +INSERT INTO devices (id, tenant_id, name, credential_hash) +VALUES ($1, $2, $3, $4) RETURNING id; + +-- name: GetDevice :one +SELECT id, name FROM devices WHERE tenant_id = $1 AND id = $2 AND revoked_at IS NULL; + +-- name: GetDeviceCredential :one +SELECT d.id, d.name, d.credential_hash FROM devices d +WHERE d.id = $1 AND d.revoked_at IS NULL AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = d.environment_id AND s.tenant_id = d.tenant_id AND s.deleted_at IS NULL +)); + +-- name: RevokeDevice :execrows +UPDATE devices SET revoked_at = COALESCE(revoked_at, clock_timestamp()) +WHERE tenant_id = $1 AND id = $2; + +-- name: TouchDevice :execrows +UPDATE devices SET last_seen_at = clock_timestamp() +WHERE devices.id = $1 AND devices.revoked_at IS NULL AND (devices.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = devices.environment_id AND s.tenant_id = devices.tenant_id AND s.deleted_at IS NULL +)); + +-- name: BindSessionDevice :one +INSERT INTO session_devices (session_id, device_id) +SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)) +ON CONFLICT (session_id) DO UPDATE SET device_id = session_devices.device_id +WHERE session_devices.device_id = EXCLUDED.device_id +RETURNING device_id; + +-- name: GetSessionDevice :one +SELECT d.id, d.name, d.environment_id FROM session_devices b +JOIN sessions s ON s.id = b.session_id +JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)); + +-- name: GetSessionExecutionBinding :one +SELECT d.id, d.name, b.native_session_id, d.environment_id, + EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.id AND t.started_at IS NOT NULL) AS has_started_turn +FROM session_devices b +JOIN sessions s ON s.id = b.session_id +JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)); + +-- name: RememberNativeSession :execrows +UPDATE session_devices SET native_session_id = $2 WHERE session_id = $1; diff --git a/services/agents-api/internal/db/queries/environment_connections.sql b/services/agents-api/internal/db/queries/environment_connections.sql new file mode 100644 index 000000000..4c2dc9dab --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_connections.sql @@ -0,0 +1,25 @@ +-- name: GetEnvironmentConnection :one +SELECT * FROM environment_connections WHERE environment_id = $1; + +-- name: ReplaceEnvironmentConnection :exec +INSERT INTO environment_connections (environment_id, generation, revision) +VALUES ($1, $2, 0) +ON CONFLICT (environment_id) DO UPDATE SET generation = EXCLUDED.generation, revision = 0; + +-- name: AdvanceEnvironmentConnection :exec +UPDATE environment_connections SET revision = $2 WHERE environment_id = $1; + +-- name: SetEnvironmentConnectionStatus :exec +UPDATE environments SET status = $2 WHERE id = $1; + +-- name: DeleteEnvironmentConnection :exec +DELETE FROM environment_connections WHERE environment_id = $1; + +-- name: ListEnvironmentConnections :many +SELECT e.id, e.session_id, s.tenant_id +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE e.id > $1 AND s.deleted_at IS NULL + AND (e.status = 'connected' OR EXISTS ( + SELECT 1 FROM environment_connections c WHERE c.environment_id = e.id + )) +ORDER BY e.id LIMIT 32; diff --git a/services/agents-api/internal/db/queries/environment_executor_credentials.sql b/services/agents-api/internal/db/queries/environment_executor_credentials.sql new file mode 100644 index 000000000..b9fdc745e --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_executor_credentials.sql @@ -0,0 +1,52 @@ +-- name: ExecutorProjectScopeExists :one +SELECT EXISTS ( + SELECT 1 FROM execution_project_scopes + WHERE tenant_id = sqlc.arg(tenant_id) AND organization_id = sqlc.arg(organization_id) AND project_id = sqlc.arg(project_id) +); + +-- name: IssueExecutorCredential :one +WITH issued AS (SELECT clock_timestamp() AS at) +INSERT INTO environment_executor_credentials + (key_id, tenant_id, subject_kind, subject_id, environment_id, token_sha256, created_at, issued_at) +SELECT sqlc.arg(key_id), p.tenant_id, sqlc.arg(subject_kind), sqlc.arg(subject_id), + sqlc.narg(environment_id)::uuid, sqlc.arg(token_sha256), issued.at, issued.at +FROM execution_project_scopes p CROSS JOIN issued +WHERE p.tenant_id = sqlc.arg(tenant_id) AND p.organization_id = sqlc.arg(organization_id) + AND p.project_id = sqlc.arg(project_id) + AND (sqlc.narg(environment_id)::uuid IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = sqlc.narg(environment_id) AND s.tenant_id = p.tenant_id AND s.deleted_at IS NULL + AND s.creator_kind = sqlc.arg(subject_kind) AND s.creator_id = sqlc.arg(subject_id) + )) +ON CONFLICT (key_id) DO NOTHING +RETURNING key_id, environment_id; + +-- name: GetExecutorCredentialForPrincipal :one +SELECT c.environment_id +FROM environment_executor_credentials c +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +WHERE c.key_id = sqlc.arg(key_id) AND c.tenant_id = sqlc.arg(tenant_id) + AND c.subject_kind = sqlc.arg(subject_kind) AND c.subject_id = sqlc.arg(subject_id) + AND p.organization_id = sqlc.arg(organization_id) AND p.project_id = sqlc.arg(project_id); + +-- name: RotateExecutorCredential :one +UPDATE environment_executor_credentials +SET token_sha256 = sqlc.arg(token_sha256), issued_at = clock_timestamp(), revoked_at = NULL +WHERE key_id = sqlc.arg(key_id) AND tenant_id = sqlc.arg(tenant_id) + AND subject_kind = sqlc.arg(subject_kind) AND subject_id = sqlc.arg(subject_id) +RETURNING key_id, environment_id; + +-- name: RevokeExecutorCredential :execrows +UPDATE environment_executor_credentials SET revoked_at = COALESCE(revoked_at, clock_timestamp()) +WHERE key_id = sqlc.arg(key_id) AND tenant_id = sqlc.arg(tenant_id) + AND subject_kind = sqlc.arg(subject_kind) AND subject_id = sqlc.arg(subject_id); + +-- name: AuthenticateEnvironmentExecutor :one +SELECT s.tenant_id +FROM environment_executor_credentials c +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +JOIN environments e ON e.id = sqlc.arg(environment_id) +JOIN sessions s ON s.id = e.session_id +WHERE c.token_sha256 = sqlc.arg(token_sha256) AND c.revoked_at IS NULL + AND c.tenant_id = s.tenant_id AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL; diff --git a/services/agents-api/internal/db/queries/environment_file_writes.sql b/services/agents-api/internal/db/queries/environment_file_writes.sql new file mode 100644 index 000000000..887b394bd --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_file_writes.sql @@ -0,0 +1,25 @@ +-- name: GetEnvironmentFileWrite :one +SELECT sqlc.embed(w), e.session_id +FROM environment_file_writes w +JOIN environments e ON e.id = w.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) AND w.id = sqlc.arg(id); + +-- name: CreateEnvironmentFileWrite :one +INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256) +VALUES ($1, $2, $3, $4) RETURNING *; + +-- name: SettleEnvironmentFileWrite :one +UPDATE environment_file_writes SET state = $3, settled_at = clock_timestamp() +WHERE environment_id = $1 AND id = $2 AND state = 'pending' RETURNING *; + +-- name: EnvironmentFileWriteBlocksSession :one +SELECT EXISTS ( + SELECT 1 FROM environments e JOIN environment_file_writes w ON w.environment_id = e.id + WHERE e.session_id = $1 AND w.state = 'pending' +)::boolean; + +-- name: EnvironmentFileWriteHasPendingInput :one +SELECT EXISTS ( + SELECT 1 FROM environment_input_reservations WHERE session_id = $1 AND state = 'pending' +)::boolean; diff --git a/services/agents-api/internal/db/queries/environment_input_activity.sql b/services/agents-api/internal/db/queries/environment_input_activity.sql new file mode 100644 index 000000000..16ea36f05 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_input_activity.sql @@ -0,0 +1,15 @@ +-- name: GetEnvironmentInputActivity :one +SELECT r.state, r.is_initial, r.created_at, r.settled_at, e.id AS environment_id, e.status AS connection_status, + COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type +FROM environments e +JOIN sessions s ON s.id = e.session_id +JOIN LATERAL ( + SELECT * FROM environment_input_reservations + WHERE session_id = e.session_id + ORDER BY created_at DESC, id DESC LIMIT 1 +) r ON true +WHERE e.session_id = $1 AND r.state <> 'admitted' + AND NOT EXISTS ( + SELECT 1 FROM turns t WHERE t.session_id = e.session_id + AND (t.created_at >= r.created_at OR t.status IN ('queued', 'in_progress', 'waiting')) + ); diff --git a/services/agents-api/internal/db/queries/environment_input_expiry.sql b/services/agents-api/internal/db/queries/environment_input_expiry.sql new file mode 100644 index 000000000..afa549fd9 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_input_expiry.sql @@ -0,0 +1,10 @@ +-- name: ListDueEnvironmentInputs :many +SELECT r.id, r.session_id +FROM environment_input_reservations r +JOIN sessions s ON s.id = r.session_id +WHERE r.state = 'pending' + AND r.deadline <= statement_timestamp() + AND s.deleted_at IS NULL +ORDER BY r.deadline, r.id +LIMIT 32 +FOR UPDATE OF s SKIP LOCKED; diff --git a/services/agents-api/internal/db/queries/environment_inputs.sql b/services/agents-api/internal/db/queries/environment_inputs.sql new file mode 100644 index 000000000..de509300a --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_inputs.sql @@ -0,0 +1,42 @@ +-- name: CreateEnvironmentInputReservation :one +WITH accepted AS (SELECT clock_timestamp() AS at) +INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, created_at, deadline) +SELECT $1, $2, $3, $4, $5, at, at + interval '5 minutes' FROM accepted +RETURNING *; + +-- name: FindEnvironmentInputReservation :one +SELECT sqlc.embed(r), r.batch = sqlc.arg(batch)::jsonb AS matches +FROM environment_input_reservations r +WHERE r.session_id = sqlc.arg(session_id) AND r.idempotency_key = sqlc.arg(idempotency_key); + +-- name: GetEnvironmentInputReservation :one +SELECT * FROM environment_input_reservations +WHERE session_id = $1 AND id = $2; + +-- name: CheckEnvironmentInputGate :one +SELECT COALESCE(( + SELECT r.batch = sqlc.arg(batch)::jsonb FROM environment_input_reservations r + WHERE r.session_id = sqlc.arg(session_id) AND r.idempotency_key = sqlc.arg(idempotency_key) +), true)::boolean AS matches, +EXISTS ( + SELECT 1 FROM environment_input_reservations r + WHERE r.session_id = sqlc.arg(session_id) + AND (r.state = 'pending' OR r.idempotency_key = sqlc.arg(idempotency_key)) +)::boolean AS blocked; + +-- name: ExpireEnvironmentInputReservation :exec +UPDATE environment_input_reservations SET state = 'expired', settled_at = clock_timestamp() +WHERE session_id = $1 AND id = $2 AND state = 'pending' AND deadline <= clock_timestamp(); + +-- name: SettleEnvironmentInputReservation :one +UPDATE environment_input_reservations SET state = sqlc.arg(state), settled_at = clock_timestamp() +WHERE session_id = sqlc.arg(session_id) AND id = sqlc.arg(id) AND state = 'pending' +RETURNING *; + +-- name: CancelSessionEnvironmentInput :exec +UPDATE environment_input_reservations SET state = 'cancelled', settled_at = clock_timestamp() +WHERE session_id = $1 AND state = 'pending'; + +-- name: FailSessionEnvironmentInput :exec +UPDATE environment_input_reservations SET state = 'failed', settled_at = clock_timestamp() +WHERE session_id = $1 AND state = 'pending'; diff --git a/services/agents-api/internal/db/queries/environment_setup.sql b/services/agents-api/internal/db/queries/environment_setup.sql new file mode 100644 index 000000000..76b6d0596 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_setup.sql @@ -0,0 +1,10 @@ +-- name: CreateEnvironmentSetup :exec +INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2); + +-- name: GetEnvironmentSetup :one +SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; + +-- name: SetSessionSetupMetadata :one +UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +WHERE id = $1 RETURNING *; diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/agents-api/internal/db/queries/environment_templates.sql new file mode 100644 index 000000000..1a34aa4e4 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_templates.sql @@ -0,0 +1,40 @@ +-- name: CreateEnvironmentTemplate :one +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; + +-- name: GetEnvironmentTemplate :one +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2; + +-- name: UpdateEnvironmentTemplate :one +UPDATE environment_templates SET + name = CASE WHEN sqlc.arg(set_name)::boolean THEN sqlc.narg(name)::text ELSE name END, + network_access = CASE WHEN sqlc.arg(set_network)::boolean THEN sqlc.arg(network_access)::text ELSE network_access END, + files = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.arg(files)::jsonb ELSE files END, + file_contents = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.narg(file_contents)::bytea ELSE file_contents END, + packages = CASE WHEN sqlc.arg(set_packages)::boolean THEN sqlc.arg(packages)::jsonb ELSE packages END, + env_contents = CASE WHEN sqlc.arg(set_env)::boolean THEN sqlc.narg(env_contents)::bytea ELSE env_contents END, + setup_contents = CASE WHEN sqlc.arg(set_setup)::boolean THEN sqlc.narg(setup_contents)::bytea ELSE setup_contents END, + skills = CASE WHEN sqlc.arg(set_skills)::boolean THEN sqlc.arg(skills)::jsonb ELSE skills END, + skill_contents = CASE WHEN sqlc.arg(set_skills)::boolean THEN sqlc.narg(skill_contents)::bytea ELSE skill_contents END, + updated_at = clock_timestamp() +WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; + +-- name: DeleteEnvironmentTemplate :one +DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id; + +-- name: ListEnvironmentTemplates :many +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates +WHERE tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: ResolveEnvironmentTemplate :one +SELECT * FROM environment_templates WHERE tenant_id = $1 AND id = $2; diff --git a/services/agents-api/internal/db/queries/environments.sql b/services/agents-api/internal/db/queries/environments.sql new file mode 100644 index 000000000..dfa4177f1 --- /dev/null +++ b/services/agents-api/internal/db/queries/environments.sql @@ -0,0 +1,12 @@ +-- name: CreateEnvironment :exec +INSERT INTO environments (id, session_id) VALUES ($1, $2); + +-- name: GetEnvironment :one +SELECT sqlc.embed(e), s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL; + +-- name: GetSessionEnvironment :one +SELECT sqlc.embed(e), s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; diff --git a/services/agents-api/internal/db/queries/functions.sql b/services/agents-api/internal/db/queries/functions.sql new file mode 100644 index 000000000..b3d9efa13 --- /dev/null +++ b/services/agents-api/internal/db/queries/functions.sql @@ -0,0 +1,34 @@ +-- name: MatchFunctionCall :one +SELECT COALESCE(executor_call_id = sqlc.arg(executor_call_id) AND name = sqlc.arg(name) + AND arguments = sqlc.arg(arguments)::jsonb, false)::boolean AS matches +FROM function_calls +WHERE session_id = sqlc.arg(session_id) AND turn_id = sqlc.arg(turn_id) AND call_id = sqlc.arg(call_id); + +-- name: CreateFunctionCall :execrows +INSERT INTO function_calls(session_id, turn_id, call_id, executor_call_id, name, arguments) +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING; + +-- name: GetFunctionCall :one +SELECT f.* FROM function_calls f JOIN sessions s ON s.id = f.session_id +WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.turn_id = $3 AND f.call_id = $4; + +-- name: ListPendingFunctionCalls :many +SELECT f.* FROM function_calls f JOIN turns t ON t.session_id = f.session_id AND t.id = f.turn_id +WHERE f.session_id = $1 AND f.turn_id = $2 AND NOT f.applied + AND t.status IN ('in_progress', 'waiting') AND t.cancel_requested_at IS NULL +ORDER BY f.created_at, f.call_id; + +-- name: MatchFunctionResult :one +SELECT (result IS NOT NULL)::boolean AS submitted, COALESCE(result = sqlc.arg(result)::jsonb, false)::boolean AS matches +FROM function_calls +WHERE session_id = sqlc.arg(session_id) AND turn_id = sqlc.arg(turn_id) AND call_id = sqlc.arg(call_id); + +-- name: SubmitFunctionResult :exec +UPDATE function_calls SET result = $4 WHERE session_id = $1 AND turn_id = $2 AND call_id = $3; + +-- name: ApplyFunctionResult :exec +UPDATE function_calls SET applied = true WHERE session_id = $1 AND turn_id = $2 AND call_id = $3; + +-- name: FunctionItemResult :one +SELECT result FROM function_calls +WHERE session_id = $1 AND turn_id = $2 AND call_id = $3; diff --git a/services/agents-api/internal/db/queries/initial_environment_files.sql b/services/agents-api/internal/db/queries/initial_environment_files.sql new file mode 100644 index 000000000..0fb3015f9 --- /dev/null +++ b/services/agents-api/internal/db/queries/initial_environment_files.sql @@ -0,0 +1,36 @@ +-- name: CreateInitialEnvironmentFile :exec +INSERT INTO initial_environment_files (id, session_id, position, path, size_bytes, contents) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: SetSessionInitialFileMetadata :one +UPDATE sessions SET configuration = jsonb_set(configuration, '{environment,files}', $2::jsonb) +WHERE id = $1 RETURNING *; + +-- name: GetInitialEnvironmentFile :one +SELECT f.* FROM initial_environment_files f JOIN sessions s ON s.id = f.session_id +WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_at IS NULL; + +-- name: GetSessionInitializationReady :one +SELECT NOT EXISTS ( + SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id + WHERE e.session_id = s.id AND a.initialization <> 'complete' +) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) + AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) + OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id + WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready +FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2; + +-- name: ClaimRuntimeInitialization :one +UPDATE runtime_allocations SET initialization = 'running' +WHERE id = $1 AND initialization = 'pending' AND state = 'running' AND create_settled +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING *; + +-- name: CompleteRuntimeInitialization :one +UPDATE runtime_allocations SET initialization = 'complete' +WHERE id = $1 AND initialization = 'running' AND state = 'running' AND create_settled +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING *; + +-- name: LockInitialSourceFile :one +SELECT * FROM source_files WHERE tenant_id = $1 AND id = $2 FOR SHARE; diff --git a/services/agents-api/internal/db/queries/local_environment_devices.sql b/services/agents-api/internal/db/queries/local_environment_devices.sql new file mode 100644 index 000000000..29b917620 --- /dev/null +++ b/services/agents-api/internal/db/queries/local_environment_devices.sql @@ -0,0 +1,8 @@ +-- name: CreateEnvironmentDevice :one +INSERT INTO devices (id, tenant_id, name, credential_hash, environment_id) +SELECT sqlc.arg(id), s.tenant_id, sqlc.arg(name), sqlc.arg(credential_hash), e.id +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) +AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +ON CONFLICT (environment_id) DO NOTHING +RETURNING id; diff --git a/services/agents-api/internal/db/queries/mcp_credentials.sql b/services/agents-api/internal/db/queries/mcp_credentials.sql new file mode 100644 index 000000000..fc337e000 --- /dev/null +++ b/services/agents-api/internal/db/queries/mcp_credentials.sql @@ -0,0 +1,26 @@ +-- name: GetAttachedVaultIDs :many +SELECT id FROM vaults +WHERE tenant_id = sqlc.arg(tenant_id) AND id = ANY(sqlc.arg(vault_ids)::uuid[]); + +-- name: FindMCPStaticCredentials :many +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = sqlc.arg(tenant_id) + AND v.id = ANY(sqlc.arg(vault_ids)::uuid[]) + AND c.auth_type = 'static_bearer' + AND c.mcp_server_url = sqlc.arg(mcp_server_url) + AND (sqlc.narg(credential_id)::uuid IS NULL OR c.id = sqlc.narg(credential_id)::uuid) +ORDER BY c.id +LIMIT 2; + +-- name: GetMCPStaticCredentialCiphertext :one +SELECT c.token_ciphertext +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = sqlc.arg(tenant_id) + AND v.id = ANY(sqlc.arg(vault_ids)::uuid[]) + AND v.id = sqlc.arg(vault_id) + AND c.id = sqlc.arg(credential_id) + AND c.auth_type = 'static_bearer' + AND c.mcp_server_url = sqlc.arg(mcp_server_url); diff --git a/services/agents-api/internal/db/queries/project_scopes.sql b/services/agents-api/internal/db/queries/project_scopes.sql new file mode 100644 index 000000000..2d1339c23 --- /dev/null +++ b/services/agents-api/internal/db/queries/project_scopes.sql @@ -0,0 +1,7 @@ +-- name: EnsureProjectScope :one +INSERT INTO execution_project_scopes (tenant_id, organization_id, project_id) +VALUES ($1, $2, $3) +ON CONFLICT (tenant_id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id +WHERE execution_project_scopes.organization_id = EXCLUDED.organization_id + AND execution_project_scopes.project_id = EXCLUDED.project_id +RETURNING tenant_id; diff --git a/services/agents-api/internal/db/queries/runtime_allocations.sql b/services/agents-api/internal/db/queries/runtime_allocations.sql new file mode 100644 index 000000000..4c7e97418 --- /dev/null +++ b/services/agents-api/internal/db/queries/runtime_allocations.sql @@ -0,0 +1,50 @@ +-- name: CreateRuntimeAllocation :one +INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, initialization) +VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING *; + +-- name: GetRuntimeAllocation :one +SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.kept_at <= clock_timestamp() - interval '1 hour') AS expired +FROM runtime_allocations a +JOIN environments e ON e.id = a.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND a.environment_id = $2; + +-- name: ListRuntimeAllocations :many +SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.kept_at <= clock_timestamp() - interval '1 hour') AS expired +FROM runtime_allocations a +JOIN environments e ON e.id = a.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE a.id > $1 AND a.state <> 'released' +ORDER BY a.id LIMIT 32; + +-- name: ObserveRuntimeRunning :one +UPDATE runtime_allocations SET state = 'running', create_settled = true +WHERE id = $1 AND state IN ('creating', 'running') +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING *; + +-- name: KeepRuntimeAllocation :one +UPDATE runtime_allocations SET kept_at = clock_timestamp() +WHERE id = $1 AND state = 'running' +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING *; + +-- name: RequestRuntimeCleanup :one +UPDATE runtime_allocations SET state = 'cleanup_pending' +WHERE id = $1 AND state <> 'released' RETURNING *; + +-- name: SettleRuntimeCreation :one +UPDATE runtime_allocations SET create_settled = true +WHERE id = $1 AND state <> 'released' RETURNING *; + +-- name: ReleaseRuntimeAllocation :one +UPDATE runtime_allocations SET state = 'released', released_at = clock_timestamp() +WHERE id = $1 AND state = 'cleanup_pending' AND create_settled RETURNING *; + +-- name: ListUnallocatedHostedEnvironments :many +SELECT e.id, s.tenant_id, s.engine +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status = 'pending' + AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id) +ORDER BY e.id LIMIT 32; diff --git a/services/agents-api/internal/db/queries/scheduling.sql b/services/agents-api/internal/db/queries/scheduling.sql new file mode 100644 index 000000000..54da1333c --- /dev/null +++ b/services/agents-api/internal/db/queries/scheduling.sql @@ -0,0 +1,35 @@ +-- name: TryExecutionLease :one +SELECT pg_try_advisory_lock(706172736172::bigint)::boolean; + +-- name: ListExecutionWork :many +SELECT t.id, t.session_id, s.tenant_id, t.status +FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE t.status = ANY(sqlc.arg(statuses)::text[]) AND t.id > sqlc.arg(after_id)::uuid +AND (s.deleted_at IS NULL OR t.status <> 'queued') +AND (NOT sqlc.arg(connected_only)::boolean OR EXISTS ( + SELECT 1 FROM devices d WHERE d.tenant_id = s.tenant_id AND d.revoked_at IS NULL + AND d.id = ANY(sqlc.arg(connected_devices)::uuid[]) +)) +ORDER BY t.id LIMIT 100; + +-- name: ListEnvironmentInputWork :many +SELECT r.id, r.session_id, s.tenant_id +FROM environment_input_reservations r +JOIN sessions s ON s.id = r.session_id +LEFT JOIN session_devices b ON b.session_id = s.id +WHERE r.state = 'pending' AND r.deadline > clock_timestamp() +AND r.id > sqlc.arg(after_id)::uuid AND s.deleted_at IS NULL +AND EXISTS ( + SELECT 1 FROM devices d WHERE d.tenant_id = s.tenant_id AND d.revoked_at IS NULL + AND d.id = ANY(sqlc.arg(connected_devices)::uuid[]) + AND (b.device_id IS NULL OR d.id = b.device_id) +) +ORDER BY r.id LIMIT 100; + +-- name: ListExecutionDevices :many +SELECT id, name FROM devices +WHERE tenant_id = $1 AND revoked_at IS NULL AND environment_id IS NULL +ORDER BY id; + +-- name: GetLatestSessionTurn :one +SELECT * FROM turns WHERE session_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1; diff --git a/services/agents-api/internal/db/queries/session_artifacts.sql b/services/agents-api/internal/db/queries/session_artifacts.sql new file mode 100644 index 000000000..5d69cfa70 --- /dev/null +++ b/services/agents-api/internal/db/queries/session_artifacts.sql @@ -0,0 +1,48 @@ +-- name: BeginTurnArtifactCapture :execrows +UPDATE turns SET artifact_capture_started = true +WHERE session_id = $1 AND id = $2 AND NOT artifact_capture_started; + +-- name: StageSessionArtifact :exec +INSERT INTO session_artifacts (id, session_id, turn_id, environment_id, path, size_bytes, body_oid, sha256) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + +-- name: PublishTurnArtifacts :exec +UPDATE session_artifacts SET created_at = $3 +WHERE session_id = $1 AND turn_id = $2 AND created_at IS NULL; + +-- name: DeleteUnpublishedTurnArtifacts :exec +WITH removed AS ( + DELETE FROM session_artifacts WHERE session_id = $1 AND turn_id = $2 AND created_at IS NULL RETURNING body_oid +) +SELECT lo_unlink(body_oid) FROM removed; + +-- name: DeleteSessionArtifacts :exec +WITH removed AS ( + DELETE FROM session_artifacts WHERE session_id = $1 RETURNING body_oid +) +SELECT lo_unlink(body_oid) FROM removed; + +-- name: GetSessionArtifact :one +SELECT a.* FROM session_artifacts a JOIN sessions s ON s.id = a.session_id +WHERE s.tenant_id = $1 AND s.deleted_at IS NULL AND a.session_id = $2 AND a.id = $3 AND a.created_at IS NOT NULL; + +-- name: DeleteSessionArtifact :one +DELETE FROM session_artifacts a USING sessions s +WHERE s.id = a.session_id AND s.tenant_id = $1 AND s.deleted_at IS NULL +AND a.session_id = $2 AND a.id = $3 AND a.created_at IS NOT NULL +RETURNING a.body_oid; + +-- name: ListSessionArtifacts :many +SELECT a.* FROM session_artifacts a JOIN sessions s ON s.id = a.session_id +WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.deleted_at IS NULL + AND a.session_id = sqlc.arg(session_id) AND a.created_at IS NOT NULL + AND (sqlc.narg(environment_id)::uuid IS NULL OR a.environment_id = sqlc.narg(environment_id)::uuid) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (a.created_at, a.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (a.created_at, a.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN a.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN a.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.id END DESC +LIMIT sqlc.arg(page_limit); diff --git a/services/agents-api/internal/db/queries/session_events.sql b/services/agents-api/internal/db/queries/session_events.sql new file mode 100644 index 000000000..486f3699d --- /dev/null +++ b/services/agents-api/internal/db/queries/session_events.sql @@ -0,0 +1,38 @@ +-- name: AppendSessionEvent :exec +WITH next AS ( + UPDATE sessions SET event_sequence = event_sequence + 1 WHERE id = $1 + RETURNING id, event_sequence +) +INSERT INTO session_events(session_id, sequence, payload) +SELECT id, event_sequence, $2 FROM next; + +-- name: SessionEventCursor :one +SELECT event_sequence FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL; + +-- name: ListSessionEvents :many +SELECT sequence, payload FROM ( + SELECT e.sequence, e.payload, + row_number() OVER (ORDER BY e.sequence) AS n, + sum(e.payload_bytes) OVER (ORDER BY e.sequence) AS bytes + FROM session_events e JOIN sessions s ON s.id = e.session_id + WHERE s.tenant_id = $1 AND s.deleted_at IS NULL AND e.session_id = $2 AND e.sequence > $3 +) AS pending WHERE n = 1 OR bytes <= 1048576 +ORDER BY sequence LIMIT 32; + +-- name: PruneSessionEvents :exec +WITH retained AS ( + SELECT sequence, row_number() OVER (ORDER BY sequence DESC) AS n, + sum(payload_bytes) OVER (ORDER BY sequence DESC) AS bytes + FROM session_events e WHERE e.session_id = $1 +) +DELETE FROM session_events e WHERE e.session_id = $1 AND e.sequence IN ( + SELECT sequence FROM retained WHERE n > 256 OR (bytes > 67108864 AND n > 1) +); + +-- name: FinishSessionItems :many +UPDATE session_items SET payload = jsonb_set(payload, '{status}', '"incomplete"') +WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress' +RETURNING *; + +-- name: SessionEventTurn :one +SELECT * FROM turns WHERE session_id = $1 AND id = $2; diff --git a/services/agents-api/internal/db/queries/session_items.sql b/services/agents-api/internal/db/queries/session_items.sql new file mode 100644 index 000000000..aef74dd39 --- /dev/null +++ b/services/agents-api/internal/db/queries/session_items.sql @@ -0,0 +1,40 @@ +-- name: GetSessionItem :one +SELECT * FROM session_items WHERE session_id = $1 AND id = $2; + +-- name: PutSessionItem :one +INSERT INTO session_items(id, session_id, turn_id, created_at, payload, position, output_index) +VALUES (sqlc.arg(id), sqlc.arg(session_id), sqlc.arg(turn_id), sqlc.arg(created_at), sqlc.arg(payload), + (SELECT COALESCE(max(position), -1) + 1 FROM session_items WHERE session_id = sqlc.arg(session_id)), + CASE WHEN sqlc.arg(is_output)::boolean THEN + (SELECT COALESCE(max(output_index), -1) + 1 FROM session_items WHERE turn_id = sqlc.arg(turn_id)) + END) +ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload +RETURNING *; + +-- name: ListSessionItems :many +SELECT i.id, i.created_at, + (CASE WHEN i.payload->>'status' = 'in_progress' AND t.status IN ('completed', 'failed', 'cancelled') + THEN jsonb_set(i.payload, '{status}', '"incomplete"') ELSE i.payload END)::jsonb AS payload +FROM session_items i JOIN turns t ON t.id = i.turn_id +WHERE i.session_id = sqlc.arg(session_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (sqlc.arg(ascending)::boolean AND (i.created_at, i.position, i.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_position)::integer, sqlc.arg(after_id)::uuid)) + OR (NOT sqlc.arg(ascending)::boolean AND (i.created_at, i.position, i.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_position)::integer, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN i.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.position END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.position END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: ItemInputSource :one +SELECT * FROM turn_inputs WHERE session_id = $1 AND sequence = $2; + +-- name: ItemEventSources :many +SELECT * FROM turn_events WHERE session_id = $1 AND turn_id = $2 AND ordinal >= $3 ORDER BY ordinal; + +-- name: HasNativeMessageItem :one +SELECT EXISTS(SELECT 1 FROM session_items WHERE turn_id = $1 + AND payload->>'role' = 'assistant' AND id <> $2); diff --git a/services/agents-api/internal/db/queries/session_model_execution.sql b/services/agents-api/internal/db/queries/session_model_execution.sql new file mode 100644 index 000000000..266bd2853 --- /dev/null +++ b/services/agents-api/internal/db/queries/session_model_execution.sql @@ -0,0 +1,6 @@ +-- name: SaveSessionModelExecution :exec +INSERT INTO session_model_execution(session_id, encrypted_config) VALUES (@session_id, @encrypted_config); + +-- name: GetSessionModelExecution :one +SELECT e.encrypted_config FROM session_model_execution e +JOIN sessions s ON s.id = e.session_id WHERE s.tenant_id = @tenant_id AND s.id = @session_id; diff --git a/services/agents-api/internal/db/queries/sessions.sql b/services/agents-api/internal/db/queries/sessions.sql new file mode 100644 index 000000000..2dc88c67e --- /dev/null +++ b/services/agents-api/internal/db/queries/sessions.sql @@ -0,0 +1,38 @@ +-- name: CreateSession :one +INSERT INTO sessions (id, tenant_id, engine, metadata, idempotency_key, request_hash, configuration, creation_request_hash, creator_kind, creator_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (tenant_id, idempotency_key) DO UPDATE +SET idempotency_key = EXCLUDED.idempotency_key +WHERE sessions.deleted_at IS NULL + AND sessions.creator_kind = EXCLUDED.creator_kind AND sessions.creator_id = EXCLUDED.creator_id + AND CASE WHEN sessions.creation_request_hash IS NULL + THEN sessions.request_hash = EXCLUDED.request_hash + ELSE sessions.creation_request_hash = EXCLUDED.creation_request_hash END +RETURNING *; + +-- name: GetSession :one +SELECT * FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL; + +-- name: ListSessions :many +SELECT * FROM sessions +WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL + AND (sqlc.narg(agent_id)::text IS NULL OR configuration #>> '{agent,id}' = sqlc.narg(agent_id)::text) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: UpdateSessionMetadata :one +UPDATE sessions SET metadata = $3 WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING *; + +-- name: FindSessionCreation :one +SELECT * FROM sessions +WHERE tenant_id = $1 AND idempotency_key = $2; + +-- name: MarkSessionDeleted :exec +UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1 AND deleted_at IS NULL; diff --git a/services/agents-api/internal/db/queries/source_files.sql b/services/agents-api/internal/db/queries/source_files.sql new file mode 100644 index 000000000..4f54fed9a --- /dev/null +++ b/services/agents-api/internal/db/queries/source_files.sql @@ -0,0 +1,24 @@ +-- name: CreateSourceFile :one +INSERT INTO source_files (id, tenant_id, filename, purpose, body_oid, size_bytes, sha256) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: GetSourceFile :one +SELECT * FROM source_files WHERE tenant_id = $1 AND id = $2; + +-- name: ListSourceFiles :many +SELECT * FROM source_files +WHERE tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(purpose)::text IS NULL OR purpose = sqlc.narg(purpose)::text) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: DeleteSourceFile :one +DELETE FROM source_files WHERE tenant_id = $1 AND id = $2 RETURNING body_oid; diff --git a/services/agents-api/internal/db/queries/subagent_identities.sql b/services/agents-api/internal/db/queries/subagent_identities.sql new file mode 100644 index 000000000..ab5044195 --- /dev/null +++ b/services/agents-api/internal/db/queries/subagent_identities.sql @@ -0,0 +1,26 @@ +-- name: PutSubagentIdentity :one +INSERT INTO subagent_identities ( + id, session_id, device_id, engine, native_id, parent_native_id, native_created_at, + first_turn_id, first_event_ordinal +) +SELECT sqlc.arg(id), s.id, b.device_id, s.engine, sqlc.arg(native_id), + sqlc.arg(parent_native_id), sqlc.arg(native_created_at), sqlc.arg(first_turn_id), sqlc.arg(first_event_ordinal) +FROM sessions s JOIN session_devices b ON b.session_id = s.id +WHERE s.id = sqlc.arg(session_id) + AND (b.native_session_id = '' OR b.native_session_id = sqlc.arg(parent_native_id)) + AND NOT EXISTS ( + SELECT 1 FROM subagent_identities old + WHERE old.session_id = s.id AND old.parent_native_id <> sqlc.arg(parent_native_id) + ) +ON CONFLICT (device_id, engine, native_id) DO UPDATE SET id = subagent_identities.id +WHERE subagent_identities.session_id = EXCLUDED.session_id + AND subagent_identities.parent_native_id = EXCLUDED.parent_native_id + AND subagent_identities.native_created_at = EXCLUDED.native_created_at +RETURNING id; + +-- name: GetSubagentIdentity :one +SELECT i.*, e.created_at AS first_observed_at FROM subagent_identities i +JOIN sessions s ON s.id = i.session_id +JOIN turn_events e ON e.turn_id = i.first_turn_id AND e.ordinal = i.first_event_ordinal +WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.id = sqlc.arg(session_id) + AND s.deleted_at IS NULL AND i.native_id = sqlc.arg(native_id); diff --git a/services/agents-api/internal/db/queries/token_usage.sql b/services/agents-api/internal/db/queries/token_usage.sql new file mode 100644 index 000000000..0751d834a --- /dev/null +++ b/services/agents-api/internal/db/queries/token_usage.sql @@ -0,0 +1,12 @@ +-- name: PutTurnUsage :exec +UPDATE turns SET token_usage = $3 WHERE session_id = $1 AND id = $2; + +-- name: SessionTokenUsage :one +SELECT CASE WHEN count(token_usage) = 0 THEN NULL ELSE jsonb_build_object( + 'input_tokens', sum((token_usage->>'input_tokens')::numeric), + 'input_tokens_details', jsonb_build_object('cached_tokens', sum((token_usage->'input_tokens_details'->>'cached_tokens')::numeric)), + 'output_tokens', sum((token_usage->>'output_tokens')::numeric), + 'output_tokens_details', jsonb_build_object('reasoning_tokens', sum((token_usage->'output_tokens_details'->>'reasoning_tokens')::numeric)), + 'total_tokens', sum((token_usage->>'total_tokens')::numeric) +) END::jsonb AS usage +FROM turns WHERE session_id = $1; diff --git a/services/agents-api/internal/db/queries/turn_events.sql b/services/agents-api/internal/db/queries/turn_events.sql new file mode 100644 index 000000000..c5b730ed8 --- /dev/null +++ b/services/agents-api/internal/db/queries/turn_events.sql @@ -0,0 +1,23 @@ +-- name: InsertTurnEvent :exec +INSERT INTO turn_events(session_id, turn_id, ordinal, kind, payload) +VALUES ($1, $2, $3, $4, $5); + +-- name: CountTurnEvent :exec +UPDATE turns SET event_count = event_count + sqlc.arg(event_count)::integer, event_bytes = event_bytes + sqlc.arg(payload_bytes)::bigint +WHERE session_id = $1 AND id = $2; + +-- name: MatchTurnEventBatch :one +SELECT COALESCE(jsonb_agg(jsonb_build_object('kind', e.kind, 'payload', e.payload) ORDER BY ordinal) + = sqlc.arg(batch)::jsonb, false)::boolean AS matches +FROM turn_events e WHERE session_id = $1 AND turn_id = $2 + AND ordinal >= sqlc.arg(first_ordinal) AND ordinal < sqlc.arg(first_ordinal)::integer + sqlc.arg(event_count)::integer; + +-- name: InsertTurnEventBatch :exec +INSERT INTO turn_events(session_id, turn_id, ordinal, kind, payload) +SELECT $1, $2, sqlc.arg(first_ordinal)::integer + item.n::integer - 1, item.value->>'kind', item.value->'payload' +FROM jsonb_array_elements(sqlc.arg(batch)::jsonb) WITH ORDINALITY AS item(value, n); + +-- name: ListTurnEvents :many +SELECT e.* FROM turn_events e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND e.session_id = $2 AND e.turn_id = $3 AND e.ordinal > $4 +ORDER BY e.ordinal LIMIT $5; diff --git a/services/agents-api/internal/db/queries/turn_reads.sql b/services/agents-api/internal/db/queries/turn_reads.sql new file mode 100644 index 000000000..65b921041 --- /dev/null +++ b/services/agents-api/internal/db/queries/turn_reads.sql @@ -0,0 +1,12 @@ +-- name: ListTurns :many +SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE s.tenant_id = sqlc.arg(tenant_id) AND t.session_id = sqlc.arg(session_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (t.created_at, t.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (t.created_at, t.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN t.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN t.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.id END DESC +LIMIT sqlc.arg(page_limit); diff --git a/services/agents-api/internal/db/queries/turns.sql b/services/agents-api/internal/db/queries/turns.sql new file mode 100644 index 000000000..2a0477a16 --- /dev/null +++ b/services/agents-api/internal/db/queries/turns.sql @@ -0,0 +1,54 @@ +-- name: LockSession :one +SELECT id, deleted_at FROM sessions WHERE tenant_id = $1 AND id = $2 FOR UPDATE; + +-- name: GetActiveTurn :one +SELECT * FROM turns WHERE session_id = $1 AND status IN ('queued', 'in_progress', 'waiting'); + +-- name: CreateTurn :one +INSERT INTO turns(id, session_id) VALUES ($1, $2) RETURNING *; + +-- name: GetTurn :one +SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE s.tenant_id = $1 AND t.session_id = $2 AND t.id = $3; + +-- name: FindInputBatch :many +WITH previous AS ( + SELECT sequence, turn_id, kind, payload, batch_position FROM turn_inputs + WHERE session_id = $1 AND idempotency_key = $2 +) +SELECT sequence, turn_id, COALESCE(( + SELECT jsonb_agg(jsonb_build_object('kind', kind, 'payload', payload) ORDER BY batch_position) + = sqlc.arg(batch)::jsonb FROM previous +), false)::boolean AS matches +FROM previous ORDER BY batch_position; + +-- name: CreateTurnInput :one +INSERT INTO turn_inputs(session_id, turn_id, idempotency_key, kind, payload, batch_position) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING sequence; + +-- name: RequestTurnCancel :exec +UPDATE turns SET cancel_requested_at = COALESCE(cancel_requested_at, clock_timestamp()), + completed_at = CASE WHEN status = 'queued' THEN clock_timestamp() ELSE completed_at END, + status = CASE WHEN status = 'queued' THEN 'cancelled' ELSE status END +WHERE id = $1 AND session_id = $2 AND status IN ('queued', 'in_progress', 'waiting'); + +-- name: TransitionTurn :one +UPDATE turns SET status = sqlc.arg(new_status), outcome = sqlc.arg(outcome), + started_at = CASE WHEN sqlc.arg(new_status)::text = 'in_progress' + THEN COALESCE(started_at, clock_timestamp()) ELSE started_at END, + completed_at = CASE WHEN sqlc.arg(new_status)::text IN ('completed', 'failed', 'cancelled') + THEN clock_timestamp() ELSE NULL END +WHERE id = sqlc.arg(id) AND session_id = sqlc.arg(session_id) + AND status = sqlc.arg(expected_status) + AND status IN ('queued', 'in_progress', 'waiting') + AND (sqlc.arg(new_status)::text <> 'in_progress' OR cancel_requested_at IS NULL) +RETURNING *; + +-- name: ListTurnInputs :many +SELECT i.* FROM turn_inputs i JOIN sessions s ON s.id = i.session_id +WHERE s.tenant_id = $1 AND i.session_id = $2 AND i.turn_id = $3 AND i.sequence > $4 +ORDER BY i.sequence LIMIT $5; + +-- name: HasUnappliedMessages :one +SELECT EXISTS(SELECT 1 FROM turn_inputs +WHERE session_id = $1 AND turn_id = $2 AND sequence > $3 AND kind = 'message'); diff --git a/services/agents-api/internal/db/queries/vault_credentials.sql b/services/agents-api/internal/db/queries/vault_credentials.sql new file mode 100644 index 000000000..488a81459 --- /dev/null +++ b/services/agents-api/internal/db/queries/vault_credentials.sql @@ -0,0 +1,44 @@ +-- name: CreateStaticCredential :one +INSERT INTO vault_credentials (id, vault_id, name, auth_type, mcp_server_url, token_ciphertext) +SELECT sqlc.arg(id), v.id, sqlc.arg(name), 'static_bearer', sqlc.arg(mcp_server_url), sqlc.arg(token_ciphertext) +FROM vaults v +WHERE v.tenant_id = sqlc.arg(tenant_id) AND v.id = sqlc.arg(vault_id) +RETURNING id, vault_id, name, auth_type, mcp_server_url, created_at, updated_at; + +-- name: GetCredential :one +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = sqlc.arg(tenant_id) AND v.id = sqlc.arg(vault_id) AND c.id = sqlc.arg(id); + +-- name: UpdateStaticCredential :one +UPDATE vault_credentials c +SET token_ciphertext = sqlc.arg(token_ciphertext), updated_at = statement_timestamp() +FROM vaults v +WHERE v.id = c.vault_id AND v.tenant_id = sqlc.arg(tenant_id) + AND v.id = sqlc.arg(vault_id) AND c.id = sqlc.arg(id) + AND c.auth_type = 'static_bearer' AND c.mcp_server_url = sqlc.arg(mcp_server_url) +RETURNING c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at; + +-- name: ListCredentials :many +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = sqlc.arg(tenant_id) AND v.id = sqlc.arg(vault_id) + AND c.status = ANY(sqlc.arg(statuses)::text[]) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (c.created_at, c.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (c.created_at, c.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN c.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN c.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN c.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN c.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: DeleteCredential :one +DELETE FROM vault_credentials c +USING vaults v +WHERE v.id = c.vault_id AND v.tenant_id = sqlc.arg(tenant_id) + AND v.id = sqlc.arg(vault_id) AND c.id = sqlc.arg(id) +RETURNING c.id; diff --git a/services/agents-api/internal/db/queries/vaults.sql b/services/agents-api/internal/db/queries/vaults.sql new file mode 100644 index 000000000..10a898cd8 --- /dev/null +++ b/services/agents-api/internal/db/queries/vaults.sql @@ -0,0 +1,24 @@ +-- name: CreateVault :one +INSERT INTO vaults (id, tenant_id, name, metadata) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetVault :one +SELECT * FROM vaults WHERE tenant_id = $1 AND id = $2; + +-- name: DeleteVault :one +DELETE FROM vaults WHERE tenant_id = $1 AND id = $2 RETURNING id; + +-- name: ListVaults :many +SELECT * FROM vaults +WHERE tenant_id = sqlc.arg(tenant_id) + AND status = ANY(sqlc.arg(statuses)::text[]) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); diff --git a/services/agents-api/internal/db/sqlc/agents.sql.go b/services/agents-api/internal/db/sqlc/agents.sql.go new file mode 100644 index 000000000..f62145b00 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/agents.sql.go @@ -0,0 +1,193 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: agents.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAgent = `-- name: CreateAgent :one +INSERT INTO agents (id, tenant_id, metadata, configuration) +VALUES ($1, $2, $3, $4) +RETURNING id, tenant_id, metadata, configuration, created_at, updated_at +` + +type CreateAgentParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Metadata []byte `json:"metadata"` + Configuration []byte `json:"configuration"` +} + +func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) { + row := q.db.QueryRow(ctx, createAgent, + arg.ID, + arg.TenantID, + arg.Metadata, + arg.Configuration, + ) + var i Agent + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAgent = `-- name: DeleteAgent :one +DELETE FROM agents WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteAgentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteAgent(ctx context.Context, arg DeleteAgentParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteAgent, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getAgent = `-- name: GetAgent :one +SELECT id, tenant_id, metadata, configuration, created_at, updated_at FROM agents WHERE tenant_id = $1 AND id = $2 +` + +type GetAgentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetAgent(ctx context.Context, arg GetAgentParams) (Agent, error) { + row := q.db.QueryRow(ctx, getAgent, arg.TenantID, arg.ID) + var i Agent + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAgents = `-- name: ListAgents :many +SELECT id, tenant_id, metadata, configuration, created_at, updated_at FROM agents +WHERE tenant_id = $1 + AND ($2::timestamptz IS NULL + OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) + OR ($3::boolean AND (created_at, id) > ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN created_at END ASC, + CASE WHEN $3::boolean THEN id END ASC, + CASE WHEN NOT $3::boolean THEN created_at END DESC, + CASE WHEN NOT $3::boolean THEN id END DESC +LIMIT $5 +` + +type ListAgentsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListAgents(ctx context.Context, arg ListAgentsParams) ([]Agent, error) { + rows, err := q.db.Query(ctx, listAgents, + arg.TenantID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Agent{} + for rows.Next() { + var i Agent + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockAgent = `-- name: LockAgent :one +SELECT id, tenant_id, metadata, configuration, created_at, updated_at FROM agents WHERE tenant_id = $1 AND id = $2 FOR UPDATE +` + +type LockAgentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) LockAgent(ctx context.Context, arg LockAgentParams) (Agent, error) { + row := q.db.QueryRow(ctx, lockAgent, arg.TenantID, arg.ID) + var i Agent + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAgent = `-- name: UpdateAgent :one +UPDATE agents SET configuration = $3, metadata = $4, updated_at = clock_timestamp() +WHERE tenant_id = $1 AND id = $2 +RETURNING id, tenant_id, metadata, configuration, created_at, updated_at +` + +type UpdateAgentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + Configuration []byte `json:"configuration"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) { + row := q.db.QueryRow(ctx, updateAgent, + arg.TenantID, + arg.ID, + arg.Configuration, + arg.Metadata, + ) + var i Agent + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/db.go b/services/agents-api/internal/db/sqlc/db.go new file mode 100644 index 000000000..272510886 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/services/agents-api/internal/db/sqlc/devices.sql.go b/services/agents-api/internal/db/sqlc/devices.sql.go new file mode 100644 index 000000000..8b822474f --- /dev/null +++ b/services/agents-api/internal/db/sqlc/devices.sql.go @@ -0,0 +1,220 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: devices.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const bindSessionDevice = `-- name: BindSessionDevice :one +INSERT INTO session_devices (session_id, device_id) +SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)) +ON CONFLICT (session_id) DO UPDATE SET device_id = session_devices.device_id +WHERE session_devices.device_id = EXCLUDED.device_id +RETURNING device_id +` + +type BindSessionDeviceParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + ID_2 pgtype.UUID `json:"id_2"` +} + +func (q *Queries) BindSessionDevice(ctx context.Context, arg BindSessionDeviceParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, bindSessionDevice, arg.TenantID, arg.ID, arg.ID_2) + var device_id pgtype.UUID + err := row.Scan(&device_id) + return device_id, err +} + +const createDevice = `-- name: CreateDevice :one +INSERT INTO devices (id, tenant_id, name, credential_hash) +VALUES ($1, $2, $3, $4) RETURNING id +` + +type CreateDeviceParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + CredentialHash string `json:"credential_hash"` +} + +func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, createDevice, + arg.ID, + arg.TenantID, + arg.Name, + arg.CredentialHash, + ) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getDevice = `-- name: GetDevice :one +SELECT id, name FROM devices WHERE tenant_id = $1 AND id = $2 AND revoked_at IS NULL +` + +type GetDeviceParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetDeviceRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) GetDevice(ctx context.Context, arg GetDeviceParams) (GetDeviceRow, error) { + row := q.db.QueryRow(ctx, getDevice, arg.TenantID, arg.ID) + var i GetDeviceRow + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const getDeviceCredential = `-- name: GetDeviceCredential :one +SELECT d.id, d.name, d.credential_hash FROM devices d +WHERE d.id = $1 AND d.revoked_at IS NULL AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = d.environment_id AND s.tenant_id = d.tenant_id AND s.deleted_at IS NULL +)) +` + +type GetDeviceCredentialRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + CredentialHash string `json:"credential_hash"` +} + +func (q *Queries) GetDeviceCredential(ctx context.Context, id pgtype.UUID) (GetDeviceCredentialRow, error) { + row := q.db.QueryRow(ctx, getDeviceCredential, id) + var i GetDeviceCredentialRow + err := row.Scan(&i.ID, &i.Name, &i.CredentialHash) + return i, err +} + +const getSessionDevice = `-- name: GetSessionDevice :one +SELECT d.id, d.name, d.environment_id FROM session_devices b +JOIN sessions s ON s.id = b.session_id +JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)) +` + +type GetSessionDeviceParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetSessionDeviceRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) GetSessionDevice(ctx context.Context, arg GetSessionDeviceParams) (GetSessionDeviceRow, error) { + row := q.db.QueryRow(ctx, getSessionDevice, arg.TenantID, arg.ID) + var i GetSessionDeviceRow + err := row.Scan(&i.ID, &i.Name, &i.EnvironmentID) + return i, err +} + +const getSessionExecutionBinding = `-- name: GetSessionExecutionBinding :one +SELECT d.id, d.name, b.native_session_id, d.environment_id, + EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.id AND t.started_at IS NOT NULL) AS has_started_turn +FROM session_devices b +JOIN sessions s ON s.id = b.session_id +JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id +WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND (d.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id +)) +` + +type GetSessionExecutionBindingParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetSessionExecutionBindingRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + NativeSessionID string `json:"native_session_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + HasStartedTurn bool `json:"has_started_turn"` +} + +func (q *Queries) GetSessionExecutionBinding(ctx context.Context, arg GetSessionExecutionBindingParams) (GetSessionExecutionBindingRow, error) { + row := q.db.QueryRow(ctx, getSessionExecutionBinding, arg.TenantID, arg.ID) + var i GetSessionExecutionBindingRow + err := row.Scan( + &i.ID, + &i.Name, + &i.NativeSessionID, + &i.EnvironmentID, + &i.HasStartedTurn, + ) + return i, err +} + +const rememberNativeSession = `-- name: RememberNativeSession :execrows +UPDATE session_devices SET native_session_id = $2 WHERE session_id = $1 +` + +type RememberNativeSessionParams struct { + SessionID pgtype.UUID `json:"session_id"` + NativeSessionID string `json:"native_session_id"` +} + +func (q *Queries) RememberNativeSession(ctx context.Context, arg RememberNativeSessionParams) (int64, error) { + result, err := q.db.Exec(ctx, rememberNativeSession, arg.SessionID, arg.NativeSessionID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const revokeDevice = `-- name: RevokeDevice :execrows +UPDATE devices SET revoked_at = COALESCE(revoked_at, clock_timestamp()) +WHERE tenant_id = $1 AND id = $2 +` + +type RevokeDeviceParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) RevokeDevice(ctx context.Context, arg RevokeDeviceParams) (int64, error) { + result, err := q.db.Exec(ctx, revokeDevice, arg.TenantID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const touchDevice = `-- name: TouchDevice :execrows +UPDATE devices SET last_seen_at = clock_timestamp() +WHERE devices.id = $1 AND devices.revoked_at IS NULL AND (devices.environment_id IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = devices.environment_id AND s.tenant_id = devices.tenant_id AND s.deleted_at IS NULL +)) +` + +func (q *Queries) TouchDevice(ctx context.Context, id pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, touchDevice, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/services/agents-api/internal/db/sqlc/environment_connections.sql.go b/services/agents-api/internal/db/sqlc/environment_connections.sql.go new file mode 100644 index 000000000..7d850cf27 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_connections.sql.go @@ -0,0 +1,112 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_connections.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceEnvironmentConnection = `-- name: AdvanceEnvironmentConnection :exec +UPDATE environment_connections SET revision = $2 WHERE environment_id = $1 +` + +type AdvanceEnvironmentConnectionParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + Revision int64 `json:"revision"` +} + +func (q *Queries) AdvanceEnvironmentConnection(ctx context.Context, arg AdvanceEnvironmentConnectionParams) error { + _, err := q.db.Exec(ctx, advanceEnvironmentConnection, arg.EnvironmentID, arg.Revision) + return err +} + +const deleteEnvironmentConnection = `-- name: DeleteEnvironmentConnection :exec +DELETE FROM environment_connections WHERE environment_id = $1 +` + +func (q *Queries) DeleteEnvironmentConnection(ctx context.Context, environmentID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteEnvironmentConnection, environmentID) + return err +} + +const getEnvironmentConnection = `-- name: GetEnvironmentConnection :one +SELECT environment_id, generation, revision FROM environment_connections WHERE environment_id = $1 +` + +func (q *Queries) GetEnvironmentConnection(ctx context.Context, environmentID pgtype.UUID) (EnvironmentConnection, error) { + row := q.db.QueryRow(ctx, getEnvironmentConnection, environmentID) + var i EnvironmentConnection + err := row.Scan(&i.EnvironmentID, &i.Generation, &i.Revision) + return i, err +} + +const listEnvironmentConnections = `-- name: ListEnvironmentConnections :many +SELECT e.id, e.session_id, s.tenant_id +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE e.id > $1 AND s.deleted_at IS NULL + AND (e.status = 'connected' OR EXISTS ( + SELECT 1 FROM environment_connections c WHERE c.environment_id = e.id + )) +ORDER BY e.id LIMIT 32 +` + +type ListEnvironmentConnectionsRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` +} + +func (q *Queries) ListEnvironmentConnections(ctx context.Context, id pgtype.UUID) ([]ListEnvironmentConnectionsRow, error) { + rows, err := q.db.Query(ctx, listEnvironmentConnections, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnvironmentConnectionsRow{} + for rows.Next() { + var i ListEnvironmentConnectionsRow + if err := rows.Scan(&i.ID, &i.SessionID, &i.TenantID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const replaceEnvironmentConnection = `-- name: ReplaceEnvironmentConnection :exec +INSERT INTO environment_connections (environment_id, generation, revision) +VALUES ($1, $2, 0) +ON CONFLICT (environment_id) DO UPDATE SET generation = EXCLUDED.generation, revision = 0 +` + +type ReplaceEnvironmentConnectionParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + Generation pgtype.UUID `json:"generation"` +} + +func (q *Queries) ReplaceEnvironmentConnection(ctx context.Context, arg ReplaceEnvironmentConnectionParams) error { + _, err := q.db.Exec(ctx, replaceEnvironmentConnection, arg.EnvironmentID, arg.Generation) + return err +} + +const setEnvironmentConnectionStatus = `-- name: SetEnvironmentConnectionStatus :exec +UPDATE environments SET status = $2 WHERE id = $1 +` + +type SetEnvironmentConnectionStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) SetEnvironmentConnectionStatus(ctx context.Context, arg SetEnvironmentConnectionStatusParams) error { + _, err := q.db.Exec(ctx, setEnvironmentConnectionStatus, arg.ID, arg.Status) + return err +} diff --git a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go new file mode 100644 index 000000000..0f9e24144 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go @@ -0,0 +1,197 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_executor_credentials.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const authenticateEnvironmentExecutor = `-- name: AuthenticateEnvironmentExecutor :one +SELECT s.tenant_id +FROM environment_executor_credentials c +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +JOIN environments e ON e.id = $1 +JOIN sessions s ON s.id = e.session_id +WHERE c.token_sha256 = $2 AND c.revoked_at IS NULL + AND c.tenant_id = s.tenant_id AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL +` + +type AuthenticateEnvironmentExecutorParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + TokenSha256 string `json:"token_sha256"` +} + +func (q *Queries) AuthenticateEnvironmentExecutor(ctx context.Context, arg AuthenticateEnvironmentExecutorParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, authenticateEnvironmentExecutor, arg.EnvironmentID, arg.TokenSha256) + var tenant_id pgtype.UUID + err := row.Scan(&tenant_id) + return tenant_id, err +} + +const executorProjectScopeExists = `-- name: ExecutorProjectScopeExists :one +SELECT EXISTS ( + SELECT 1 FROM execution_project_scopes + WHERE tenant_id = $1 AND organization_id = $2 AND project_id = $3 +) +` + +type ExecutorProjectScopeExistsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +func (q *Queries) ExecutorProjectScopeExists(ctx context.Context, arg ExecutorProjectScopeExistsParams) (bool, error) { + row := q.db.QueryRow(ctx, executorProjectScopeExists, arg.TenantID, arg.OrganizationID, arg.ProjectID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const getExecutorCredentialForPrincipal = `-- name: GetExecutorCredentialForPrincipal :one +SELECT c.environment_id +FROM environment_executor_credentials c +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +WHERE c.key_id = $1 AND c.tenant_id = $2 + AND c.subject_kind = $3 AND c.subject_id = $4 + AND p.organization_id = $5 AND p.project_id = $6 +` + +type GetExecutorCredentialForPrincipalParams struct { + KeyID pgtype.UUID `json:"key_id"` + TenantID pgtype.UUID `json:"tenant_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +func (q *Queries) GetExecutorCredentialForPrincipal(ctx context.Context, arg GetExecutorCredentialForPrincipalParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, getExecutorCredentialForPrincipal, + arg.KeyID, + arg.TenantID, + arg.SubjectKind, + arg.SubjectID, + arg.OrganizationID, + arg.ProjectID, + ) + var environment_id pgtype.UUID + err := row.Scan(&environment_id) + return environment_id, err +} + +const issueExecutorCredential = `-- name: IssueExecutorCredential :one +WITH issued AS (SELECT clock_timestamp() AS at) +INSERT INTO environment_executor_credentials + (key_id, tenant_id, subject_kind, subject_id, environment_id, token_sha256, created_at, issued_at) +SELECT $1, p.tenant_id, $2, $3, + $4::uuid, $5, issued.at, issued.at +FROM execution_project_scopes p CROSS JOIN issued +WHERE p.tenant_id = $6 AND p.organization_id = $7 + AND p.project_id = $8 + AND ($4::uuid IS NULL OR EXISTS ( + SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + WHERE e.id = $4 AND s.tenant_id = p.tenant_id AND s.deleted_at IS NULL + AND s.creator_kind = $2 AND s.creator_id = $3 + )) +ON CONFLICT (key_id) DO NOTHING +RETURNING key_id, environment_id +` + +type IssueExecutorCredentialParams struct { + KeyID pgtype.UUID `json:"key_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + TokenSha256 string `json:"token_sha256"` + TenantID pgtype.UUID `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +type IssueExecutorCredentialRow struct { + KeyID pgtype.UUID `json:"key_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) IssueExecutorCredential(ctx context.Context, arg IssueExecutorCredentialParams) (IssueExecutorCredentialRow, error) { + row := q.db.QueryRow(ctx, issueExecutorCredential, + arg.KeyID, + arg.SubjectKind, + arg.SubjectID, + arg.EnvironmentID, + arg.TokenSha256, + arg.TenantID, + arg.OrganizationID, + arg.ProjectID, + ) + var i IssueExecutorCredentialRow + err := row.Scan(&i.KeyID, &i.EnvironmentID) + return i, err +} + +const revokeExecutorCredential = `-- name: RevokeExecutorCredential :execrows +UPDATE environment_executor_credentials SET revoked_at = COALESCE(revoked_at, clock_timestamp()) +WHERE key_id = $1 AND tenant_id = $2 + AND subject_kind = $3 AND subject_id = $4 +` + +type RevokeExecutorCredentialParams struct { + KeyID pgtype.UUID `json:"key_id"` + TenantID pgtype.UUID `json:"tenant_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` +} + +func (q *Queries) RevokeExecutorCredential(ctx context.Context, arg RevokeExecutorCredentialParams) (int64, error) { + result, err := q.db.Exec(ctx, revokeExecutorCredential, + arg.KeyID, + arg.TenantID, + arg.SubjectKind, + arg.SubjectID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const rotateExecutorCredential = `-- name: RotateExecutorCredential :one +UPDATE environment_executor_credentials +SET token_sha256 = $1, issued_at = clock_timestamp(), revoked_at = NULL +WHERE key_id = $2 AND tenant_id = $3 + AND subject_kind = $4 AND subject_id = $5 +RETURNING key_id, environment_id +` + +type RotateExecutorCredentialParams struct { + TokenSha256 string `json:"token_sha256"` + KeyID pgtype.UUID `json:"key_id"` + TenantID pgtype.UUID `json:"tenant_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` +} + +type RotateExecutorCredentialRow struct { + KeyID pgtype.UUID `json:"key_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) RotateExecutorCredential(ctx context.Context, arg RotateExecutorCredentialParams) (RotateExecutorCredentialRow, error) { + row := q.db.QueryRow(ctx, rotateExecutorCredential, + arg.TokenSha256, + arg.KeyID, + arg.TenantID, + arg.SubjectKind, + arg.SubjectID, + ) + var i RotateExecutorCredentialRow + err := row.Scan(&i.KeyID, &i.EnvironmentID) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go b/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go new file mode 100644 index 000000000..470dbde04 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go @@ -0,0 +1,132 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_file_writes.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentFileWrite = `-- name: CreateEnvironmentFileWrite :one +INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256) +VALUES ($1, $2, $3, $4) RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at +` + +type CreateEnvironmentFileWriteParams struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + DeviceID pgtype.UUID `json:"device_id"` + RequestSha256 string `json:"request_sha256"` +} + +func (q *Queries) CreateEnvironmentFileWrite(ctx context.Context, arg CreateEnvironmentFileWriteParams) (EnvironmentFileWrite, error) { + row := q.db.QueryRow(ctx, createEnvironmentFileWrite, + arg.ID, + arg.EnvironmentID, + arg.DeviceID, + arg.RequestSha256, + ) + var i EnvironmentFileWrite + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.RequestSha256, + &i.State, + &i.CreatedAt, + &i.SettledAt, + ) + return i, err +} + +const environmentFileWriteBlocksSession = `-- name: EnvironmentFileWriteBlocksSession :one +SELECT EXISTS ( + SELECT 1 FROM environments e JOIN environment_file_writes w ON w.environment_id = e.id + WHERE e.session_id = $1 AND w.state = 'pending' +)::boolean +` + +func (q *Queries) EnvironmentFileWriteBlocksSession(ctx context.Context, sessionID pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, environmentFileWriteBlocksSession, sessionID) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const environmentFileWriteHasPendingInput = `-- name: EnvironmentFileWriteHasPendingInput :one +SELECT EXISTS ( + SELECT 1 FROM environment_input_reservations WHERE session_id = $1 AND state = 'pending' +)::boolean +` + +func (q *Queries) EnvironmentFileWriteHasPendingInput(ctx context.Context, sessionID pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, environmentFileWriteHasPendingInput, sessionID) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const getEnvironmentFileWrite = `-- name: GetEnvironmentFileWrite :one +SELECT w.id, w.environment_id, w.device_id, w.request_sha256, w.state, w.created_at, w.settled_at, e.session_id +FROM environment_file_writes w +JOIN environments e ON e.id = w.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND e.id = $2 AND w.id = $3 +` + +type GetEnvironmentFileWriteParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + ID pgtype.UUID `json:"id"` +} + +type GetEnvironmentFileWriteRow struct { + EnvironmentFileWrite EnvironmentFileWrite `json:"environment_file_write"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) GetEnvironmentFileWrite(ctx context.Context, arg GetEnvironmentFileWriteParams) (GetEnvironmentFileWriteRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentFileWrite, arg.TenantID, arg.EnvironmentID, arg.ID) + var i GetEnvironmentFileWriteRow + err := row.Scan( + &i.EnvironmentFileWrite.ID, + &i.EnvironmentFileWrite.EnvironmentID, + &i.EnvironmentFileWrite.DeviceID, + &i.EnvironmentFileWrite.RequestSha256, + &i.EnvironmentFileWrite.State, + &i.EnvironmentFileWrite.CreatedAt, + &i.EnvironmentFileWrite.SettledAt, + &i.SessionID, + ) + return i, err +} + +const settleEnvironmentFileWrite = `-- name: SettleEnvironmentFileWrite :one +UPDATE environment_file_writes SET state = $3, settled_at = clock_timestamp() +WHERE environment_id = $1 AND id = $2 AND state = 'pending' RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at +` + +type SettleEnvironmentFileWriteParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + ID pgtype.UUID `json:"id"` + State string `json:"state"` +} + +func (q *Queries) SettleEnvironmentFileWrite(ctx context.Context, arg SettleEnvironmentFileWriteParams) (EnvironmentFileWrite, error) { + row := q.db.QueryRow(ctx, settleEnvironmentFileWrite, arg.EnvironmentID, arg.ID, arg.State) + var i EnvironmentFileWrite + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.RequestSha256, + &i.State, + &i.CreatedAt, + &i.SettledAt, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go b/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go new file mode 100644 index 000000000..9d2f3ac4a --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go @@ -0,0 +1,54 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_input_activity.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getEnvironmentInputActivity = `-- name: GetEnvironmentInputActivity :one +SELECT r.state, r.is_initial, r.created_at, r.settled_at, e.id AS environment_id, e.status AS connection_status, + COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type +FROM environments e +JOIN sessions s ON s.id = e.session_id +JOIN LATERAL ( + SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial FROM environment_input_reservations + WHERE session_id = e.session_id + ORDER BY created_at DESC, id DESC LIMIT 1 +) r ON true +WHERE e.session_id = $1 AND r.state <> 'admitted' + AND NOT EXISTS ( + SELECT 1 FROM turns t WHERE t.session_id = e.session_id + AND (t.created_at >= r.created_at OR t.status IN ('queued', 'in_progress', 'waiting')) + ) +` + +type GetEnvironmentInputActivityRow struct { + State string `json:"state"` + IsInitial bool `json:"is_initial"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + SettledAt pgtype.Timestamptz `json:"settled_at"` + EnvironmentID pgtype.UUID `json:"environment_id"` + ConnectionStatus string `json:"connection_status"` + EnvironmentType string `json:"environment_type"` +} + +func (q *Queries) GetEnvironmentInputActivity(ctx context.Context, sessionID pgtype.UUID) (GetEnvironmentInputActivityRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentInputActivity, sessionID) + var i GetEnvironmentInputActivityRow + err := row.Scan( + &i.State, + &i.IsInitial, + &i.CreatedAt, + &i.SettledAt, + &i.EnvironmentID, + &i.ConnectionStatus, + &i.EnvironmentType, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go b/services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go new file mode 100644 index 000000000..a1fbacf18 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go @@ -0,0 +1,49 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_input_expiry.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listDueEnvironmentInputs = `-- name: ListDueEnvironmentInputs :many +SELECT r.id, r.session_id +FROM environment_input_reservations r +JOIN sessions s ON s.id = r.session_id +WHERE r.state = 'pending' + AND r.deadline <= statement_timestamp() + AND s.deleted_at IS NULL +ORDER BY r.deadline, r.id +LIMIT 32 +FOR UPDATE OF s SKIP LOCKED +` + +type ListDueEnvironmentInputsRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) ListDueEnvironmentInputs(ctx context.Context) ([]ListDueEnvironmentInputsRow, error) { + rows, err := q.db.Query(ctx, listDueEnvironmentInputs) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListDueEnvironmentInputsRow{} + for rows.Next() { + var i ListDueEnvironmentInputsRow + if err := rows.Scan(&i.ID, &i.SessionID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/db/sqlc/environment_inputs.sql.go b/services/agents-api/internal/db/sqlc/environment_inputs.sql.go new file mode 100644 index 000000000..bf3c88b87 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_inputs.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_inputs.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const cancelSessionEnvironmentInput = `-- name: CancelSessionEnvironmentInput :exec +UPDATE environment_input_reservations SET state = 'cancelled', settled_at = clock_timestamp() +WHERE session_id = $1 AND state = 'pending' +` + +func (q *Queries) CancelSessionEnvironmentInput(ctx context.Context, sessionID pgtype.UUID) error { + _, err := q.db.Exec(ctx, cancelSessionEnvironmentInput, sessionID) + return err +} + +const checkEnvironmentInputGate = `-- name: CheckEnvironmentInputGate :one +SELECT COALESCE(( + SELECT r.batch = $1::jsonb FROM environment_input_reservations r + WHERE r.session_id = $2 AND r.idempotency_key = $3 +), true)::boolean AS matches, +EXISTS ( + SELECT 1 FROM environment_input_reservations r + WHERE r.session_id = $2 + AND (r.state = 'pending' OR r.idempotency_key = $3) +)::boolean AS blocked +` + +type CheckEnvironmentInputGateParams struct { + Batch []byte `json:"batch"` + SessionID pgtype.UUID `json:"session_id"` + IdempotencyKey string `json:"idempotency_key"` +} + +type CheckEnvironmentInputGateRow struct { + Matches bool `json:"matches"` + Blocked bool `json:"blocked"` +} + +func (q *Queries) CheckEnvironmentInputGate(ctx context.Context, arg CheckEnvironmentInputGateParams) (CheckEnvironmentInputGateRow, error) { + row := q.db.QueryRow(ctx, checkEnvironmentInputGate, arg.Batch, arg.SessionID, arg.IdempotencyKey) + var i CheckEnvironmentInputGateRow + err := row.Scan(&i.Matches, &i.Blocked) + return i, err +} + +const createEnvironmentInputReservation = `-- name: CreateEnvironmentInputReservation :one +WITH accepted AS (SELECT clock_timestamp() AS at) +INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, created_at, deadline) +SELECT $1, $2, $3, $4, $5, at, at + interval '5 minutes' FROM accepted +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial +` + +type CreateEnvironmentInputReservationParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + IdempotencyKey string `json:"idempotency_key"` + Batch []byte `json:"batch"` + IsInitial bool `json:"is_initial"` +} + +func (q *Queries) CreateEnvironmentInputReservation(ctx context.Context, arg CreateEnvironmentInputReservationParams) (EnvironmentInputReservation, error) { + row := q.db.QueryRow(ctx, createEnvironmentInputReservation, + arg.ID, + arg.SessionID, + arg.IdempotencyKey, + arg.Batch, + arg.IsInitial, + ) + var i EnvironmentInputReservation + err := row.Scan( + &i.ID, + &i.SessionID, + &i.IdempotencyKey, + &i.Batch, + &i.State, + &i.CreatedAt, + &i.Deadline, + &i.SettledAt, + &i.IsInitial, + ) + return i, err +} + +const expireEnvironmentInputReservation = `-- name: ExpireEnvironmentInputReservation :exec +UPDATE environment_input_reservations SET state = 'expired', settled_at = clock_timestamp() +WHERE session_id = $1 AND id = $2 AND state = 'pending' AND deadline <= clock_timestamp() +` + +type ExpireEnvironmentInputReservationParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) ExpireEnvironmentInputReservation(ctx context.Context, arg ExpireEnvironmentInputReservationParams) error { + _, err := q.db.Exec(ctx, expireEnvironmentInputReservation, arg.SessionID, arg.ID) + return err +} + +const failSessionEnvironmentInput = `-- name: FailSessionEnvironmentInput :exec +UPDATE environment_input_reservations SET state = 'failed', settled_at = clock_timestamp() +WHERE session_id = $1 AND state = 'pending' +` + +func (q *Queries) FailSessionEnvironmentInput(ctx context.Context, sessionID pgtype.UUID) error { + _, err := q.db.Exec(ctx, failSessionEnvironmentInput, sessionID) + return err +} + +const findEnvironmentInputReservation = `-- name: FindEnvironmentInputReservation :one +SELECT r.id, r.session_id, r.idempotency_key, r.batch, r.state, r.created_at, r.deadline, r.settled_at, r.is_initial, r.batch = $1::jsonb AS matches +FROM environment_input_reservations r +WHERE r.session_id = $2 AND r.idempotency_key = $3 +` + +type FindEnvironmentInputReservationParams struct { + Batch []byte `json:"batch"` + SessionID pgtype.UUID `json:"session_id"` + IdempotencyKey string `json:"idempotency_key"` +} + +type FindEnvironmentInputReservationRow struct { + EnvironmentInputReservation EnvironmentInputReservation `json:"environment_input_reservation"` + Matches bool `json:"matches"` +} + +func (q *Queries) FindEnvironmentInputReservation(ctx context.Context, arg FindEnvironmentInputReservationParams) (FindEnvironmentInputReservationRow, error) { + row := q.db.QueryRow(ctx, findEnvironmentInputReservation, arg.Batch, arg.SessionID, arg.IdempotencyKey) + var i FindEnvironmentInputReservationRow + err := row.Scan( + &i.EnvironmentInputReservation.ID, + &i.EnvironmentInputReservation.SessionID, + &i.EnvironmentInputReservation.IdempotencyKey, + &i.EnvironmentInputReservation.Batch, + &i.EnvironmentInputReservation.State, + &i.EnvironmentInputReservation.CreatedAt, + &i.EnvironmentInputReservation.Deadline, + &i.EnvironmentInputReservation.SettledAt, + &i.EnvironmentInputReservation.IsInitial, + &i.Matches, + ) + return i, err +} + +const getEnvironmentInputReservation = `-- name: GetEnvironmentInputReservation :one +SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial FROM environment_input_reservations +WHERE session_id = $1 AND id = $2 +` + +type GetEnvironmentInputReservationParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetEnvironmentInputReservation(ctx context.Context, arg GetEnvironmentInputReservationParams) (EnvironmentInputReservation, error) { + row := q.db.QueryRow(ctx, getEnvironmentInputReservation, arg.SessionID, arg.ID) + var i EnvironmentInputReservation + err := row.Scan( + &i.ID, + &i.SessionID, + &i.IdempotencyKey, + &i.Batch, + &i.State, + &i.CreatedAt, + &i.Deadline, + &i.SettledAt, + &i.IsInitial, + ) + return i, err +} + +const settleEnvironmentInputReservation = `-- name: SettleEnvironmentInputReservation :one +UPDATE environment_input_reservations SET state = $1, settled_at = clock_timestamp() +WHERE session_id = $2 AND id = $3 AND state = 'pending' +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial +` + +type SettleEnvironmentInputReservationParams struct { + State string `json:"state"` + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) SettleEnvironmentInputReservation(ctx context.Context, arg SettleEnvironmentInputReservationParams) (EnvironmentInputReservation, error) { + row := q.db.QueryRow(ctx, settleEnvironmentInputReservation, arg.State, arg.SessionID, arg.ID) + var i EnvironmentInputReservation + err := row.Scan( + &i.ID, + &i.SessionID, + &i.IdempotencyKey, + &i.Batch, + &i.State, + &i.CreatedAt, + &i.Deadline, + &i.SettledAt, + &i.IsInitial, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environment_setup.sql.go b/services/agents-api/internal/db/sqlc/environment_setup.sql.go new file mode 100644 index 000000000..a0b20ae00 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_setup.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_setup.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentSetup = `-- name: CreateEnvironmentSetup :exec +INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2) +` + +type CreateEnvironmentSetupParams struct { + SessionID pgtype.UUID `json:"session_id"` + Contents []byte `json:"contents"` +} + +func (q *Queries) CreateEnvironmentSetup(ctx context.Context, arg CreateEnvironmentSetupParams) error { + _, err := q.db.Exec(ctx, createEnvironmentSetup, arg.SessionID, arg.Contents) + return err +} + +const getEnvironmentSetup = `-- name: GetEnvironmentSetup :one +SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL +` + +type GetEnvironmentSetupParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSetupParams) ([]byte, error) { + row := q.db.QueryRow(ctx, getEnvironmentSetup, arg.TenantID, arg.ID) + var contents []byte + err := row.Scan(&contents) + return contents, err +} + +const setSessionSetupMetadata = `-- name: SetSessionSetupMetadata :one +UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +WHERE id = $1 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +` + +type SetSessionSetupMetadataParams struct { + ID pgtype.UUID `json:"id"` + Column2 []byte `json:"column_2"` +} + +func (q *Queries) SetSessionSetupMetadata(ctx context.Context, arg SetSessionSetupMetadataParams) (Session, error) { + row := q.db.QueryRow(ctx, setSessionSetupMetadata, arg.ID, arg.Column2) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environment_templates.sql.go b/services/agents-api/internal/db/sqlc/environment_templates.sql.go new file mode 100644 index 000000000..49755205b --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_templates.sql.go @@ -0,0 +1,311 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_templates.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentTemplate = `-- name: CreateEnvironmentTemplate :one +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills +` + +type CreateEnvironmentTemplateParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` +} + +type CreateEnvironmentTemplateRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` +} + +func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (CreateEnvironmentTemplateRow, error) { + row := q.db.QueryRow(ctx, createEnvironmentTemplate, + arg.ID, + arg.TenantID, + arg.Name, + arg.NetworkAccess, + arg.Files, + arg.FileContents, + arg.Packages, + arg.EnvContents, + arg.SetupContents, + arg.Skills, + arg.SkillContents, + ) + var i CreateEnvironmentTemplateRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + &i.Files, + &i.Packages, + &i.Skills, + ) + return i, err +} + +const deleteEnvironmentTemplate = `-- name: DeleteEnvironmentTemplate :one +DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteEnvironmentTemplateParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvironmentTemplateParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteEnvironmentTemplate, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2 +` + +type GetEnvironmentTemplateParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetEnvironmentTemplateRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` +} + +func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (GetEnvironmentTemplateRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentTemplate, arg.TenantID, arg.ID) + var i GetEnvironmentTemplateRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + &i.Files, + &i.Packages, + &i.Skills, + ) + return i, err +} + +const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates +WHERE tenant_id = $1 + AND ($2::timestamptz IS NULL + OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) + OR ($3::boolean AND (created_at, id) > ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN created_at END ASC, + CASE WHEN $3::boolean THEN id END ASC, + CASE WHEN NOT $3::boolean THEN created_at END DESC, + CASE WHEN NOT $3::boolean THEN id END DESC +LIMIT $5 +` + +type ListEnvironmentTemplatesParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListEnvironmentTemplatesRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` +} + +func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]ListEnvironmentTemplatesRow, error) { + rows, err := q.db.Query(ctx, listEnvironmentTemplates, + arg.TenantID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnvironmentTemplatesRow{} + for rows.Next() { + var i ListEnvironmentTemplatesRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + &i.Files, + &i.Packages, + &i.Skills, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveEnvironmentTemplate = `-- name: ResolveEnvironmentTemplate :one +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents FROM environment_templates WHERE tenant_id = $1 AND id = $2 +` + +type ResolveEnvironmentTemplateParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) ResolveEnvironmentTemplate(ctx context.Context, arg ResolveEnvironmentTemplateParams) (EnvironmentTemplate, error) { + row := q.db.QueryRow(ctx, resolveEnvironmentTemplate, arg.TenantID, arg.ID) + var i EnvironmentTemplate + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + &i.Files, + &i.FileContents, + &i.Packages, + &i.EnvContents, + &i.SetupContents, + &i.Skills, + &i.SkillContents, + ) + return i, err +} + +const updateEnvironmentTemplate = `-- name: UpdateEnvironmentTemplate :one +UPDATE environment_templates SET + name = CASE WHEN $1::boolean THEN $2::text ELSE name END, + network_access = CASE WHEN $3::boolean THEN $4::text ELSE network_access END, + files = CASE WHEN $5::boolean THEN $6::jsonb ELSE files END, + file_contents = CASE WHEN $5::boolean THEN $7::bytea ELSE file_contents END, + packages = CASE WHEN $8::boolean THEN $9::jsonb ELSE packages END, + env_contents = CASE WHEN $10::boolean THEN $11::bytea ELSE env_contents END, + setup_contents = CASE WHEN $12::boolean THEN $13::bytea ELSE setup_contents END, + skills = CASE WHEN $14::boolean THEN $15::jsonb ELSE skills END, + skill_contents = CASE WHEN $14::boolean THEN $16::bytea ELSE skill_contents END, + updated_at = clock_timestamp() +WHERE tenant_id = $17 AND id = $18 +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills +` + +type UpdateEnvironmentTemplateParams struct { + SetName bool `json:"set_name"` + Name pgtype.Text `json:"name"` + SetNetwork bool `json:"set_network"` + NetworkAccess string `json:"network_access"` + SetFiles bool `json:"set_files"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + SetPackages bool `json:"set_packages"` + Packages []byte `json:"packages"` + SetEnv bool `json:"set_env"` + EnvContents []byte `json:"env_contents"` + SetSetup bool `json:"set_setup"` + SetupContents []byte `json:"setup_contents"` + SetSkills bool `json:"set_skills"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type UpdateEnvironmentTemplateRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` +} + +func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (UpdateEnvironmentTemplateRow, error) { + row := q.db.QueryRow(ctx, updateEnvironmentTemplate, + arg.SetName, + arg.Name, + arg.SetNetwork, + arg.NetworkAccess, + arg.SetFiles, + arg.Files, + arg.FileContents, + arg.SetPackages, + arg.Packages, + arg.SetEnv, + arg.EnvContents, + arg.SetSetup, + arg.SetupContents, + arg.SetSkills, + arg.Skills, + arg.SkillContents, + arg.TenantID, + arg.ID, + ) + var i UpdateEnvironmentTemplateRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + &i.Files, + &i.Packages, + &i.Skills, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/environments.sql.go b/services/agents-api/internal/db/sqlc/environments.sql.go new file mode 100644 index 000000000..db51912fc --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environments.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environments.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironment = `-- name: CreateEnvironment :exec +INSERT INTO environments (id, session_id) VALUES ($1, $2) +` + +type CreateEnvironmentParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) CreateEnvironment(ctx context.Context, arg CreateEnvironmentParams) error { + _, err := q.db.Exec(ctx, createEnvironment, arg.ID, arg.SessionID) + return err +} + +const getEnvironment = `-- name: GetEnvironment :one +SELECT e.id, e.session_id, e.status, e.created_at, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL +` + +type GetEnvironmentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetEnvironmentRow struct { + Environment Environment `json:"environment"` + TenantID pgtype.UUID `json:"tenant_id"` + Configuration []byte `json:"configuration"` +} + +func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams) (GetEnvironmentRow, error) { + row := q.db.QueryRow(ctx, getEnvironment, arg.TenantID, arg.ID) + var i GetEnvironmentRow + err := row.Scan( + &i.Environment.ID, + &i.Environment.SessionID, + &i.Environment.Status, + &i.Environment.CreatedAt, + &i.TenantID, + &i.Configuration, + ) + return i, err +} + +const getSessionEnvironment = `-- name: GetSessionEnvironment :one +SELECT e.id, e.session_id, e.status, e.created_at, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL +` + +type GetSessionEnvironmentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetSessionEnvironmentRow struct { + Environment Environment `json:"environment"` + TenantID pgtype.UUID `json:"tenant_id"` + Configuration []byte `json:"configuration"` +} + +func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvironmentParams) (GetSessionEnvironmentRow, error) { + row := q.db.QueryRow(ctx, getSessionEnvironment, arg.TenantID, arg.ID) + var i GetSessionEnvironmentRow + err := row.Scan( + &i.Environment.ID, + &i.Environment.SessionID, + &i.Environment.Status, + &i.Environment.CreatedAt, + &i.TenantID, + &i.Configuration, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/functions.sql.go b/services/agents-api/internal/db/sqlc/functions.sql.go new file mode 100644 index 000000000..377cc0429 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/functions.sql.go @@ -0,0 +1,231 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: functions.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const applyFunctionResult = `-- name: ApplyFunctionResult :exec +UPDATE function_calls SET applied = true WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 +` + +type ApplyFunctionResultParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` +} + +func (q *Queries) ApplyFunctionResult(ctx context.Context, arg ApplyFunctionResultParams) error { + _, err := q.db.Exec(ctx, applyFunctionResult, arg.SessionID, arg.TurnID, arg.CallID) + return err +} + +const createFunctionCall = `-- name: CreateFunctionCall :execrows +INSERT INTO function_calls(session_id, turn_id, call_id, executor_call_id, name, arguments) +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING +` + +type CreateFunctionCallParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` + ExecutorCallID string `json:"executor_call_id"` + Name string `json:"name"` + Arguments []byte `json:"arguments"` +} + +func (q *Queries) CreateFunctionCall(ctx context.Context, arg CreateFunctionCallParams) (int64, error) { + result, err := q.db.Exec(ctx, createFunctionCall, + arg.SessionID, + arg.TurnID, + arg.CallID, + arg.ExecutorCallID, + arg.Name, + arg.Arguments, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const functionItemResult = `-- name: FunctionItemResult :one +SELECT result FROM function_calls +WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 +` + +type FunctionItemResultParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` +} + +func (q *Queries) FunctionItemResult(ctx context.Context, arg FunctionItemResultParams) ([]byte, error) { + row := q.db.QueryRow(ctx, functionItemResult, arg.SessionID, arg.TurnID, arg.CallID) + var result []byte + err := row.Scan(&result) + return result, err +} + +const getFunctionCall = `-- name: GetFunctionCall :one +SELECT f.session_id, f.turn_id, f.call_id, f.executor_call_id, f.name, f.arguments, f.result, f.applied, f.created_at FROM function_calls f JOIN sessions s ON s.id = f.session_id +WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.turn_id = $3 AND f.call_id = $4 +` + +type GetFunctionCallParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` +} + +func (q *Queries) GetFunctionCall(ctx context.Context, arg GetFunctionCallParams) (FunctionCall, error) { + row := q.db.QueryRow(ctx, getFunctionCall, + arg.TenantID, + arg.SessionID, + arg.TurnID, + arg.CallID, + ) + var i FunctionCall + err := row.Scan( + &i.SessionID, + &i.TurnID, + &i.CallID, + &i.ExecutorCallID, + &i.Name, + &i.Arguments, + &i.Result, + &i.Applied, + &i.CreatedAt, + ) + return i, err +} + +const listPendingFunctionCalls = `-- name: ListPendingFunctionCalls :many +SELECT f.session_id, f.turn_id, f.call_id, f.executor_call_id, f.name, f.arguments, f.result, f.applied, f.created_at FROM function_calls f JOIN turns t ON t.session_id = f.session_id AND t.id = f.turn_id +WHERE f.session_id = $1 AND f.turn_id = $2 AND NOT f.applied + AND t.status IN ('in_progress', 'waiting') AND t.cancel_requested_at IS NULL +ORDER BY f.created_at, f.call_id +` + +type ListPendingFunctionCallsParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` +} + +func (q *Queries) ListPendingFunctionCalls(ctx context.Context, arg ListPendingFunctionCallsParams) ([]FunctionCall, error) { + rows, err := q.db.Query(ctx, listPendingFunctionCalls, arg.SessionID, arg.TurnID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FunctionCall{} + for rows.Next() { + var i FunctionCall + if err := rows.Scan( + &i.SessionID, + &i.TurnID, + &i.CallID, + &i.ExecutorCallID, + &i.Name, + &i.Arguments, + &i.Result, + &i.Applied, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const matchFunctionCall = `-- name: MatchFunctionCall :one +SELECT COALESCE(executor_call_id = $1 AND name = $2 + AND arguments = $3::jsonb, false)::boolean AS matches +FROM function_calls +WHERE session_id = $4 AND turn_id = $5 AND call_id = $6 +` + +type MatchFunctionCallParams struct { + ExecutorCallID string `json:"executor_call_id"` + Name string `json:"name"` + Arguments []byte `json:"arguments"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` +} + +func (q *Queries) MatchFunctionCall(ctx context.Context, arg MatchFunctionCallParams) (bool, error) { + row := q.db.QueryRow(ctx, matchFunctionCall, + arg.ExecutorCallID, + arg.Name, + arg.Arguments, + arg.SessionID, + arg.TurnID, + arg.CallID, + ) + var matches bool + err := row.Scan(&matches) + return matches, err +} + +const matchFunctionResult = `-- name: MatchFunctionResult :one +SELECT (result IS NOT NULL)::boolean AS submitted, COALESCE(result = $1::jsonb, false)::boolean AS matches +FROM function_calls +WHERE session_id = $2 AND turn_id = $3 AND call_id = $4 +` + +type MatchFunctionResultParams struct { + Result []byte `json:"result"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` +} + +type MatchFunctionResultRow struct { + Submitted bool `json:"submitted"` + Matches bool `json:"matches"` +} + +func (q *Queries) MatchFunctionResult(ctx context.Context, arg MatchFunctionResultParams) (MatchFunctionResultRow, error) { + row := q.db.QueryRow(ctx, matchFunctionResult, + arg.Result, + arg.SessionID, + arg.TurnID, + arg.CallID, + ) + var i MatchFunctionResultRow + err := row.Scan(&i.Submitted, &i.Matches) + return i, err +} + +const submitFunctionResult = `-- name: SubmitFunctionResult :exec +UPDATE function_calls SET result = $4 WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 +` + +type SubmitFunctionResultParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` + Result []byte `json:"result"` +} + +func (q *Queries) SubmitFunctionResult(ctx context.Context, arg SubmitFunctionResultParams) error { + _, err := q.db.Exec(ctx, submitFunctionResult, + arg.SessionID, + arg.TurnID, + arg.CallID, + arg.Result, + ) + return err +} diff --git a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go new file mode 100644 index 000000000..3f9fa3ef6 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go @@ -0,0 +1,192 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: initial_environment_files.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const claimRuntimeInitialization = `-- name: ClaimRuntimeInitialization :one +UPDATE runtime_allocations SET initialization = 'running' +WHERE id = $1 AND initialization = 'pending' AND state = 'running' AND create_settled +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) ClaimRuntimeInitialization(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, claimRuntimeInitialization, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const completeRuntimeInitialization = `-- name: CompleteRuntimeInitialization :one +UPDATE runtime_allocations SET initialization = 'complete' +WHERE id = $1 AND initialization = 'running' AND state = 'running' AND create_settled +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) CompleteRuntimeInitialization(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, completeRuntimeInitialization, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const createInitialEnvironmentFile = `-- name: CreateInitialEnvironmentFile :exec +INSERT INTO initial_environment_files (id, session_id, position, path, size_bytes, contents) +VALUES ($1, $2, $3, $4, $5, $6) +` + +type CreateInitialEnvironmentFileParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Position int32 `json:"position"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Contents []byte `json:"contents"` +} + +func (q *Queries) CreateInitialEnvironmentFile(ctx context.Context, arg CreateInitialEnvironmentFileParams) error { + _, err := q.db.Exec(ctx, createInitialEnvironmentFile, + arg.ID, + arg.SessionID, + arg.Position, + arg.Path, + arg.SizeBytes, + arg.Contents, + ) + return err +} + +const getInitialEnvironmentFile = `-- name: GetInitialEnvironmentFile :one +SELECT f.id, f.session_id, f.position, f.path, f.size_bytes, f.contents FROM initial_environment_files f JOIN sessions s ON s.id = f.session_id +WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_at IS NULL +` + +type GetInitialEnvironmentFileParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + Position int32 `json:"position"` +} + +func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialEnvironmentFileParams) (InitialEnvironmentFile, error) { + row := q.db.QueryRow(ctx, getInitialEnvironmentFile, arg.TenantID, arg.SessionID, arg.Position) + var i InitialEnvironmentFile + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Position, + &i.Path, + &i.SizeBytes, + &i.Contents, + ) + return i, err +} + +const getSessionInitializationReady = `-- name: GetSessionInitializationReady :one +SELECT NOT EXISTS ( + SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id + WHERE e.session_id = s.id AND a.initialization <> 'complete' +) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) + AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) + OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id + WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready +FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2 +` + +type GetSessionInitializationReadyParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSessionInitializationReady(ctx context.Context, arg GetSessionInitializationReadyParams) (pgtype.Bool, error) { + row := q.db.QueryRow(ctx, getSessionInitializationReady, arg.TenantID, arg.ID) + var ready pgtype.Bool + err := row.Scan(&ready) + return ready, err +} + +const lockInitialSourceFile = `-- name: LockInitialSourceFile :one +SELECT id, tenant_id, filename, purpose, body_oid, size_bytes, sha256, created_at FROM source_files WHERE tenant_id = $1 AND id = $2 FOR SHARE +` + +type LockInitialSourceFileParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) LockInitialSourceFile(ctx context.Context, arg LockInitialSourceFileParams) (SourceFile, error) { + row := q.db.QueryRow(ctx, lockInitialSourceFile, arg.TenantID, arg.ID) + var i SourceFile + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Filename, + &i.Purpose, + &i.BodyOid, + &i.SizeBytes, + &i.Sha256, + &i.CreatedAt, + ) + return i, err +} + +const setSessionInitialFileMetadata = `-- name: SetSessionInitialFileMetadata :one +UPDATE sessions SET configuration = jsonb_set(configuration, '{environment,files}', $2::jsonb) +WHERE id = $1 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +` + +type SetSessionInitialFileMetadataParams struct { + ID pgtype.UUID `json:"id"` + Column2 []byte `json:"column_2"` +} + +func (q *Queries) SetSessionInitialFileMetadata(ctx context.Context, arg SetSessionInitialFileMetadataParams) (Session, error) { + row := q.db.QueryRow(ctx, setSessionInitialFileMetadata, arg.ID, arg.Column2) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go b/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go new file mode 100644 index 000000000..109216093 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: local_environment_devices.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentDevice = `-- name: CreateEnvironmentDevice :one +INSERT INTO devices (id, tenant_id, name, credential_hash, environment_id) +SELECT $1, s.tenant_id, $2, $3, e.id +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $4 AND e.id = $5 +AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +ON CONFLICT (environment_id) DO NOTHING +RETURNING id +` + +type CreateEnvironmentDeviceParams struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + CredentialHash string `json:"credential_hash"` + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) CreateEnvironmentDevice(ctx context.Context, arg CreateEnvironmentDeviceParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, createEnvironmentDevice, + arg.ID, + arg.Name, + arg.CredentialHash, + arg.TenantID, + arg.EnvironmentID, + ) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} diff --git a/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go b/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go new file mode 100644 index 000000000..584f40b8c --- /dev/null +++ b/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go @@ -0,0 +1,138 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: mcp_credentials.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const findMCPStaticCredentials = `-- name: FindMCPStaticCredentials :many +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = $1 + AND v.id = ANY($2::uuid[]) + AND c.auth_type = 'static_bearer' + AND c.mcp_server_url = $3 + AND ($4::uuid IS NULL OR c.id = $4::uuid) +ORDER BY c.id +LIMIT 2 +` + +type FindMCPStaticCredentialsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultIds []pgtype.UUID `json:"vault_ids"` + McpServerUrl string `json:"mcp_server_url"` + CredentialID pgtype.UUID `json:"credential_id"` +} + +type FindMCPStaticCredentialsRow struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) FindMCPStaticCredentials(ctx context.Context, arg FindMCPStaticCredentialsParams) ([]FindMCPStaticCredentialsRow, error) { + rows, err := q.db.Query(ctx, findMCPStaticCredentials, + arg.TenantID, + arg.VaultIds, + arg.McpServerUrl, + arg.CredentialID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FindMCPStaticCredentialsRow{} + for rows.Next() { + var i FindMCPStaticCredentialsRow + if err := rows.Scan( + &i.ID, + &i.VaultID, + &i.Name, + &i.AuthType, + &i.McpServerUrl, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAttachedVaultIDs = `-- name: GetAttachedVaultIDs :many +SELECT id FROM vaults +WHERE tenant_id = $1 AND id = ANY($2::uuid[]) +` + +type GetAttachedVaultIDsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultIds []pgtype.UUID `json:"vault_ids"` +} + +func (q *Queries) GetAttachedVaultIDs(ctx context.Context, arg GetAttachedVaultIDsParams) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, getAttachedVaultIDs, arg.TenantID, arg.VaultIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMCPStaticCredentialCiphertext = `-- name: GetMCPStaticCredentialCiphertext :one +SELECT c.token_ciphertext +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = $1 + AND v.id = ANY($2::uuid[]) + AND v.id = $3 + AND c.id = $4 + AND c.auth_type = 'static_bearer' + AND c.mcp_server_url = $5 +` + +type GetMCPStaticCredentialCiphertextParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultIds []pgtype.UUID `json:"vault_ids"` + VaultID pgtype.UUID `json:"vault_id"` + CredentialID pgtype.UUID `json:"credential_id"` + McpServerUrl string `json:"mcp_server_url"` +} + +func (q *Queries) GetMCPStaticCredentialCiphertext(ctx context.Context, arg GetMCPStaticCredentialCiphertextParams) ([]byte, error) { + row := q.db.QueryRow(ctx, getMCPStaticCredentialCiphertext, + arg.TenantID, + arg.VaultIds, + arg.VaultID, + arg.CredentialID, + arg.McpServerUrl, + ) + var token_ciphertext []byte + err := row.Scan(&token_ciphertext) + return token_ciphertext, err +} diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go new file mode 100644 index 000000000..f6c9a6fb4 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/models.go @@ -0,0 +1,272 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Agent struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Metadata []byte `json:"metadata"` + Configuration []byte `json:"configuration"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Device struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + CredentialHash string `json:"credential_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +type Environment struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type EnvironmentConnection struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + Generation pgtype.UUID `json:"generation"` + Revision int64 `json:"revision"` +} + +type EnvironmentExecutorCredential struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + TokenSha256 string `json:"token_sha256"` + IssuedAt pgtype.Timestamptz `json:"issued_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + KeyID pgtype.UUID `json:"key_id"` + TenantID pgtype.UUID `json:"tenant_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type EnvironmentFileWrite struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + DeviceID pgtype.UUID `json:"device_id"` + RequestSha256 string `json:"request_sha256"` + State string `json:"state"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + SettledAt pgtype.Timestamptz `json:"settled_at"` +} + +type EnvironmentInputReservation struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + IdempotencyKey string `json:"idempotency_key"` + Batch []byte `json:"batch"` + State string `json:"state"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Deadline pgtype.Timestamptz `json:"deadline"` + SettledAt pgtype.Timestamptz `json:"settled_at"` + IsInitial bool `json:"is_initial"` +} + +type EnvironmentSetup struct { + SessionID pgtype.UUID `json:"session_id"` + Contents []byte `json:"contents"` +} + +type EnvironmentTemplate struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` +} + +type ExecutionProjectScope struct { + TenantID pgtype.UUID `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +type FunctionCall struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CallID string `json:"call_id"` + ExecutorCallID string `json:"executor_call_id"` + Name string `json:"name"` + Arguments []byte `json:"arguments"` + Result []byte `json:"result"` + Applied bool `json:"applied"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type InitialEnvironmentFile struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Position int32 `json:"position"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Contents []byte `json:"contents"` +} + +type RuntimeAllocation struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + DeviceID pgtype.UUID `json:"device_id"` + ProviderKey pgtype.UUID `json:"provider_key"` + State string `json:"state"` + CreateSettled bool `json:"create_settled"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + KeptAt pgtype.Timestamptz `json:"kept_at"` + ReleasedAt pgtype.Timestamptz `json:"released_at"` + Initialization string `json:"initialization"` +} + +type Session struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Engine string `json:"engine"` + Metadata []byte `json:"metadata"` + IdempotencyKey string `json:"idempotency_key"` + RequestHash string `json:"request_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Configuration []byte `json:"configuration"` + EventSequence int64 `json:"event_sequence"` + CreationRequestHash pgtype.Text `json:"creation_request_hash"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` +} + +type SessionArtifact struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + BodyOid pgtype.Uint32 `json:"body_oid"` + Sha256 string `json:"sha256"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type SessionDevice struct { + SessionID pgtype.UUID `json:"session_id"` + DeviceID pgtype.UUID `json:"device_id"` + NativeSessionID string `json:"native_session_id"` +} + +type SessionEvent struct { + SessionID pgtype.UUID `json:"session_id"` + Sequence int64 `json:"sequence"` + Payload []byte `json:"payload"` + PayloadBytes pgtype.Int4 `json:"payload_bytes"` +} + +type SessionItem struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Position int32 `json:"position"` + Payload []byte `json:"payload"` + OutputIndex pgtype.Int4 `json:"output_index"` +} + +type SessionModelExecution struct { + SessionID pgtype.UUID `json:"session_id"` + EncryptedConfig []byte `json:"encrypted_config"` +} + +type SourceFile struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Filename string `json:"filename"` + Purpose string `json:"purpose"` + BodyOid pgtype.Uint32 `json:"body_oid"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type SubagentIdentity struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + DeviceID pgtype.UUID `json:"device_id"` + Engine string `json:"engine"` + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + FirstTurnID pgtype.UUID `json:"first_turn_id"` + FirstEventOrdinal int32 `json:"first_event_ordinal"` +} + +type Turn struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CancelRequestedAt pgtype.Timestamptz `json:"cancel_requested_at"` + Outcome []byte `json:"outcome"` + EventCount int32 `json:"event_count"` + EventBytes int64 `json:"event_bytes"` + TokenUsage []byte `json:"token_usage"` + ArtifactCaptureStarted bool `json:"artifact_capture_started"` +} + +type TurnEvent struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Ordinal int32 `json:"ordinal"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type TurnInput struct { + Sequence int64 `json:"sequence"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + IdempotencyKey string `json:"idempotency_key"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + BatchPosition int32 `json:"batch_position"` +} + +type Vault struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status string `json:"status"` +} + +type VaultCredential struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + TokenCiphertext []byte `json:"token_ciphertext"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Status string `json:"status"` +} diff --git a/services/agents-api/internal/db/sqlc/project_scopes.sql.go b/services/agents-api/internal/db/sqlc/project_scopes.sql.go new file mode 100644 index 000000000..8c737a711 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/project_scopes.sql.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: project_scopes.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const ensureProjectScope = `-- name: EnsureProjectScope :one +INSERT INTO execution_project_scopes (tenant_id, organization_id, project_id) +VALUES ($1, $2, $3) +ON CONFLICT (tenant_id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id +WHERE execution_project_scopes.organization_id = EXCLUDED.organization_id + AND execution_project_scopes.project_id = EXCLUDED.project_id +RETURNING tenant_id +` + +type EnsureProjectScopeParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +func (q *Queries) EnsureProjectScope(ctx context.Context, arg EnsureProjectScopeParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, ensureProjectScope, arg.TenantID, arg.OrganizationID, arg.ProjectID) + var tenant_id pgtype.UUID + err := row.Scan(&tenant_id) + return tenant_id, err +} diff --git a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go new file mode 100644 index 000000000..7406ba900 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go @@ -0,0 +1,296 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: runtime_allocations.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createRuntimeAllocation = `-- name: CreateRuntimeAllocation :one +INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, initialization) +VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +type CreateRuntimeAllocationParams struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + DeviceID pgtype.UUID `json:"device_id"` + ProviderKey pgtype.UUID `json:"provider_key"` +} + +func (q *Queries) CreateRuntimeAllocation(ctx context.Context, arg CreateRuntimeAllocationParams) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, createRuntimeAllocation, + arg.ID, + arg.EnvironmentID, + arg.DeviceID, + arg.ProviderKey, + ) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const getRuntimeAllocation = `-- name: GetRuntimeAllocation :one +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.initialization, e.session_id, s.tenant_id, s.deleted_at, (a.kept_at <= clock_timestamp() - interval '1 hour') AS expired +FROM runtime_allocations a +JOIN environments e ON e.id = a.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND a.environment_id = $2 +` + +type GetRuntimeAllocationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +type GetRuntimeAllocationRow struct { + RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + Expired bool `json:"expired"` +} + +func (q *Queries) GetRuntimeAllocation(ctx context.Context, arg GetRuntimeAllocationParams) (GetRuntimeAllocationRow, error) { + row := q.db.QueryRow(ctx, getRuntimeAllocation, arg.TenantID, arg.EnvironmentID) + var i GetRuntimeAllocationRow + err := row.Scan( + &i.RuntimeAllocation.ID, + &i.RuntimeAllocation.EnvironmentID, + &i.RuntimeAllocation.DeviceID, + &i.RuntimeAllocation.ProviderKey, + &i.RuntimeAllocation.State, + &i.RuntimeAllocation.CreateSettled, + &i.RuntimeAllocation.CreatedAt, + &i.RuntimeAllocation.KeptAt, + &i.RuntimeAllocation.ReleasedAt, + &i.RuntimeAllocation.Initialization, + &i.SessionID, + &i.TenantID, + &i.DeletedAt, + &i.Expired, + ) + return i, err +} + +const keepRuntimeAllocation = `-- name: KeepRuntimeAllocation :one +UPDATE runtime_allocations SET kept_at = clock_timestamp() +WHERE id = $1 AND state = 'running' +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) KeepRuntimeAllocation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, keepRuntimeAllocation, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const listRuntimeAllocations = `-- name: ListRuntimeAllocations :many +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.initialization, e.session_id, s.tenant_id, s.deleted_at, (a.kept_at <= clock_timestamp() - interval '1 hour') AS expired +FROM runtime_allocations a +JOIN environments e ON e.id = a.environment_id +JOIN sessions s ON s.id = e.session_id +WHERE a.id > $1 AND a.state <> 'released' +ORDER BY a.id LIMIT 32 +` + +type ListRuntimeAllocationsRow struct { + RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + Expired bool `json:"expired"` +} + +func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([]ListRuntimeAllocationsRow, error) { + rows, err := q.db.Query(ctx, listRuntimeAllocations, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRuntimeAllocationsRow{} + for rows.Next() { + var i ListRuntimeAllocationsRow + if err := rows.Scan( + &i.RuntimeAllocation.ID, + &i.RuntimeAllocation.EnvironmentID, + &i.RuntimeAllocation.DeviceID, + &i.RuntimeAllocation.ProviderKey, + &i.RuntimeAllocation.State, + &i.RuntimeAllocation.CreateSettled, + &i.RuntimeAllocation.CreatedAt, + &i.RuntimeAllocation.KeptAt, + &i.RuntimeAllocation.ReleasedAt, + &i.RuntimeAllocation.Initialization, + &i.SessionID, + &i.TenantID, + &i.DeletedAt, + &i.Expired, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUnallocatedHostedEnvironments = `-- name: ListUnallocatedHostedEnvironments :many +SELECT e.id, s.tenant_id, s.engine +FROM environments e JOIN sessions s ON s.id = e.session_id +WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status = 'pending' + AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id) +ORDER BY e.id LIMIT 32 +` + +type ListUnallocatedHostedEnvironmentsRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Engine string `json:"engine"` +} + +func (q *Queries) ListUnallocatedHostedEnvironments(ctx context.Context, id pgtype.UUID) ([]ListUnallocatedHostedEnvironmentsRow, error) { + rows, err := q.db.Query(ctx, listUnallocatedHostedEnvironments, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUnallocatedHostedEnvironmentsRow{} + for rows.Next() { + var i ListUnallocatedHostedEnvironmentsRow + if err := rows.Scan(&i.ID, &i.TenantID, &i.Engine); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const observeRuntimeRunning = `-- name: ObserveRuntimeRunning :one +UPDATE runtime_allocations SET state = 'running', create_settled = true +WHERE id = $1 AND state IN ('creating', 'running') +AND kept_at > clock_timestamp() - interval '1 hour' +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) ObserveRuntimeRunning(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, observeRuntimeRunning, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const releaseRuntimeAllocation = `-- name: ReleaseRuntimeAllocation :one +UPDATE runtime_allocations SET state = 'released', released_at = clock_timestamp() +WHERE id = $1 AND state = 'cleanup_pending' AND create_settled RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) ReleaseRuntimeAllocation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, releaseRuntimeAllocation, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const requestRuntimeCleanup = `-- name: RequestRuntimeCleanup :one +UPDATE runtime_allocations SET state = 'cleanup_pending' +WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) RequestRuntimeCleanup(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, requestRuntimeCleanup, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} + +const settleRuntimeCreation = `-- name: SettleRuntimeCreation :one +UPDATE runtime_allocations SET create_settled = true +WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +` + +func (q *Queries) SettleRuntimeCreation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { + row := q.db.QueryRow(ctx, settleRuntimeCreation, id) + var i RuntimeAllocation + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.DeviceID, + &i.ProviderKey, + &i.State, + &i.CreateSettled, + &i.CreatedAt, + &i.KeptAt, + &i.ReleasedAt, + &i.Initialization, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/scheduling.sql.go b/services/agents-api/internal/db/sqlc/scheduling.sql.go new file mode 100644 index 000000000..4a748867c --- /dev/null +++ b/services/agents-api/internal/db/sqlc/scheduling.sql.go @@ -0,0 +1,180 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: scheduling.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getLatestSessionTurn = `-- name: GetLatestSessionTurn :one +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 +` + +func (q *Queries) GetLatestSessionTurn(ctx context.Context, sessionID pgtype.UUID) (Turn, error) { + row := q.db.QueryRow(ctx, getLatestSessionTurn, sessionID) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} + +const listEnvironmentInputWork = `-- name: ListEnvironmentInputWork :many +SELECT r.id, r.session_id, s.tenant_id +FROM environment_input_reservations r +JOIN sessions s ON s.id = r.session_id +LEFT JOIN session_devices b ON b.session_id = s.id +WHERE r.state = 'pending' AND r.deadline > clock_timestamp() +AND r.id > $1::uuid AND s.deleted_at IS NULL +AND EXISTS ( + SELECT 1 FROM devices d WHERE d.tenant_id = s.tenant_id AND d.revoked_at IS NULL + AND d.id = ANY($2::uuid[]) + AND (b.device_id IS NULL OR d.id = b.device_id) +) +ORDER BY r.id LIMIT 100 +` + +type ListEnvironmentInputWorkParams struct { + AfterID pgtype.UUID `json:"after_id"` + ConnectedDevices []pgtype.UUID `json:"connected_devices"` +} + +type ListEnvironmentInputWorkRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` +} + +func (q *Queries) ListEnvironmentInputWork(ctx context.Context, arg ListEnvironmentInputWorkParams) ([]ListEnvironmentInputWorkRow, error) { + rows, err := q.db.Query(ctx, listEnvironmentInputWork, arg.AfterID, arg.ConnectedDevices) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnvironmentInputWorkRow{} + for rows.Next() { + var i ListEnvironmentInputWorkRow + if err := rows.Scan(&i.ID, &i.SessionID, &i.TenantID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listExecutionDevices = `-- name: ListExecutionDevices :many +SELECT id, name FROM devices +WHERE tenant_id = $1 AND revoked_at IS NULL AND environment_id IS NULL +ORDER BY id +` + +type ListExecutionDevicesRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) ListExecutionDevices(ctx context.Context, tenantID pgtype.UUID) ([]ListExecutionDevicesRow, error) { + rows, err := q.db.Query(ctx, listExecutionDevices, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListExecutionDevicesRow{} + for rows.Next() { + var i ListExecutionDevicesRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listExecutionWork = `-- name: ListExecutionWork :many +SELECT t.id, t.session_id, s.tenant_id, t.status +FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE t.status = ANY($1::text[]) AND t.id > $2::uuid +AND (s.deleted_at IS NULL OR t.status <> 'queued') +AND (NOT $3::boolean OR EXISTS ( + SELECT 1 FROM devices d WHERE d.tenant_id = s.tenant_id AND d.revoked_at IS NULL + AND d.id = ANY($4::uuid[]) +)) +ORDER BY t.id LIMIT 100 +` + +type ListExecutionWorkParams struct { + Statuses []string `json:"statuses"` + AfterID pgtype.UUID `json:"after_id"` + ConnectedOnly bool `json:"connected_only"` + ConnectedDevices []pgtype.UUID `json:"connected_devices"` +} + +type ListExecutionWorkRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + Status string `json:"status"` +} + +func (q *Queries) ListExecutionWork(ctx context.Context, arg ListExecutionWorkParams) ([]ListExecutionWorkRow, error) { + rows, err := q.db.Query(ctx, listExecutionWork, + arg.Statuses, + arg.AfterID, + arg.ConnectedOnly, + arg.ConnectedDevices, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListExecutionWorkRow{} + for rows.Next() { + var i ListExecutionWorkRow + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.TenantID, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const tryExecutionLease = `-- name: TryExecutionLease :one +SELECT pg_try_advisory_lock(706172736172::bigint)::boolean +` + +func (q *Queries) TryExecutionLease(ctx context.Context) (bool, error) { + row := q.db.QueryRow(ctx, tryExecutionLease) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} diff --git a/services/agents-api/internal/db/sqlc/session_artifacts.sql.go b/services/agents-api/internal/db/sqlc/session_artifacts.sql.go new file mode 100644 index 000000000..dbbb94d45 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/session_artifacts.sql.go @@ -0,0 +1,217 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: session_artifacts.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const beginTurnArtifactCapture = `-- name: BeginTurnArtifactCapture :execrows +UPDATE turns SET artifact_capture_started = true +WHERE session_id = $1 AND id = $2 AND NOT artifact_capture_started +` + +type BeginTurnArtifactCaptureParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) BeginTurnArtifactCapture(ctx context.Context, arg BeginTurnArtifactCaptureParams) (int64, error) { + result, err := q.db.Exec(ctx, beginTurnArtifactCapture, arg.SessionID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteSessionArtifact = `-- name: DeleteSessionArtifact :one +DELETE FROM session_artifacts a USING sessions s +WHERE s.id = a.session_id AND s.tenant_id = $1 AND s.deleted_at IS NULL +AND a.session_id = $2 AND a.id = $3 AND a.created_at IS NOT NULL +RETURNING a.body_oid +` + +type DeleteSessionArtifactParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteSessionArtifact(ctx context.Context, arg DeleteSessionArtifactParams) (pgtype.Uint32, error) { + row := q.db.QueryRow(ctx, deleteSessionArtifact, arg.TenantID, arg.SessionID, arg.ID) + var body_oid pgtype.Uint32 + err := row.Scan(&body_oid) + return body_oid, err +} + +const deleteSessionArtifacts = `-- name: DeleteSessionArtifacts :exec +WITH removed AS ( + DELETE FROM session_artifacts WHERE session_id = $1 RETURNING body_oid +) +SELECT lo_unlink(body_oid) FROM removed +` + +func (q *Queries) DeleteSessionArtifacts(ctx context.Context, sessionID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteSessionArtifacts, sessionID) + return err +} + +const deleteUnpublishedTurnArtifacts = `-- name: DeleteUnpublishedTurnArtifacts :exec +WITH removed AS ( + DELETE FROM session_artifacts WHERE session_id = $1 AND turn_id = $2 AND created_at IS NULL RETURNING body_oid +) +SELECT lo_unlink(body_oid) FROM removed +` + +type DeleteUnpublishedTurnArtifactsParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` +} + +func (q *Queries) DeleteUnpublishedTurnArtifacts(ctx context.Context, arg DeleteUnpublishedTurnArtifactsParams) error { + _, err := q.db.Exec(ctx, deleteUnpublishedTurnArtifacts, arg.SessionID, arg.TurnID) + return err +} + +const getSessionArtifact = `-- name: GetSessionArtifact :one +SELECT a.id, a.session_id, a.turn_id, a.environment_id, a.path, a.size_bytes, a.body_oid, a.sha256, a.created_at FROM session_artifacts a JOIN sessions s ON s.id = a.session_id +WHERE s.tenant_id = $1 AND s.deleted_at IS NULL AND a.session_id = $2 AND a.id = $3 AND a.created_at IS NOT NULL +` + +type GetSessionArtifactParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSessionArtifact(ctx context.Context, arg GetSessionArtifactParams) (SessionArtifact, error) { + row := q.db.QueryRow(ctx, getSessionArtifact, arg.TenantID, arg.SessionID, arg.ID) + var i SessionArtifact + err := row.Scan( + &i.ID, + &i.SessionID, + &i.TurnID, + &i.EnvironmentID, + &i.Path, + &i.SizeBytes, + &i.BodyOid, + &i.Sha256, + &i.CreatedAt, + ) + return i, err +} + +const listSessionArtifacts = `-- name: ListSessionArtifacts :many +SELECT a.id, a.session_id, a.turn_id, a.environment_id, a.path, a.size_bytes, a.body_oid, a.sha256, a.created_at FROM session_artifacts a JOIN sessions s ON s.id = a.session_id +WHERE s.tenant_id = $1 AND s.deleted_at IS NULL + AND a.session_id = $2 AND a.created_at IS NOT NULL + AND ($3::uuid IS NULL OR a.environment_id = $3::uuid) + AND ($4::timestamptz IS NULL + OR (NOT $5::boolean AND (a.created_at, a.id) < ($4::timestamptz, $6::uuid)) + OR ($5::boolean AND (a.created_at, a.id) > ($4::timestamptz, $6::uuid))) +ORDER BY + CASE WHEN $5::boolean THEN a.created_at END ASC, + CASE WHEN $5::boolean THEN a.id END ASC, + CASE WHEN NOT $5::boolean THEN a.created_at END DESC, + CASE WHEN NOT $5::boolean THEN a.id END DESC +LIMIT $7 +` + +type ListSessionArtifactsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListSessionArtifacts(ctx context.Context, arg ListSessionArtifactsParams) ([]SessionArtifact, error) { + rows, err := q.db.Query(ctx, listSessionArtifacts, + arg.TenantID, + arg.SessionID, + arg.EnvironmentID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SessionArtifact{} + for rows.Next() { + var i SessionArtifact + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.TurnID, + &i.EnvironmentID, + &i.Path, + &i.SizeBytes, + &i.BodyOid, + &i.Sha256, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const publishTurnArtifacts = `-- name: PublishTurnArtifacts :exec +UPDATE session_artifacts SET created_at = $3 +WHERE session_id = $1 AND turn_id = $2 AND created_at IS NULL +` + +type PublishTurnArtifactsParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) PublishTurnArtifacts(ctx context.Context, arg PublishTurnArtifactsParams) error { + _, err := q.db.Exec(ctx, publishTurnArtifacts, arg.SessionID, arg.TurnID, arg.CreatedAt) + return err +} + +const stageSessionArtifact = `-- name: StageSessionArtifact :exec +INSERT INTO session_artifacts (id, session_id, turn_id, environment_id, path, size_bytes, body_oid, sha256) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +` + +type StageSessionArtifactParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + BodyOid pgtype.Uint32 `json:"body_oid"` + Sha256 string `json:"sha256"` +} + +func (q *Queries) StageSessionArtifact(ctx context.Context, arg StageSessionArtifactParams) error { + _, err := q.db.Exec(ctx, stageSessionArtifact, + arg.ID, + arg.SessionID, + arg.TurnID, + arg.EnvironmentID, + arg.Path, + arg.SizeBytes, + arg.BodyOid, + arg.Sha256, + ) + return err +} diff --git a/services/agents-api/internal/db/sqlc/session_events.sql.go b/services/agents-api/internal/db/sqlc/session_events.sql.go new file mode 100644 index 000000000..c1290c507 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/session_events.sql.go @@ -0,0 +1,173 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: session_events.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const appendSessionEvent = `-- name: AppendSessionEvent :exec +WITH next AS ( + UPDATE sessions SET event_sequence = event_sequence + 1 WHERE id = $1 + RETURNING id, event_sequence +) +INSERT INTO session_events(session_id, sequence, payload) +SELECT id, event_sequence, $2 FROM next +` + +type AppendSessionEventParams struct { + ID pgtype.UUID `json:"id"` + Payload []byte `json:"payload"` +} + +func (q *Queries) AppendSessionEvent(ctx context.Context, arg AppendSessionEventParams) error { + _, err := q.db.Exec(ctx, appendSessionEvent, arg.ID, arg.Payload) + return err +} + +const finishSessionItems = `-- name: FinishSessionItems :many +UPDATE session_items SET payload = jsonb_set(payload, '{status}', '"incomplete"') +WHERE session_id = $1 AND turn_id = $2 AND payload->>'status' = 'in_progress' +RETURNING id, session_id, turn_id, created_at, position, payload, output_index +` + +type FinishSessionItemsParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` +} + +func (q *Queries) FinishSessionItems(ctx context.Context, arg FinishSessionItemsParams) ([]SessionItem, error) { + rows, err := q.db.Query(ctx, finishSessionItems, arg.SessionID, arg.TurnID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SessionItem{} + for rows.Next() { + var i SessionItem + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.TurnID, + &i.CreatedAt, + &i.Position, + &i.Payload, + &i.OutputIndex, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSessionEvents = `-- name: ListSessionEvents :many +SELECT sequence, payload FROM ( + SELECT e.sequence, e.payload, + row_number() OVER (ORDER BY e.sequence) AS n, + sum(e.payload_bytes) OVER (ORDER BY e.sequence) AS bytes + FROM session_events e JOIN sessions s ON s.id = e.session_id + WHERE s.tenant_id = $1 AND s.deleted_at IS NULL AND e.session_id = $2 AND e.sequence > $3 +) AS pending WHERE n = 1 OR bytes <= 1048576 +ORDER BY sequence LIMIT 32 +` + +type ListSessionEventsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + Sequence int64 `json:"sequence"` +} + +type ListSessionEventsRow struct { + Sequence int64 `json:"sequence"` + Payload []byte `json:"payload"` +} + +func (q *Queries) ListSessionEvents(ctx context.Context, arg ListSessionEventsParams) ([]ListSessionEventsRow, error) { + rows, err := q.db.Query(ctx, listSessionEvents, arg.TenantID, arg.SessionID, arg.Sequence) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSessionEventsRow{} + for rows.Next() { + var i ListSessionEventsRow + if err := rows.Scan(&i.Sequence, &i.Payload); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const pruneSessionEvents = `-- name: PruneSessionEvents :exec +WITH retained AS ( + SELECT sequence, row_number() OVER (ORDER BY sequence DESC) AS n, + sum(payload_bytes) OVER (ORDER BY sequence DESC) AS bytes + FROM session_events e WHERE e.session_id = $1 +) +DELETE FROM session_events e WHERE e.session_id = $1 AND e.sequence IN ( + SELECT sequence FROM retained WHERE n > 256 OR (bytes > 67108864 AND n > 1) +) +` + +func (q *Queries) PruneSessionEvents(ctx context.Context, sessionID pgtype.UUID) error { + _, err := q.db.Exec(ctx, pruneSessionEvents, sessionID) + return err +} + +const sessionEventCursor = `-- name: SessionEventCursor :one +SELECT event_sequence FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL +` + +type SessionEventCursorParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) SessionEventCursor(ctx context.Context, arg SessionEventCursorParams) (int64, error) { + row := q.db.QueryRow(ctx, sessionEventCursor, arg.TenantID, arg.ID) + var event_sequence int64 + err := row.Scan(&event_sequence) + return event_sequence, err +} + +const sessionEventTurn = `-- name: SessionEventTurn :one +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 AND id = $2 +` + +type SessionEventTurnParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) SessionEventTurn(ctx context.Context, arg SessionEventTurnParams) (Turn, error) { + row := q.db.QueryRow(ctx, sessionEventTurn, arg.SessionID, arg.ID) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/session_items.sql.go b/services/agents-api/internal/db/sqlc/session_items.sql.go new file mode 100644 index 000000000..5ef09514b --- /dev/null +++ b/services/agents-api/internal/db/sqlc/session_items.sql.go @@ -0,0 +1,218 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: session_items.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSessionItem = `-- name: GetSessionItem :one +SELECT id, session_id, turn_id, created_at, position, payload, output_index FROM session_items WHERE session_id = $1 AND id = $2 +` + +type GetSessionItemParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSessionItem(ctx context.Context, arg GetSessionItemParams) (SessionItem, error) { + row := q.db.QueryRow(ctx, getSessionItem, arg.SessionID, arg.ID) + var i SessionItem + err := row.Scan( + &i.ID, + &i.SessionID, + &i.TurnID, + &i.CreatedAt, + &i.Position, + &i.Payload, + &i.OutputIndex, + ) + return i, err +} + +const hasNativeMessageItem = `-- name: HasNativeMessageItem :one +SELECT EXISTS(SELECT 1 FROM session_items WHERE turn_id = $1 + AND payload->>'role' = 'assistant' AND id <> $2) +` + +type HasNativeMessageItemParams struct { + TurnID pgtype.UUID `json:"turn_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) HasNativeMessageItem(ctx context.Context, arg HasNativeMessageItemParams) (bool, error) { + row := q.db.QueryRow(ctx, hasNativeMessageItem, arg.TurnID, arg.ID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const itemEventSources = `-- name: ItemEventSources :many +SELECT session_id, turn_id, ordinal, kind, payload, created_at FROM turn_events WHERE session_id = $1 AND turn_id = $2 AND ordinal >= $3 ORDER BY ordinal +` + +type ItemEventSourcesParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Ordinal int32 `json:"ordinal"` +} + +func (q *Queries) ItemEventSources(ctx context.Context, arg ItemEventSourcesParams) ([]TurnEvent, error) { + rows, err := q.db.Query(ctx, itemEventSources, arg.SessionID, arg.TurnID, arg.Ordinal) + if err != nil { + return nil, err + } + defer rows.Close() + items := []TurnEvent{} + for rows.Next() { + var i TurnEvent + if err := rows.Scan( + &i.SessionID, + &i.TurnID, + &i.Ordinal, + &i.Kind, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const itemInputSource = `-- name: ItemInputSource :one +SELECT sequence, session_id, turn_id, idempotency_key, kind, payload, created_at, batch_position FROM turn_inputs WHERE session_id = $1 AND sequence = $2 +` + +type ItemInputSourceParams struct { + SessionID pgtype.UUID `json:"session_id"` + Sequence int64 `json:"sequence"` +} + +func (q *Queries) ItemInputSource(ctx context.Context, arg ItemInputSourceParams) (TurnInput, error) { + row := q.db.QueryRow(ctx, itemInputSource, arg.SessionID, arg.Sequence) + var i TurnInput + err := row.Scan( + &i.Sequence, + &i.SessionID, + &i.TurnID, + &i.IdempotencyKey, + &i.Kind, + &i.Payload, + &i.CreatedAt, + &i.BatchPosition, + ) + return i, err +} + +const listSessionItems = `-- name: ListSessionItems :many +SELECT i.id, i.created_at, + (CASE WHEN i.payload->>'status' = 'in_progress' AND t.status IN ('completed', 'failed', 'cancelled') + THEN jsonb_set(i.payload, '{status}', '"incomplete"') ELSE i.payload END)::jsonb AS payload +FROM session_items i JOIN turns t ON t.id = i.turn_id +WHERE i.session_id = $1 + AND ($2::timestamptz IS NULL + OR ($3::boolean AND (i.created_at, i.position, i.id) > ($2::timestamptz, $4::integer, $5::uuid)) + OR (NOT $3::boolean AND (i.created_at, i.position, i.id) < ($2::timestamptz, $4::integer, $5::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN i.created_at END ASC, + CASE WHEN $3::boolean THEN i.position END ASC, + CASE WHEN $3::boolean THEN i.id END ASC, + CASE WHEN NOT $3::boolean THEN i.created_at END DESC, + CASE WHEN NOT $3::boolean THEN i.position END DESC, + CASE WHEN NOT $3::boolean THEN i.id END DESC +LIMIT $6 +` + +type ListSessionItemsParams struct { + SessionID pgtype.UUID `json:"session_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterPosition int32 `json:"after_position"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListSessionItemsRow struct { + ID pgtype.UUID `json:"id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Payload []byte `json:"payload"` +} + +func (q *Queries) ListSessionItems(ctx context.Context, arg ListSessionItemsParams) ([]ListSessionItemsRow, error) { + rows, err := q.db.Query(ctx, listSessionItems, + arg.SessionID, + arg.AfterCreated, + arg.Ascending, + arg.AfterPosition, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSessionItemsRow{} + for rows.Next() { + var i ListSessionItemsRow + if err := rows.Scan(&i.ID, &i.CreatedAt, &i.Payload); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const putSessionItem = `-- name: PutSessionItem :one +INSERT INTO session_items(id, session_id, turn_id, created_at, payload, position, output_index) +VALUES ($1, $2, $3, $4, $5, + (SELECT COALESCE(max(position), -1) + 1 FROM session_items WHERE session_id = $2), + CASE WHEN $6::boolean THEN + (SELECT COALESCE(max(output_index), -1) + 1 FROM session_items WHERE turn_id = $3) + END) +ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload +RETURNING id, session_id, turn_id, created_at, position, payload, output_index +` + +type PutSessionItemParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Payload []byte `json:"payload"` + IsOutput bool `json:"is_output"` +} + +func (q *Queries) PutSessionItem(ctx context.Context, arg PutSessionItemParams) (SessionItem, error) { + row := q.db.QueryRow(ctx, putSessionItem, + arg.ID, + arg.SessionID, + arg.TurnID, + arg.CreatedAt, + arg.Payload, + arg.IsOutput, + ) + var i SessionItem + err := row.Scan( + &i.ID, + &i.SessionID, + &i.TurnID, + &i.CreatedAt, + &i.Position, + &i.Payload, + &i.OutputIndex, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/session_model_execution.sql.go b/services/agents-api/internal/db/sqlc/session_model_execution.sql.go new file mode 100644 index 000000000..22e7d3af6 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/session_model_execution.sql.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: session_model_execution.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSessionModelExecution = `-- name: GetSessionModelExecution :one +SELECT e.encrypted_config FROM session_model_execution e +JOIN sessions s ON s.id = e.session_id WHERE s.tenant_id = $1 AND s.id = $2 +` + +type GetSessionModelExecutionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) GetSessionModelExecution(ctx context.Context, arg GetSessionModelExecutionParams) ([]byte, error) { + row := q.db.QueryRow(ctx, getSessionModelExecution, arg.TenantID, arg.SessionID) + var encrypted_config []byte + err := row.Scan(&encrypted_config) + return encrypted_config, err +} + +const saveSessionModelExecution = `-- name: SaveSessionModelExecution :exec +INSERT INTO session_model_execution(session_id, encrypted_config) VALUES ($1, $2) +` + +type SaveSessionModelExecutionParams struct { + SessionID pgtype.UUID `json:"session_id"` + EncryptedConfig []byte `json:"encrypted_config"` +} + +func (q *Queries) SaveSessionModelExecution(ctx context.Context, arg SaveSessionModelExecutionParams) error { + _, err := q.db.Exec(ctx, saveSessionModelExecution, arg.SessionID, arg.EncryptedConfig) + return err +} diff --git a/services/agents-api/internal/db/sqlc/sessions.sql.go b/services/agents-api/internal/db/sqlc/sessions.sql.go new file mode 100644 index 000000000..00b4db5e8 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/sessions.sql.go @@ -0,0 +1,236 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: sessions.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO sessions (id, tenant_id, engine, metadata, idempotency_key, request_hash, configuration, creation_request_hash, creator_kind, creator_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (tenant_id, idempotency_key) DO UPDATE +SET idempotency_key = EXCLUDED.idempotency_key +WHERE sessions.deleted_at IS NULL + AND sessions.creator_kind = EXCLUDED.creator_kind AND sessions.creator_id = EXCLUDED.creator_id + AND CASE WHEN sessions.creation_request_hash IS NULL + THEN sessions.request_hash = EXCLUDED.request_hash + ELSE sessions.creation_request_hash = EXCLUDED.creation_request_hash END +RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +` + +type CreateSessionParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Engine string `json:"engine"` + Metadata []byte `json:"metadata"` + IdempotencyKey string `json:"idempotency_key"` + RequestHash string `json:"request_hash"` + Configuration []byte `json:"configuration"` + CreationRequestHash pgtype.Text `json:"creation_request_hash"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, createSession, + arg.ID, + arg.TenantID, + arg.Engine, + arg.Metadata, + arg.IdempotencyKey, + arg.RequestHash, + arg.Configuration, + arg.CreationRequestHash, + arg.CreatorKind, + arg.CreatorID, + ) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} + +const findSessionCreation = `-- name: FindSessionCreation :one +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions +WHERE tenant_id = $1 AND idempotency_key = $2 +` + +type FindSessionCreationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + IdempotencyKey string `json:"idempotency_key"` +} + +func (q *Queries) FindSessionCreation(ctx context.Context, arg FindSessionCreationParams) (Session, error) { + row := q.db.QueryRow(ctx, findSessionCreation, arg.TenantID, arg.IdempotencyKey) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} + +const getSession = `-- name: GetSession :one +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL +` + +type GetSessionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, getSession, arg.TenantID, arg.ID) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} + +const listSessions = `-- name: ListSessions :many +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions +WHERE tenant_id = $1 AND deleted_at IS NULL + AND ($2::text IS NULL OR configuration #>> '{agent,id}' = $2::text) + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (created_at, id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (created_at, id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN created_at END ASC, + CASE WHEN $4::boolean THEN id END ASC, + CASE WHEN NOT $4::boolean THEN created_at END DESC, + CASE WHEN NOT $4::boolean THEN id END DESC +LIMIT $6 +` + +type ListSessionsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AgentID pgtype.Text `json:"agent_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListSessions(ctx context.Context, arg ListSessionsParams) ([]Session, error) { + rows, err := q.db.Query(ctx, listSessions, + arg.TenantID, + arg.AgentID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Session{} + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markSessionDeleted = `-- name: MarkSessionDeleted :exec +UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) MarkSessionDeleted(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, markSessionDeleted, id) + return err +} + +const updateSessionMetadata = `-- name: UpdateSessionMetadata :one +UPDATE sessions SET metadata = $3 WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +` + +type UpdateSessionMetadataParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) UpdateSessionMetadata(ctx context.Context, arg UpdateSessionMetadataParams) (Session, error) { + row := q.db.QueryRow(ctx, updateSessionMetadata, arg.TenantID, arg.ID, arg.Metadata) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/source_files.sql.go b/services/agents-api/internal/db/sqlc/source_files.sql.go new file mode 100644 index 000000000..e4f0700be --- /dev/null +++ b/services/agents-api/internal/db/sqlc/source_files.sql.go @@ -0,0 +1,153 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: source_files.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createSourceFile = `-- name: CreateSourceFile :one +INSERT INTO source_files (id, tenant_id, filename, purpose, body_oid, size_bytes, sha256) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, tenant_id, filename, purpose, body_oid, size_bytes, sha256, created_at +` + +type CreateSourceFileParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Filename string `json:"filename"` + Purpose string `json:"purpose"` + BodyOid pgtype.Uint32 `json:"body_oid"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` +} + +func (q *Queries) CreateSourceFile(ctx context.Context, arg CreateSourceFileParams) (SourceFile, error) { + row := q.db.QueryRow(ctx, createSourceFile, + arg.ID, + arg.TenantID, + arg.Filename, + arg.Purpose, + arg.BodyOid, + arg.SizeBytes, + arg.Sha256, + ) + var i SourceFile + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Filename, + &i.Purpose, + &i.BodyOid, + &i.SizeBytes, + &i.Sha256, + &i.CreatedAt, + ) + return i, err +} + +const deleteSourceFile = `-- name: DeleteSourceFile :one +DELETE FROM source_files WHERE tenant_id = $1 AND id = $2 RETURNING body_oid +` + +type DeleteSourceFileParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteSourceFile(ctx context.Context, arg DeleteSourceFileParams) (pgtype.Uint32, error) { + row := q.db.QueryRow(ctx, deleteSourceFile, arg.TenantID, arg.ID) + var body_oid pgtype.Uint32 + err := row.Scan(&body_oid) + return body_oid, err +} + +const getSourceFile = `-- name: GetSourceFile :one +SELECT id, tenant_id, filename, purpose, body_oid, size_bytes, sha256, created_at FROM source_files WHERE tenant_id = $1 AND id = $2 +` + +type GetSourceFileParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSourceFile(ctx context.Context, arg GetSourceFileParams) (SourceFile, error) { + row := q.db.QueryRow(ctx, getSourceFile, arg.TenantID, arg.ID) + var i SourceFile + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Filename, + &i.Purpose, + &i.BodyOid, + &i.SizeBytes, + &i.Sha256, + &i.CreatedAt, + ) + return i, err +} + +const listSourceFiles = `-- name: ListSourceFiles :many +SELECT id, tenant_id, filename, purpose, body_oid, size_bytes, sha256, created_at FROM source_files +WHERE tenant_id = $1 + AND ($2::text IS NULL OR purpose = $2::text) + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (created_at, id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (created_at, id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN created_at END ASC, + CASE WHEN $4::boolean THEN id END ASC, + CASE WHEN NOT $4::boolean THEN created_at END DESC, + CASE WHEN NOT $4::boolean THEN id END DESC +LIMIT $6 +` + +type ListSourceFilesParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + Purpose pgtype.Text `json:"purpose"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListSourceFiles(ctx context.Context, arg ListSourceFilesParams) ([]SourceFile, error) { + rows, err := q.db.Query(ctx, listSourceFiles, + arg.TenantID, + arg.Purpose, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SourceFile{} + for rows.Next() { + var i SourceFile + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Filename, + &i.Purpose, + &i.BodyOid, + &i.SizeBytes, + &i.Sha256, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/db/sqlc/subagent_identities.sql.go b/services/agents-api/internal/db/sqlc/subagent_identities.sql.go new file mode 100644 index 000000000..0b9e7d14b --- /dev/null +++ b/services/agents-api/internal/db/sqlc/subagent_identities.sql.go @@ -0,0 +1,103 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: subagent_identities.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSubagentIdentity = `-- name: GetSubagentIdentity :one +SELECT i.id, i.session_id, i.device_id, i.engine, i.native_id, i.parent_native_id, i.native_created_at, i.first_turn_id, i.first_event_ordinal, e.created_at AS first_observed_at FROM subagent_identities i +JOIN sessions s ON s.id = i.session_id +JOIN turn_events e ON e.turn_id = i.first_turn_id AND e.ordinal = i.first_event_ordinal +WHERE s.tenant_id = $1 AND s.id = $2 + AND s.deleted_at IS NULL AND i.native_id = $3 +` + +type GetSubagentIdentityParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + NativeID string `json:"native_id"` +} + +type GetSubagentIdentityRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + DeviceID pgtype.UUID `json:"device_id"` + Engine string `json:"engine"` + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + FirstTurnID pgtype.UUID `json:"first_turn_id"` + FirstEventOrdinal int32 `json:"first_event_ordinal"` + FirstObservedAt pgtype.Timestamptz `json:"first_observed_at"` +} + +func (q *Queries) GetSubagentIdentity(ctx context.Context, arg GetSubagentIdentityParams) (GetSubagentIdentityRow, error) { + row := q.db.QueryRow(ctx, getSubagentIdentity, arg.TenantID, arg.SessionID, arg.NativeID) + var i GetSubagentIdentityRow + err := row.Scan( + &i.ID, + &i.SessionID, + &i.DeviceID, + &i.Engine, + &i.NativeID, + &i.ParentNativeID, + &i.NativeCreatedAt, + &i.FirstTurnID, + &i.FirstEventOrdinal, + &i.FirstObservedAt, + ) + return i, err +} + +const putSubagentIdentity = `-- name: PutSubagentIdentity :one +INSERT INTO subagent_identities ( + id, session_id, device_id, engine, native_id, parent_native_id, native_created_at, + first_turn_id, first_event_ordinal +) +SELECT $1, s.id, b.device_id, s.engine, $2, + $3, $4, $5, $6 +FROM sessions s JOIN session_devices b ON b.session_id = s.id +WHERE s.id = $7 + AND (b.native_session_id = '' OR b.native_session_id = $3) + AND NOT EXISTS ( + SELECT 1 FROM subagent_identities old + WHERE old.session_id = s.id AND old.parent_native_id <> $3 + ) +ON CONFLICT (device_id, engine, native_id) DO UPDATE SET id = subagent_identities.id +WHERE subagent_identities.session_id = EXCLUDED.session_id + AND subagent_identities.parent_native_id = EXCLUDED.parent_native_id + AND subagent_identities.native_created_at = EXCLUDED.native_created_at +RETURNING id +` + +type PutSubagentIdentityParams struct { + ID pgtype.UUID `json:"id"` + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + FirstTurnID pgtype.UUID `json:"first_turn_id"` + FirstEventOrdinal int32 `json:"first_event_ordinal"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) PutSubagentIdentity(ctx context.Context, arg PutSubagentIdentityParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, putSubagentIdentity, + arg.ID, + arg.NativeID, + arg.ParentNativeID, + arg.NativeCreatedAt, + arg.FirstTurnID, + arg.FirstEventOrdinal, + arg.SessionID, + ) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} diff --git a/services/agents-api/internal/db/sqlc/token_usage.sql.go b/services/agents-api/internal/db/sqlc/token_usage.sql.go new file mode 100644 index 000000000..9787fc434 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/token_usage.sql.go @@ -0,0 +1,45 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: token_usage.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const putTurnUsage = `-- name: PutTurnUsage :exec +UPDATE turns SET token_usage = $3 WHERE session_id = $1 AND id = $2 +` + +type PutTurnUsageParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` + TokenUsage []byte `json:"token_usage"` +} + +func (q *Queries) PutTurnUsage(ctx context.Context, arg PutTurnUsageParams) error { + _, err := q.db.Exec(ctx, putTurnUsage, arg.SessionID, arg.ID, arg.TokenUsage) + return err +} + +const sessionTokenUsage = `-- name: SessionTokenUsage :one +SELECT CASE WHEN count(token_usage) = 0 THEN NULL ELSE jsonb_build_object( + 'input_tokens', sum((token_usage->>'input_tokens')::numeric), + 'input_tokens_details', jsonb_build_object('cached_tokens', sum((token_usage->'input_tokens_details'->>'cached_tokens')::numeric)), + 'output_tokens', sum((token_usage->>'output_tokens')::numeric), + 'output_tokens_details', jsonb_build_object('reasoning_tokens', sum((token_usage->'output_tokens_details'->>'reasoning_tokens')::numeric)), + 'total_tokens', sum((token_usage->>'total_tokens')::numeric) +) END::jsonb AS usage +FROM turns WHERE session_id = $1 +` + +func (q *Queries) SessionTokenUsage(ctx context.Context, sessionID pgtype.UUID) ([]byte, error) { + row := q.db.QueryRow(ctx, sessionTokenUsage, sessionID) + var usage []byte + err := row.Scan(&usage) + return usage, err +} diff --git a/services/agents-api/internal/db/sqlc/turn_events.sql.go b/services/agents-api/internal/db/sqlc/turn_events.sql.go new file mode 100644 index 000000000..fbb47136b --- /dev/null +++ b/services/agents-api/internal/db/sqlc/turn_events.sql.go @@ -0,0 +1,156 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: turn_events.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countTurnEvent = `-- name: CountTurnEvent :exec +UPDATE turns SET event_count = event_count + $3::integer, event_bytes = event_bytes + $4::bigint +WHERE session_id = $1 AND id = $2 +` + +type CountTurnEventParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` + EventCount int32 `json:"event_count"` + PayloadBytes int64 `json:"payload_bytes"` +} + +func (q *Queries) CountTurnEvent(ctx context.Context, arg CountTurnEventParams) error { + _, err := q.db.Exec(ctx, countTurnEvent, + arg.SessionID, + arg.ID, + arg.EventCount, + arg.PayloadBytes, + ) + return err +} + +const insertTurnEvent = `-- name: InsertTurnEvent :exec +INSERT INTO turn_events(session_id, turn_id, ordinal, kind, payload) +VALUES ($1, $2, $3, $4, $5) +` + +type InsertTurnEventParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Ordinal int32 `json:"ordinal"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` +} + +func (q *Queries) InsertTurnEvent(ctx context.Context, arg InsertTurnEventParams) error { + _, err := q.db.Exec(ctx, insertTurnEvent, + arg.SessionID, + arg.TurnID, + arg.Ordinal, + arg.Kind, + arg.Payload, + ) + return err +} + +const insertTurnEventBatch = `-- name: InsertTurnEventBatch :exec +INSERT INTO turn_events(session_id, turn_id, ordinal, kind, payload) +SELECT $1, $2, $3::integer + item.n::integer - 1, item.value->>'kind', item.value->'payload' +FROM jsonb_array_elements($4::jsonb) WITH ORDINALITY AS item(value, n) +` + +type InsertTurnEventBatchParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + FirstOrdinal int32 `json:"first_ordinal"` + Batch []byte `json:"batch"` +} + +func (q *Queries) InsertTurnEventBatch(ctx context.Context, arg InsertTurnEventBatchParams) error { + _, err := q.db.Exec(ctx, insertTurnEventBatch, + arg.SessionID, + arg.TurnID, + arg.FirstOrdinal, + arg.Batch, + ) + return err +} + +const listTurnEvents = `-- name: ListTurnEvents :many +SELECT e.session_id, e.turn_id, e.ordinal, e.kind, e.payload, e.created_at FROM turn_events e JOIN sessions s ON s.id = e.session_id +WHERE s.tenant_id = $1 AND e.session_id = $2 AND e.turn_id = $3 AND e.ordinal > $4 +ORDER BY e.ordinal LIMIT $5 +` + +type ListTurnEventsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Ordinal int32 `json:"ordinal"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListTurnEvents(ctx context.Context, arg ListTurnEventsParams) ([]TurnEvent, error) { + rows, err := q.db.Query(ctx, listTurnEvents, + arg.TenantID, + arg.SessionID, + arg.TurnID, + arg.Ordinal, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []TurnEvent{} + for rows.Next() { + var i TurnEvent + if err := rows.Scan( + &i.SessionID, + &i.TurnID, + &i.Ordinal, + &i.Kind, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const matchTurnEventBatch = `-- name: MatchTurnEventBatch :one +SELECT COALESCE(jsonb_agg(jsonb_build_object('kind', e.kind, 'payload', e.payload) ORDER BY ordinal) + = $3::jsonb, false)::boolean AS matches +FROM turn_events e WHERE session_id = $1 AND turn_id = $2 + AND ordinal >= $4 AND ordinal < $4::integer + $5::integer +` + +type MatchTurnEventBatchParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Batch []byte `json:"batch"` + FirstOrdinal int32 `json:"first_ordinal"` + EventCount int32 `json:"event_count"` +} + +func (q *Queries) MatchTurnEventBatch(ctx context.Context, arg MatchTurnEventBatchParams) (bool, error) { + row := q.db.QueryRow(ctx, matchTurnEventBatch, + arg.SessionID, + arg.TurnID, + arg.Batch, + arg.FirstOrdinal, + arg.EventCount, + ) + var matches bool + err := row.Scan(&matches) + return matches, err +} diff --git a/services/agents-api/internal/db/sqlc/turn_reads.sql.go b/services/agents-api/internal/db/sqlc/turn_reads.sql.go new file mode 100644 index 000000000..b2137b465 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/turn_reads.sql.go @@ -0,0 +1,75 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: turn_reads.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listTurns = `-- name: ListTurns :many +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE s.tenant_id = $1 AND t.session_id = $2 + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (t.created_at, t.id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (t.created_at, t.id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN t.created_at END ASC, + CASE WHEN $4::boolean THEN t.id END ASC, + CASE WHEN NOT $4::boolean THEN t.created_at END DESC, + CASE WHEN NOT $4::boolean THEN t.id END DESC +LIMIT $6 +` + +type ListTurnsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]Turn, error) { + rows, err := q.db.Query(ctx, listTurns, + arg.TenantID, + arg.SessionID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Turn{} + for rows.Next() { + var i Turn + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/db/sqlc/turns.sql.go b/services/agents-api/internal/db/sqlc/turns.sql.go new file mode 100644 index 000000000..1c718729f --- /dev/null +++ b/services/agents-api/internal/db/sqlc/turns.sql.go @@ -0,0 +1,320 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: turns.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createTurn = `-- name: CreateTurn :one +INSERT INTO turns(id, session_id) VALUES ($1, $2) RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started +` + +type CreateTurnParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) CreateTurn(ctx context.Context, arg CreateTurnParams) (Turn, error) { + row := q.db.QueryRow(ctx, createTurn, arg.ID, arg.SessionID) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} + +const createTurnInput = `-- name: CreateTurnInput :one +INSERT INTO turn_inputs(session_id, turn_id, idempotency_key, kind, payload, batch_position) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING sequence +` + +type CreateTurnInputParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + IdempotencyKey string `json:"idempotency_key"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` + BatchPosition int32 `json:"batch_position"` +} + +func (q *Queries) CreateTurnInput(ctx context.Context, arg CreateTurnInputParams) (int64, error) { + row := q.db.QueryRow(ctx, createTurnInput, + arg.SessionID, + arg.TurnID, + arg.IdempotencyKey, + arg.Kind, + arg.Payload, + arg.BatchPosition, + ) + var sequence int64 + err := row.Scan(&sequence) + return sequence, err +} + +const findInputBatch = `-- name: FindInputBatch :many +WITH previous AS ( + SELECT sequence, turn_id, kind, payload, batch_position FROM turn_inputs + WHERE session_id = $1 AND idempotency_key = $2 +) +SELECT sequence, turn_id, COALESCE(( + SELECT jsonb_agg(jsonb_build_object('kind', kind, 'payload', payload) ORDER BY batch_position) + = $3::jsonb FROM previous +), false)::boolean AS matches +FROM previous ORDER BY batch_position +` + +type FindInputBatchParams struct { + SessionID pgtype.UUID `json:"session_id"` + IdempotencyKey string `json:"idempotency_key"` + Batch []byte `json:"batch"` +} + +type FindInputBatchRow struct { + Sequence int64 `json:"sequence"` + TurnID pgtype.UUID `json:"turn_id"` + Matches bool `json:"matches"` +} + +func (q *Queries) FindInputBatch(ctx context.Context, arg FindInputBatchParams) ([]FindInputBatchRow, error) { + rows, err := q.db.Query(ctx, findInputBatch, arg.SessionID, arg.IdempotencyKey, arg.Batch) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FindInputBatchRow{} + for rows.Next() { + var i FindInputBatchRow + if err := rows.Scan(&i.Sequence, &i.TurnID, &i.Matches); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getActiveTurn = `-- name: GetActiveTurn :one +SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started FROM turns WHERE session_id = $1 AND status IN ('queued', 'in_progress', 'waiting') +` + +func (q *Queries) GetActiveTurn(ctx context.Context, sessionID pgtype.UUID) (Turn, error) { + row := q.db.QueryRow(ctx, getActiveTurn, sessionID) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} + +const getTurn = `-- name: GetTurn :one +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id +WHERE s.tenant_id = $1 AND t.session_id = $2 AND t.id = $3 +` + +type GetTurnParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetTurn(ctx context.Context, arg GetTurnParams) (Turn, error) { + row := q.db.QueryRow(ctx, getTurn, arg.TenantID, arg.SessionID, arg.ID) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} + +const hasUnappliedMessages = `-- name: HasUnappliedMessages :one +SELECT EXISTS(SELECT 1 FROM turn_inputs +WHERE session_id = $1 AND turn_id = $2 AND sequence > $3 AND kind = 'message') +` + +type HasUnappliedMessagesParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Sequence int64 `json:"sequence"` +} + +func (q *Queries) HasUnappliedMessages(ctx context.Context, arg HasUnappliedMessagesParams) (bool, error) { + row := q.db.QueryRow(ctx, hasUnappliedMessages, arg.SessionID, arg.TurnID, arg.Sequence) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listTurnInputs = `-- name: ListTurnInputs :many +SELECT i.sequence, i.session_id, i.turn_id, i.idempotency_key, i.kind, i.payload, i.created_at, i.batch_position FROM turn_inputs i JOIN sessions s ON s.id = i.session_id +WHERE s.tenant_id = $1 AND i.session_id = $2 AND i.turn_id = $3 AND i.sequence > $4 +ORDER BY i.sequence LIMIT $5 +` + +type ListTurnInputsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` + TurnID pgtype.UUID `json:"turn_id"` + Sequence int64 `json:"sequence"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListTurnInputs(ctx context.Context, arg ListTurnInputsParams) ([]TurnInput, error) { + rows, err := q.db.Query(ctx, listTurnInputs, + arg.TenantID, + arg.SessionID, + arg.TurnID, + arg.Sequence, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []TurnInput{} + for rows.Next() { + var i TurnInput + if err := rows.Scan( + &i.Sequence, + &i.SessionID, + &i.TurnID, + &i.IdempotencyKey, + &i.Kind, + &i.Payload, + &i.CreatedAt, + &i.BatchPosition, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockSession = `-- name: LockSession :one +SELECT id, deleted_at FROM sessions WHERE tenant_id = $1 AND id = $2 FOR UPDATE +` + +type LockSessionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type LockSessionRow struct { + ID pgtype.UUID `json:"id"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +func (q *Queries) LockSession(ctx context.Context, arg LockSessionParams) (LockSessionRow, error) { + row := q.db.QueryRow(ctx, lockSession, arg.TenantID, arg.ID) + var i LockSessionRow + err := row.Scan(&i.ID, &i.DeletedAt) + return i, err +} + +const requestTurnCancel = `-- name: RequestTurnCancel :exec +UPDATE turns SET cancel_requested_at = COALESCE(cancel_requested_at, clock_timestamp()), + completed_at = CASE WHEN status = 'queued' THEN clock_timestamp() ELSE completed_at END, + status = CASE WHEN status = 'queued' THEN 'cancelled' ELSE status END +WHERE id = $1 AND session_id = $2 AND status IN ('queued', 'in_progress', 'waiting') +` + +type RequestTurnCancelParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) RequestTurnCancel(ctx context.Context, arg RequestTurnCancelParams) error { + _, err := q.db.Exec(ctx, requestTurnCancel, arg.ID, arg.SessionID) + return err +} + +const transitionTurn = `-- name: TransitionTurn :one +UPDATE turns SET status = $1, outcome = $2, + started_at = CASE WHEN $1::text = 'in_progress' + THEN COALESCE(started_at, clock_timestamp()) ELSE started_at END, + completed_at = CASE WHEN $1::text IN ('completed', 'failed', 'cancelled') + THEN clock_timestamp() ELSE NULL END +WHERE id = $3 AND session_id = $4 + AND status = $5 + AND status IN ('queued', 'in_progress', 'waiting') + AND ($1::text <> 'in_progress' OR cancel_requested_at IS NULL) +RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started +` + +type TransitionTurnParams struct { + NewStatus string `json:"new_status"` + Outcome []byte `json:"outcome"` + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + ExpectedStatus string `json:"expected_status"` +} + +func (q *Queries) TransitionTurn(ctx context.Context, arg TransitionTurnParams) (Turn, error) { + row := q.db.QueryRow(ctx, transitionTurn, + arg.NewStatus, + arg.Outcome, + arg.ID, + arg.SessionID, + arg.ExpectedStatus, + ) + var i Turn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.CancelRequestedAt, + &i.Outcome, + &i.EventCount, + &i.EventBytes, + &i.TokenUsage, + &i.ArtifactCaptureStarted, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/vault_credentials.sql.go b/services/agents-api/internal/db/sqlc/vault_credentials.sql.go new file mode 100644 index 000000000..bdfd9d8bb --- /dev/null +++ b/services/agents-api/internal/db/sqlc/vault_credentials.sql.go @@ -0,0 +1,242 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: vault_credentials.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createStaticCredential = `-- name: CreateStaticCredential :one +INSERT INTO vault_credentials (id, vault_id, name, auth_type, mcp_server_url, token_ciphertext) +SELECT $1, v.id, $2, 'static_bearer', $3, $4 +FROM vaults v +WHERE v.tenant_id = $5 AND v.id = $6 +RETURNING id, vault_id, name, auth_type, mcp_server_url, created_at, updated_at +` + +type CreateStaticCredentialParams struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + McpServerUrl string `json:"mcp_server_url"` + TokenCiphertext []byte `json:"token_ciphertext"` + TenantID pgtype.UUID `json:"tenant_id"` + VaultID pgtype.UUID `json:"vault_id"` +} + +type CreateStaticCredentialRow struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) CreateStaticCredential(ctx context.Context, arg CreateStaticCredentialParams) (CreateStaticCredentialRow, error) { + row := q.db.QueryRow(ctx, createStaticCredential, + arg.ID, + arg.Name, + arg.McpServerUrl, + arg.TokenCiphertext, + arg.TenantID, + arg.VaultID, + ) + var i CreateStaticCredentialRow + err := row.Scan( + &i.ID, + &i.VaultID, + &i.Name, + &i.AuthType, + &i.McpServerUrl, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteCredential = `-- name: DeleteCredential :one +DELETE FROM vault_credentials c +USING vaults v +WHERE v.id = c.vault_id AND v.tenant_id = $1 + AND v.id = $2 AND c.id = $3 +RETURNING c.id +` + +type DeleteCredentialParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultID pgtype.UUID `json:"vault_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteCredential(ctx context.Context, arg DeleteCredentialParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteCredential, arg.TenantID, arg.VaultID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getCredential = `-- name: GetCredential :one +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = $1 AND v.id = $2 AND c.id = $3 +` + +type GetCredentialParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultID pgtype.UUID `json:"vault_id"` + ID pgtype.UUID `json:"id"` +} + +type GetCredentialRow struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetCredential(ctx context.Context, arg GetCredentialParams) (GetCredentialRow, error) { + row := q.db.QueryRow(ctx, getCredential, arg.TenantID, arg.VaultID, arg.ID) + var i GetCredentialRow + err := row.Scan( + &i.ID, + &i.VaultID, + &i.Name, + &i.AuthType, + &i.McpServerUrl, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listCredentials = `-- name: ListCredentials :many +SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +FROM vault_credentials c +JOIN vaults v ON v.id = c.vault_id +WHERE v.tenant_id = $1 AND v.id = $2 + AND c.status = ANY($3::text[]) + AND ($4::timestamptz IS NULL + OR (NOT $5::boolean AND (c.created_at, c.id) < ($4::timestamptz, $6::uuid)) + OR ($5::boolean AND (c.created_at, c.id) > ($4::timestamptz, $6::uuid))) +ORDER BY + CASE WHEN $5::boolean THEN c.created_at END ASC, + CASE WHEN $5::boolean THEN c.id END ASC, + CASE WHEN NOT $5::boolean THEN c.created_at END DESC, + CASE WHEN NOT $5::boolean THEN c.id END DESC +LIMIT $7 +` + +type ListCredentialsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + VaultID pgtype.UUID `json:"vault_id"` + Statuses []string `json:"statuses"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListCredentialsRow struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListCredentials(ctx context.Context, arg ListCredentialsParams) ([]ListCredentialsRow, error) { + rows, err := q.db.Query(ctx, listCredentials, + arg.TenantID, + arg.VaultID, + arg.Statuses, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListCredentialsRow{} + for rows.Next() { + var i ListCredentialsRow + if err := rows.Scan( + &i.ID, + &i.VaultID, + &i.Name, + &i.AuthType, + &i.McpServerUrl, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateStaticCredential = `-- name: UpdateStaticCredential :one +UPDATE vault_credentials c +SET token_ciphertext = $1, updated_at = statement_timestamp() +FROM vaults v +WHERE v.id = c.vault_id AND v.tenant_id = $2 + AND v.id = $3 AND c.id = $4 + AND c.auth_type = 'static_bearer' AND c.mcp_server_url = $5 +RETURNING c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at +` + +type UpdateStaticCredentialParams struct { + TokenCiphertext []byte `json:"token_ciphertext"` + TenantID pgtype.UUID `json:"tenant_id"` + VaultID pgtype.UUID `json:"vault_id"` + ID pgtype.UUID `json:"id"` + McpServerUrl string `json:"mcp_server_url"` +} + +type UpdateStaticCredentialRow struct { + ID pgtype.UUID `json:"id"` + VaultID pgtype.UUID `json:"vault_id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + McpServerUrl string `json:"mcp_server_url"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateStaticCredential(ctx context.Context, arg UpdateStaticCredentialParams) (UpdateStaticCredentialRow, error) { + row := q.db.QueryRow(ctx, updateStaticCredential, + arg.TokenCiphertext, + arg.TenantID, + arg.VaultID, + arg.ID, + arg.McpServerUrl, + ) + var i UpdateStaticCredentialRow + err := row.Scan( + &i.ID, + &i.VaultID, + &i.Name, + &i.AuthType, + &i.McpServerUrl, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/vaults.sql.go b/services/agents-api/internal/db/sqlc/vaults.sql.go new file mode 100644 index 000000000..8d83bfa9b --- /dev/null +++ b/services/agents-api/internal/db/sqlc/vaults.sql.go @@ -0,0 +1,141 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: vaults.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createVault = `-- name: CreateVault :one +INSERT INTO vaults (id, tenant_id, name, metadata) +VALUES ($1, $2, $3, $4) +RETURNING id, tenant_id, name, metadata, created_at, status +` + +type CreateVaultParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) CreateVault(ctx context.Context, arg CreateVaultParams) (Vault, error) { + row := q.db.QueryRow(ctx, createVault, + arg.ID, + arg.TenantID, + arg.Name, + arg.Metadata, + ) + var i Vault + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Metadata, + &i.CreatedAt, + &i.Status, + ) + return i, err +} + +const deleteVault = `-- name: DeleteVault :one +DELETE FROM vaults WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteVaultParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteVault(ctx context.Context, arg DeleteVaultParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteVault, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getVault = `-- name: GetVault :one +SELECT id, tenant_id, name, metadata, created_at, status FROM vaults WHERE tenant_id = $1 AND id = $2 +` + +type GetVaultParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetVault(ctx context.Context, arg GetVaultParams) (Vault, error) { + row := q.db.QueryRow(ctx, getVault, arg.TenantID, arg.ID) + var i Vault + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Metadata, + &i.CreatedAt, + &i.Status, + ) + return i, err +} + +const listVaults = `-- name: ListVaults :many +SELECT id, tenant_id, name, metadata, created_at, status FROM vaults +WHERE tenant_id = $1 + AND status = ANY($2::text[]) + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (created_at, id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (created_at, id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN created_at END ASC, + CASE WHEN $4::boolean THEN id END ASC, + CASE WHEN NOT $4::boolean THEN created_at END DESC, + CASE WHEN NOT $4::boolean THEN id END DESC +LIMIT $6 +` + +type ListVaultsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + Statuses []string `json:"statuses"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListVaults(ctx context.Context, arg ListVaultsParams) ([]Vault, error) { + rows, err := q.db.Query(ctx, listVaults, + arg.TenantID, + arg.Statuses, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Vault{} + for rows.Next() { + var i Vault + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Metadata, + &i.CreatedAt, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go new file mode 100644 index 000000000..95ae078e4 --- /dev/null +++ b/services/agents-api/internal/engine/claude.go @@ -0,0 +1,57 @@ +package engine + +import ( + "encoding/json" + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func claudeProfile() Profile { + return Profile{ + Placements: []string{"none", "openai_hosted"}, MCPBearer: true, + ValidateConfiguration: validateClaudeConfiguration, + ValidateTools: validateClaudeTools, + ValidateFunctionResult: func(content []proto.FunctionResultContent) error { + for _, part := range content { + if part.Type != "input_text" { + return ErrInvalidInput + } + } + return nil + }, + } +} + +func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error { + if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted") || hasDaemon || strings.TrimSpace(agent.Model) == "" { + return ErrInvalidInput + } + if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { + return errors.New("The configured engine currently supports medium text verbosity only.") + } + if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { + return ErrInvalidInput + } + return nil +} + +func validateClaudeTools(environment *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if environment.Type == "openai_hosted" && len(mcp) != 0 { + return errors.New("The configured workspace profile does not support HTTP MCP tools.") + } + if err := validateClaudeMCP(mcp); err != nil { + return err + } + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine currently requires function schemas with root type object.") + } + } + return nil +} diff --git a/services/agents-api/internal/engine/claude_mcp.go b/services/agents-api/internal/engine/claude_mcp.go new file mode 100644 index 000000000..a65125a55 --- /dev/null +++ b/services/agents-api/internal/engine/claude_mcp.go @@ -0,0 +1,30 @@ +package engine + +import ( + "errors" + "regexp" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var claudeMCPLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var claudeMCPTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) + +// These are execution limits of the packaged adapter, not saved-Agent schema rules. +// Shared resolution still owns URL, transport and credential-binding validation. +func validateClaudeMCP(servers []proto.MCPHTTPServer) error { + for _, server := range servers { + if !claudeMCPLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.ContainsAny(server.ServerURL, "?#") { + return errors.New("The configured engine does not support this HTTP MCP declaration.") + } + if server.AllowedTools != nil { + for _, name := range *server.AllowedTools { + if !claudeMCPTool.MatchString(name) { + return errors.New("The configured engine does not support this MCP tool name.") + } + } + } + } + return nil +} diff --git a/services/agents-api/internal/engine/codex.go b/services/agents-api/internal/engine/codex.go new file mode 100644 index 000000000..bb79af7d5 --- /dev/null +++ b/services/agents-api/internal/engine/codex.go @@ -0,0 +1,21 @@ +package engine + +import ( + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func codexProfile() Profile { + return Profile{ + Placements: []string{"none", "self_hosted", "openai_hosted"}, + WebSearchControl: true, TextVerbosity: true, MCPBearer: true, + ValidateTools: func(environment *v1.Environment, hasDaemon bool, _ []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if len(mcp) != 0 && (environment == nil || (environment.Type != "none" && environment.Type != "self_hosted") || hasDaemon) { + return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") + } + return nil + }, + } +} diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go new file mode 100644 index 000000000..e101a4df9 --- /dev/null +++ b/services/agents-api/internal/engine/mcode.go @@ -0,0 +1,23 @@ +package engine + +import ( + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func mcodeProfile() Profile { + return Profile{Placements: []string{"none", "openai_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { + if e == nil || (e.Type != "none" && e.Type != "openai_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.MultiAgent.Enabled || a.MultiAgent.MaxConcurrentSubagents != nil || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { + return ErrInvalidInput + } + return nil + }, ValidateTools: func(_ *v1.Environment, _ bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if len(functions) > 0 || len(mcp) > 0 { + return errors.New("The configured engine does not support public functions or MCP tools.") + } + return nil + }} +} diff --git a/services/agents-api/internal/engine/profile.go b/services/agents-api/internal/engine/profile.go new file mode 100644 index 000000000..a8549a999 --- /dev/null +++ b/services/agents-api/internal/engine/profile.go @@ -0,0 +1,54 @@ +package engine + +import ( + "errors" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var ErrInvalidInput = errors.New("invalid engine configuration") + +// Profile records qualified public behavior, independently of Runtime advertisements. +type Profile struct { + Placements []string + WebSearchControl, TextVerbosity, MCPBearer bool + ValidateConfiguration func(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error + ValidateTools func(environment *v1.Environment, hasDaemon bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error + ValidateFunctionResult func([]proto.FunctionResultContent) error +} + +func (p Profile) Accepts(placement string) bool { + return slices.Contains(p.Placements, placement) +} + +// Catalog is immutable after construction. Its zero value selects built-in profiles. +// NewCatalog with an empty map explicitly qualifies no engines. +type Catalog struct { + profiles map[string]Profile +} + +func NewCatalog(profiles map[string]Profile) Catalog { + c := Catalog{profiles: make(map[string]Profile, len(profiles))} + for kind, profile := range profiles { + profile.Placements = slices.Clone(profile.Placements) + c.profiles[kind] = profile + } + return c +} + +func (c Catalog) Lookup(kind string) (Profile, bool) { + if c.profiles == nil { + c = qualified + } + profile, ok := c.profiles[kind] + profile.Placements = slices.Clone(profile.Placements) + return profile, ok +} + +var qualified = NewCatalog(map[string]Profile{ + "codex": codexProfile(), + "claude_sdk": claudeProfile(), + "mcode": mcodeProfile(), +}) diff --git a/services/agents-api/internal/engine/profile_test.go b/services/agents-api/internal/engine/profile_test.go new file mode 100644 index 000000000..c793314fd --- /dev/null +++ b/services/agents-api/internal/engine/profile_test.go @@ -0,0 +1,39 @@ +package engine + +import "testing" + +func TestCatalogOwnsQualification(t *testing.T) { + placements := []string{"none"} + profiles := map[string]Profile{"fixture": {Placements: placements}} + catalog := NewCatalog(profiles) + placements[0] = "openai_hosted" + profiles["fixture"] = Profile{MCPBearer: true} + profiles["unqualified"] = Profile{Placements: []string{"none"}} + profile, ok := catalog.Lookup("fixture") + if !ok || !profile.Accepts("none") || profile.Accepts("openai_hosted") || profile.MCPBearer { + t.Fatal("caller changed catalog qualification") + } + profile.Placements[0] = "self_hosted" + profile.MCPBearer = true + again, _ := catalog.Lookup("fixture") + if !again.Accepts("none") || again.MCPBearer { + t.Fatal("lookup exposed mutable qualification") + } + if _, ok := catalog.Lookup("unqualified"); ok { + t.Fatal("caller registered an engine after catalog construction") + } + if _, ok := (Catalog{}).Lookup("fixture"); ok { + t.Fatal("fixture widened the service catalog") + } +} + +func TestExplicitCatalogDoesNotInheritBuiltins(t *testing.T) { + for _, catalog := range []Catalog{NewCatalog(nil), NewCatalog(map[string]Profile{"fixture": {Placements: []string{"none"}}})} { + if _, ok := catalog.Lookup("codex"); ok { + t.Fatal("explicit catalog inherited built-in qualification") + } + } + if _, ok := (Catalog{}).Lookup("codex"); !ok { + t.Fatal("zero-value catalog lost built-in qualification") + } +} diff --git a/services/agents-api/internal/execution/artifacts.go b/services/agents-api/internal/execution/artifacts.go new file mode 100644 index 000000000..187f7d4b0 --- /dev/null +++ b/services/agents-api/internal/execution/artifacts.go @@ -0,0 +1,43 @@ +package execution + +import ( + "context" + "errors" + "io" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (d *Dispatcher) captureCompletedArtifacts(ctx context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, turnID string, result Result, status string) (Result, string) { + if status != store.TurnCompleted || !LocalWorkspaceConfiguration(environment.Configuration) { + return result, status + } + owner, cancel := context.WithTimeout(ctx, 180*time.Second) + defer cancel() + err := d.Store.BeginTurnArtifactCapture(owner, session.TenantID, session.ID, turnID, result.AppliedThrough) + if err == nil { + err = d.withPreparedWorkspace(owner, peer, session, environment, bound, func(ctx context.Context, handle string) error { + return peer.ExportWorkspaceOutputs(ctx, proto.WorkspaceExportPayload{Handle: handle, EnvironmentID: environment.ID}, func(body io.Reader) error { + return d.Store.StageTurnArtifacts(ctx, session.TenantID, session.ID, turnID, environment.ID, body) + }) + }) + } + if err == nil { + return result, status + } + // Do not expose native diagnostics or publish partial output after a failed capture. + result.ErrorCode = "artifact_capture_failed" + if errors.Is(err, store.ErrUnappliedInputs) { + result.ErrorCode = "input_not_applied" + } + status = store.TurnFailed + check, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + if turn, err := d.Store.GetTurn(check, session.TenantID, session.ID, turnID); err == nil && !turn.CancelRequestedAt.IsZero() { + status = store.TurnCancelled + } + return result, status +} diff --git a/services/agents-api/internal/execution/delivery.go b/services/agents-api/internal/execution/delivery.go new file mode 100644 index 000000000..d6ff50b73 --- /dev/null +++ b/services/agents-api/internal/execution/delivery.go @@ -0,0 +1,326 @@ +package execution + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type pendingInput struct { + sequence int64 + text string + started time.Time + waiting bool + written bool +} + +type cancellationResult struct { + ack proto.InteractionDecisionAckPayload + err error +} + +func requestCancellation(ctx context.Context, peer *gateway.Session, runID string) <-chan cancellationResult { + out := make(chan cancellationResult, 1) + go func() { + id := "cancel:" + runID + env, _ := proto.NewEnvelope(proto.TypePromptCancel, runID, proto.PromptCancelPayload{DeliveryID: id}) + ack, err := peer.SendAndWaitInteractionAck(ctx, env, id) + out <- cancellationResult{ack: ack, err: err} + }() + return out +} + +func send(ctx context.Context, peer *gateway.Session, kind, runID string, payload any) error { + env, err := proto.NewEnvelope(kind, runID, payload) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return peer.Send(ctx, env) +} + +func abort(peer *gateway.Session, runID string) { + _ = send(context.Background(), peer, proto.TypePromptCancel, runID, proto.PromptCancelPayload{}) +} + +func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, peer *gateway.Session, request proto.PromptRequestPayload, first int64, prepared *preparedStart) (result Result, status string) { + status = store.TurnFailed + result.AppliedThrough = first + subscription, err := peer.SubscribeDurable(request.RunID) + if err != nil { + result.ErrorCode = "device_disconnected" + return + } + upstream := subscription.Events + defer peer.Unsubscribe(request.RunID) + defer func() { + if status == store.TurnFailed { + abort(peer, request.RunID) + } + }() + journal := &journal{store: d.Store, tenant: tenantID, session: sessionID, turn: request.RunID, next: 1, + observeSubagents: request.ObserveSubagentIdentities} + defer func() { + finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := journal.drain(upstream, &result); err != nil { + result.ErrorCode, status = "event_persistence_failed", store.TurnFailed + } + if err := journal.flush(finishCtx); err != nil { + result.ErrorCode, status = "event_persistence_failed", store.TurnFailed + } + if subscription.Err() != nil { + result.ErrorCode, status = "event_stream_incomplete", store.TurnFailed + } + }() + var preparationEvents <-chan proto.Envelope + if prepared != nil { + preparationEvents = prepared.sub.Events + err = prepared.start(ctx, request) + } else { + err = send(ctx, peer, proto.TypePromptRequest, request.RunID, request) + } + if err != nil { + result.ErrorCode = "delivery_unknown" + return + } + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + flushTicker := time.NewTicker(100 * time.Millisecond) + defer flushTicker.Stop() + var pending *pendingInput + var cancelSent time.Time + var cancelReply <-chan cancellationResult + functions := &functionExchange{store: d.Store, tenant: tenantID, session: sessionID, turn: request.RunID, tools: request.FunctionTools} + done := false + cancelCtx, stopCancellation := context.WithCancel(ctx) + defer stopCancellation() + for { + if done && functions.reply == nil && preparationEvents == nil { + status = finishDelivery(ctx, journal, &result, cancelReply, pending != nil, functions) + return + } + select { + case env, ok := <-preparationEvents: + if !ok { + if cancelReply != nil { + preparationEvents = nil + continue + } + result.ErrorCode = "preparation_interrupted" + return + } + started, err := prepared.started(env, request.RunID) + if err != nil { + // Cancellation owns the receipt even when it interrupts native Start. + if cancelReply != nil { + preparationEvents = nil + continue + } + result.ErrorCode = "preparation_start_failed" + return + } + if started { + preparationEvents = nil + } + case reply := <-functions.reply: + // Once cancellation is sent, its receipt owns the terminal outcome. + if err := functions.confirm(ctx, reply); err != nil && cancelReply == nil { + result.ErrorCode = "function_result_unconfirmed" + return + } + case <-flushTicker.C: + if journal.flush(ctx) != nil { + result.ErrorCode = "event_persistence_failed" + return + } + case reply := <-cancelReply: + drainErr := journal.drain(upstream, &result) + receiptErr := recordCancellation(ctx, journal, reply, &result) + if drainErr != nil || receiptErr != nil { + result.ErrorCode = "event_persistence_failed" + return + } + if reply.err == nil && reply.ack.Applied { + if reply.ack.Outcome == nil { + result.ErrorCode = "cancel_outcome_unavailable" + return + } + status = store.TurnCancelled + } else { + result.ErrorCode = "cancel_unconfirmed" + } + return + case <-ctx.Done(): + result.ErrorCode = "execution_interrupted" + return + case env, ok := <-upstream: + if !ok { + result.ErrorCode = "device_disconnected" + return + } + writeErr := journal.observe(ctx, env) + if result.mergeObservation(env) != nil { + result.ErrorCode = "invalid_executor_result" + return + } + if writeErr != nil { + result.ErrorCode = "event_persistence_failed" + return + } + switch env.Type { + case proto.TypeError: + var failure proto.ErrorPayload + if env.DecodePayload(&failure) != nil { + result.ErrorCode = "invalid_executor_result" + return + } + result.ErrorCode, result.Error = "engine_failed", failure.Error + case proto.TypeDone: + // Done closes the event subscription; application receipts have a separate waiter. + done, upstream = true, nil + case proto.TypeFunctionCall: + if err := journal.flush(ctx); err != nil { + result.ErrorCode = "event_persistence_failed" + return + } + if err := functions.record(ctx, env); err != nil { + result.ErrorCode = "function_call_invalid" + return + } + case proto.TypePromptSteerAck: + var ack proto.PromptSteerAckPayload + if env.DecodePayload(&ack) != nil { + result.ErrorCode = "invalid_executor_result" + return + } + if pending == nil || ack.InputID != strconv.FormatInt(pending.sequence, 10) { + continue + } + switch { + case ack.Accepted: + result.AppliedThrough = pending.sequence + pending = nil + case ack.Written && !ack.Accepted && ack.ErrorCode == "": + pending.written, pending.waiting = true, true + case (ack.ErrorCode == "not_ready" || ack.ErrorCode == "busy") && !pending.written: + pending.waiting = false + case ack.ErrorCode == "in_flight": + default: + if cancelReply == nil { + result.ErrorCode, result.Error = "input_"+ack.ErrorCode, ack.Error + return + } + } + case proto.TypePermissionRequest, proto.TypePromptForUserChoice, proto.TypeAuthoringRequest: + result.ErrorCode = "interaction_not_supported" + return + } + case <-ticker.C: + if !cancelSent.IsZero() { + if time.Since(cancelSent) > 15*time.Second { + result.ErrorCode = "cancel_unconfirmed" + return + } + continue + } + turn, err := d.Store.GetTurn(ctx, tenantID, sessionID, request.RunID) + if err != nil { + result.ErrorCode = "execution_state_unavailable" + return + } + if turn.Status != store.TurnInProgress && turn.Status != store.TurnWaiting { + result.ErrorCode = "execution_state_changed" + return + } + if !turn.CancelRequestedAt.IsZero() { + cancelReply = requestCancellation(cancelCtx, peer, request.RunID) + cancelSent = time.Now() + continue + } + if err := functions.start(cancelCtx, peer); err != nil { + result.ErrorCode = "function_result_invalid" + return + } + if done { + continue + } + if pending == nil { + inputs, err := d.Store.ListTurnInputs(ctx, tenantID, sessionID, request.RunID, result.AppliedThrough, 1) + if err != nil { + result.ErrorCode = "execution_state_unavailable" + return + } + if len(inputs) == 0 { + continue + } + if inputs[0].Kind == "tool_result" { + // Function results use their own application receipts, not message steering. + result.AppliedThrough = inputs[0].Sequence + continue + } + if inputs[0].Kind == "cancel" { + continue + } + text, err := messageText(inputs[0].Payload) + if err != nil || inputs[0].Kind != "message" { + result.ErrorCode = "invalid_input" + return + } + pending = &pendingInput{sequence: inputs[0].Sequence, text: text, started: time.Now()} + } + if !pending.written && time.Since(pending.started) > 30*time.Second { + result.ErrorCode = "input_outcome_unknown" + return + } + if !pending.waiting && !pending.written { + if send(ctx, peer, proto.TypePromptSteer, request.RunID, proto.PromptSteerPayload{InputID: strconv.FormatInt(pending.sequence, 10), Text: pending.text, DurableReceipt: true}) != nil { + result.ErrorCode = "input_outcome_unknown" + return + } + pending.waiting = true + } + } + } +} + +func (r *Result) mergeObservation(env proto.Envelope) error { + switch env.Type { + case proto.TypeUsage: + return env.DecodePayload(&r.Done.Usage) + case proto.TypeDone: + return r.mergeDone(env.Payload) + } + return nil +} + +func (r *Result) mergeDone(raw json.RawMessage) error { + var done proto.DonePayload + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &done); err != nil { + return err + } + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + if _, present := fields["usage"]; !present { + done.Usage = r.Done.Usage + } + if done.Usage.Model == "" { + done.Usage.Model = r.Done.Usage.Model + } + if done.Content == "" { + done.Content = r.Done.Content + } + if done.Metadata == nil { + done.Metadata = r.Done.Metadata + } + r.Done = done + return nil +} diff --git a/services/agents-api/internal/execution/directory_preparation.go b/services/agents-api/internal/execution/directory_preparation.go new file mode 100644 index 000000000..c82cee883 --- /dev/null +++ b/services/agents-api/internal/execution/directory_preparation.go @@ -0,0 +1,114 @@ +package execution + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (d *Dispatcher) readPreparedDirectory(ctx context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, read proto.WorkspaceReadPayload) directoryReadResult { + owner, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + var result directoryReadResult + err := d.withPreparedWorkspace(owner, peer, session, environment, bound, func(ctx context.Context, handle string) error { + read.Handle = handle + result = readEnvironmentDirectory(ctx, peer, read) + return result.err + }) + if err != nil { + return directoryReadResult{err: err} + } + return result +} + +func (d *Dispatcher) withPreparedWorkspace(owner context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, consume func(context.Context, string) error) error { + req := proto.PromptRequestPayload{AgentKind: session.Engine, AgentStateKey: "agents-api-" + session.ID, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + release, err := d.configurePreparedEnvironment(owner, session, environment, bound, &req) + if release != nil { + defer release() + } + if err != nil { + return ErrExecutionUnavailable + } + prepared, err := newPreparedStart(peer) + if err != nil { + return ErrExecutionUnavailable + } + releaseAttempted := false + defer func() { + if !releaseAttempted { + prepared.close() + } else { + peer.UnsubscribePreparation(prepared.requestID) + } + }() + prepare, stop := context.WithTimeout(owner, 10*time.Second) + err = send(prepare, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{Configuration: req}) + if err == nil { + err = prepared.awaitDirectoryReady(prepare) + } + stop() + if err != nil { + return ErrExecutionUnavailable + } + err = consume(owner, prepared.handle) + releaseAttempted = true + if prepared.releaseDirectory() != nil { + return ErrExecutionUnavailable + } + return err +} + +func (p *preparedStart) awaitDirectoryReady(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ErrExecutionUnavailable + case env, ok := <-p.sub.Events: + if !ok { + return ErrExecutionUnavailable + } + status, err := p.observation(env) + if err != nil || status.RunID != "" { + return ErrExecutionUnavailable + } + if status.State == "ready" { + return nil + } + if status.State != "preparing" { + return ErrExecutionUnavailable + } + } + } +} + +func (p *preparedStart) releaseDirectory() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if send(ctx, p.peer, proto.TypeExecutionRelease, p.requestID, proto.ExecutionReleasePayload{Handle: p.handle}) != nil { + return ErrExecutionUnavailable + } + for { + select { + case <-ctx.Done(): + return ErrExecutionUnavailable + case env, ok := <-p.sub.Events: + if !ok { + return ErrExecutionUnavailable + } + status, err := p.controlStatus(env) + if err != nil || status.RunID != "" { + return ErrExecutionUnavailable + } + if status.State == "released" && status.ErrorCode == "" { + return nil + } + if status.State != "preparing" && status.State != "ready" { + return ErrExecutionUnavailable + } + } + } +} diff --git a/services/agents-api/internal/execution/directory_preparation_test.go b/services/agents-api/internal/execution/directory_preparation_test.go new file mode 100644 index 000000000..5405d89a2 --- /dev/null +++ b/services/agents-api/internal/execution/directory_preparation_test.go @@ -0,0 +1,26 @@ +package execution + +import ( + "context" + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestDirectoryPreparationBoundsConnectionResolution(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + d := &Dispatcher{EnvironmentConnection: func(ctx context.Context, _ store.Session, _ store.Environment) (EnvironmentConnection, error) { + <-ctx.Done() + return EnvironmentConnection{}, ctx.Err() + }} + started := time.Now() + result := d.readPreparedDirectory(context.Background(), nil, store.Session{}, store.Environment{Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, store.ExecutionDevice{}, proto.WorkspaceReadPayload{}) + if !errors.Is(result.err, ErrExecutionUnavailable) || time.Since(started) <= 0 || time.Since(started) > 45*time.Second { + t.Fatal("connection resolution did not have a bounded owner lifetime") + } + }) +} diff --git a/services/agents-api/internal/execution/dispatcher.go b/services/agents-api/internal/execution/dispatcher.go new file mode 100644 index 000000000..be445865d --- /dev/null +++ b/services/agents-api/internal/execution/dispatcher.go @@ -0,0 +1,114 @@ +// Package execution delivers durable Turns through the existing daemon protocol. +package execution + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Snapshot is resolved internally; Daemon is not a public environment wire type. +type Snapshot struct { + ModelProviderConfigured bool `json:"model_provider_configured,omitempty"` + Agent v1.Agent `json:"agent"` + Daemon *DaemonConfig `json:"daemon"` + Environment *v1.Environment `json:"environment"` + VaultIDs []string `json:"vault_ids,omitempty"` + MCPCredentials []store.MCPCredentialBinding `json:"mcp_credentials,omitempty"` +} + +type DaemonConfig struct { + WorkDir string `json:"work_dir"` +} + +type Dispatcher struct { + Policy + Store *store.Store + Registry *gateway.Registry + // Options resolves transient engine credentials; they are never stored here. + Options func(context.Context, store.Session) (map[string]any, error) + EnvironmentConnection func(context.Context, store.Session, store.Environment) (EnvironmentConnection, error) + // CloseEnvironmentConnections drains transport observations before releasing execution ownership. + CloseEnvironmentConnections func() + // ManagedRuntimes is optional internal provisioning; it does not admit hosted API requests. + ManagedRuntimes *RuntimeProviders +} + +type Result struct { + Done proto.DonePayload `json:"done"` + ErrorCode string `json:"error_code,omitempty"` + Error string `json:"error,omitempty"` + AppliedThrough int64 `json:"applied_through"` +} + +// Run claims once before subscribing or sending. Uncertain deliveries are not replayed. +func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string) (store.Turn, error) { + session, err := d.Store.GetSession(ctx, tenantID, sessionID) + if err != nil { + return store.Turn{}, err + } + bound, err := d.Store.GetSessionExecutionBinding(ctx, tenantID, sessionID) + if err != nil { + return store.Turn{}, err + } + peer, err := d.Registry.LookupDevice(bound.Device.ID) + if err != nil { + return store.Turn{}, err + } + var snapshot Snapshot + if json.Unmarshal(session.Configuration, &snapshot) != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { + return store.Turn{}, store.ErrInvalidInput + } + caps, err := d.engineCapabilities(peer, session.Engine, snapshot) + if err != nil { + return store.Turn{}, err + } + workDir, noEnvironment, err := resolveExecutionEnvironment(snapshot) + if err != nil { + return store.Turn{}, err + } + text, through, err := d.initialInput(ctx, tenantID, sessionID, turnID) + if err != nil { + return store.Turn{}, err + } + req, err := d.executionRequest(ctx, session, snapshot, caps, bound) + if err != nil { + return store.Turn{}, err + } + if _, err := d.Store.TransitionTurn(ctx, tenantID, sessionID, turnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + return store.Turn{}, err + } + req.ConversationID, req.RunID, req.Prompt = sessionID, turnID, text + req.WorkDir, req.DisableExecutionEnvironment = workDir, noEnvironment + result, status := d.deliver(ctx, tenantID, sessionID, peer, req, through, nil) + return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, result, status) +} + +func (d *Dispatcher) finishRun(tenantID, sessionID, turnID, model string, result Result, status string) (store.Turn, error) { + if result.Done.Usage.Model == "" { + result.Done.Usage.Model = model + } + nativeID, _ := result.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + encoded, err := json.Marshal(result) + if err != nil || len(encoded) > 512*1024 || len(nativeID) > 512 { + result = Result{ErrorCode: "invalid_executor_result", AppliedThrough: result.AppliedThrough} + encoded, _ = json.Marshal(result) + status, nativeID = store.TurnFailed, "" + } + turn, err := d.Store.CompleteExecution(finishCtx, tenantID, sessionID, turnID, status, encoded, nativeID, result.AppliedThrough) + if errors.Is(err, store.ErrUnappliedInputs) { + result.ErrorCode = "input_not_applied" + encoded, _ = json.Marshal(result) + return d.Store.CompleteExecution(finishCtx, tenantID, sessionID, turnID, store.TurnFailed, encoded, nativeID, result.AppliedThrough) + } + return turn, err +} diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/agents-api/internal/execution/engine_profile.go new file mode 100644 index 000000000..b48873889 --- /dev/null +++ b/services/agents-api/internal/execution/engine_profile.go @@ -0,0 +1,58 @@ +package execution + +import ( + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func profileError(err error) error { + if errors.Is(err, engine.ErrInvalidInput) { + return store.ErrInvalidInput + } + return err +} + +func validateProfileConfiguration(profile engine.Profile, snapshot Snapshot) error { + if profile.ValidateConfiguration != nil { + if err := profile.ValidateConfiguration(snapshot.Agent, snapshot.Environment, snapshot.Daemon != nil); err != nil { + return profileError(err) + } + } + functions, mcp, err := executionTools(snapshot.Agent.Tools) + // Preserve each profile's admission error precedence when tool decoding fails. + if profile.ValidateConfiguration != nil && err != nil { + return err + } + if profile.ValidateTools != nil { + if validationErr := profile.ValidateTools(snapshot.Environment, snapshot.Daemon != nil, functions, mcp); validationErr != nil { + return profileError(validationErr) + } + } + return err +} + +func validateProfileInputs(profile engine.Profile, inputs []store.Input) error { + if profile.ValidateFunctionResult == nil { + return nil + } + for _, input := range inputs { + if input.Kind != "tool_result" { + continue + } + var value store.FunctionResultInput + if json.Unmarshal(input.Payload, &value) != nil { + return store.ErrInvalidInput + } + result, err := functionResult(store.FunctionCall{CallID: value.CallID, Result: value.Result}) + if err != nil { + return store.ErrInvalidInput + } + if err := profile.ValidateFunctionResult(result.Content); err != nil { + return profileError(err) + } + } + return nil +} diff --git a/services/agents-api/internal/execution/engine_profile_test.go b/services/agents-api/internal/execution/engine_profile_test.go new file mode 100644 index 000000000..a8900ae6c --- /dev/null +++ b/services/agents-api/internal/execution/engine_profile_test.go @@ -0,0 +1,83 @@ +package execution + +import ( + "encoding/json" + "errors" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestAcceptedEnginePlacements(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk", "unregistered"} { + for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { + raw := json.RawMessage(`{"agent":{"model":"fixture"},"environment":{"type":"` + placement + `"`) + if placement == "self_hosted" { + raw = append(raw, []byte(`,"workspace_directory":"/work"`)...) + } + raw = append(raw, []byte(`}}`)...) + want := engine != "unregistered" && !(engine == "claude_sdk" && placement == "self_hosted") + if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != want { + t.Fatalf("%s/%s: %v", engine, placement, err) + } + } + } +} + +func TestAdditionalProfileUsesCommonAdmission(t *testing.T) { + configurationChecked, toolsChecked, resultChecked := false, false, false + catalog := engine.NewCatalog(map[string]engine.Profile{"fixture": { + Placements: []string{"none"}, + ValidateConfiguration: func(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error { + configurationChecked = true + if agent.Model != "fixture" || environment == nil || hasDaemon { + return engine.ErrInvalidInput + } + return nil + }, + ValidateTools: func(_ *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + toolsChecked = true + if len(tools) != 1 || tools[0].Name != "echo" || len(mcp) != 0 { + t.Fatal("common tool decoding did not reach profile") + } + return nil + }, + ValidateFunctionResult: func(content []proto.FunctionResultContent) error { + resultChecked = true + if len(content) != 1 || content[0].Text == nil || *content[0].Text != "response" { + t.Fatal("common result decoding did not reach profile") + } + return engine.ErrInvalidInput + }, + }}) + profile, ok := catalog.Lookup("fixture") + if !ok || !profile.Accepts("none") || profile.Accepts("openai_hosted") { + t.Fatal("fixture qualification was not selected") + } + snapshot := Snapshot{Agent: v1.Agent{Model: "fixture", Tools: []json.RawMessage{json.RawMessage(`{"type":"function","name":"echo","parameters":{"type":"object"}}`)}}, Environment: &v1.Environment{Type: "none"}} + if err := validateProfileConfiguration(profile, snapshot); err != nil || !configurationChecked || !toolsChecked { + t.Fatal("additional engine admission failed", err) + } + snapshot.Agent.Model = "invalid" + if err := validateProfileConfiguration(profile, snapshot); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal("profile configuration lost public error mapping", err) + } + inputs := []store.Input{{Kind: "tool_result", Payload: json.RawMessage(`{"call_id":"call","result":{"success":true,"output":"response"}}`)}} + if err := validateProfileInputs(profile, inputs); !errors.Is(err, store.ErrInvalidInput) || !resultChecked { + t.Fatal("profile result lost public error mapping", err) + } +} + +func TestClaudeHostedToolsRequireSeparateQualification(t *testing.T) { + for index, tools := range []string{`[{"type":"function","name":"f","parameters":{"type":"object"},"defer_loading":false}]`, `[{"type":"mcp","server_label":"s","transport":{"type":"http","server_url":"https://example.test/mcp"},"connection_origin":"service"}]`} { + for _, placement := range []string{"none", "openai_hosted"} { + raw := json.RawMessage(`{"agent":{"model":"fixture","tools":` + tools + `},"environment":{"type":"` + placement + `"}}`) + if err := (Policy{}).ValidateSessionConfiguration("claude_sdk", raw); (err == nil) != (placement == "none" || index == 0) { + t.Fatalf("%s: %v", placement, err) + } + } + } +} diff --git a/services/agents-api/internal/execution/environment.go b/services/agents-api/internal/execution/environment.go new file mode 100644 index 000000000..d150fc2d3 --- /dev/null +++ b/services/agents-api/internal/execution/environment.go @@ -0,0 +1,25 @@ +package execution + +import ( + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func resolveExecutionEnvironment(snapshot Snapshot) (string, bool, error) { + if snapshot.Environment != nil { + if snapshot.Environment.Type != "none" || snapshot.Daemon != nil { + return "", false, store.ErrInvalidInput + } + return "", true, nil + } + if snapshot.Daemon == nil { + return "", false, store.ErrInvalidInput + } + workDir := snapshot.Daemon.WorkDir + if workDir != "" && !filepath.IsAbs(workDir) && !strings.HasPrefix(workDir, "~/") { + return "", false, store.ErrInvalidInput + } + return workDir, false, nil +} diff --git a/services/agents-api/internal/execution/environment_admission.go b/services/agents-api/internal/execution/environment_admission.go new file mode 100644 index 000000000..f2c564e4a --- /dev/null +++ b/services/agents-api/internal/execution/environment_admission.go @@ -0,0 +1,137 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + "slices" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +var ( + ErrEnvironmentInputExpired = errors.New("environment input expired before admission") + ErrEnvironmentInputCancelled = errors.New("environment input cancelled before admission") + ErrExecutionUnavailable = errors.New("execution ownership is unavailable") +) + +func preparedEnvironmentConfiguration(configuration json.RawMessage) bool { + var snapshot Snapshot + return json.Unmarshal(configuration, &snapshot) == nil && snapshot.Environment != nil && + (snapshot.Environment.Type == "self_hosted" || snapshot.Environment.Type == "openai_hosted") +} + +func (w *Worker) validateEnvironmentAdmission(engine string, configuration json.RawMessage) error { + var snapshot Snapshot + if json.Unmarshal(configuration, &snapshot) != nil || snapshot.Environment == nil { + return store.ErrInvalidInput + } + switch snapshot.Environment.Type { + case "self_hosted": + if w.dispatcher.EnvironmentConnection == nil { + return store.ErrInvalidInput + } + case "openai_hosted": + if w.runtimes == nil { + return ErrExecutionUnavailable + } + default: + return store.ErrInvalidInput + } + return w.dispatcher.ValidateSessionConfiguration(engine, configuration) +} + +func (w *Worker) validateCreation(ctx context.Context, input store.CreateSessionInput) error { + if preparedEnvironmentConfiguration(input.Configuration) { + if err := w.validateEnvironmentAdmission(input.Engine, input.Configuration); err != nil { + return err + } + var snapshot Snapshot + if err := json.Unmarshal(input.Configuration, &snapshot); err != nil { + return store.ErrInvalidInput + } + if snapshot.Environment.Type == "openai_hosted" && w.runtimes.config.ProviderForEngine(input.Engine) == "" { + return ErrExecutionUnavailable + } + if len(input.InitialInputs) == 0 && snapshot.Environment.Type == "self_hosted" { + return nil + } + return w.checkAdmissionOwnership(ctx) + } + if !w.dispatcher.canAdmitInputs(input.Engine, input.Configuration) { + return store.ErrInvalidInput + } + return nil +} + +func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Session, key string, inputs []store.Input) ([]store.InputReceipt, error) { + if err := w.validateEnvironmentAdmission(session.Engine, session.Configuration); err != nil { + return nil, err + } + if err := w.checkAdmissionOwnership(ctx); err != nil { + return nil, err + } + kind := "" + if len(inputs) > 0 { + kind = inputs[0].Kind + } + if (kind == "cancel" || kind == "tool_result") && !slices.ContainsFunc(inputs, func(input store.Input) bool { return input.Kind != kind }) { + // Neither kind creates a Turn. The Session lock preserves target and retry identity. + return w.admission.SubmitInputs(ctx, session.TenantID, session.ID, key, inputs) + } + reserve, cancel := context.WithTimeout(ctx, 5*time.Second) + reservation, err := w.admission.ReserveEnvironmentInput(reserve, session.TenantID, session.ID, key, inputs) + cancel() + if err != nil { + return nil, err + } + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + switch reservation.State { + case store.EnvironmentInputAdmitted: + return reservation.Receipts, nil + case store.EnvironmentInputFailed: + return nil, store.ErrEnvironmentUnavailable + case store.EnvironmentInputExpired: + return nil, ErrEnvironmentInputExpired + case store.EnvironmentInputCancelled: + return nil, ErrEnvironmentInputCancelled + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + if err := w.checkAdmissionOwnership(ctx); err != nil { + return nil, err + } + reservation, err = w.environmentInputOutcome(ctx, session, reservation) + if err != nil { + return nil, err + } + } +} + +func (w *Worker) environmentInputOutcome(ctx context.Context, session store.Session, reservation store.EnvironmentInputReservation) (store.EnvironmentInputReservation, error) { + read, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + // The database rechecks its clock under the Session lock before settlement. + if !time.Now().Before(reservation.Deadline) { + return w.admission.ExpireEnvironmentInput(read, session.TenantID, session.ID, reservation.ID) + } + return w.admission.GetEnvironmentInputReservation(read, session.TenantID, session.ID, reservation.ID) +} + +func (w *Worker) checkAdmissionOwnership(ctx context.Context) error { + check, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := w.CheckOwnership(check); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return ErrExecutionUnavailable + } + return nil +} diff --git a/services/agents-api/internal/execution/environment_connections.go b/services/agents-api/internal/execution/environment_connections.go new file mode 100644 index 000000000..ff3be4b66 --- /dev/null +++ b/services/agents-api/internal/execution/environment_connections.go @@ -0,0 +1,13 @@ +package execution + +import "context" + +// ReplaceEnvironmentConnection begins a serialized transport generation on the owned writer. +func (w *Worker) ReplaceEnvironmentConnection(ctx context.Context, tenant, environment, generation string) error { + return w.dispatcher.Store.ReplaceEnvironmentConnection(ctx, tenant, environment, generation) +} + +// ObserveEnvironmentConnection retains a current transport observation and its event together. +func (w *Worker) ObserveEnvironmentConnection(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + return w.dispatcher.Store.ObserveEnvironmentConnection(ctx, tenant, environment, generation, revision, connected) +} diff --git a/services/agents-api/internal/execution/environment_directory.go b/services/agents-api/internal/execution/environment_directory.go new file mode 100644 index 000000000..31719ddf3 --- /dev/null +++ b/services/agents-api/internal/execution/environment_directory.go @@ -0,0 +1,138 @@ +package execution + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type directoryReadResult struct { + directory proto.WorkspaceDirectoryResult + err error +} + +type directoryReadRequest struct { + ctx context.Context + environment store.Environment + path string + result chan directoryReadResult +} + +func (r directoryReadRequest) reply(result directoryReadResult) { r.result <- result } + +// ReadEnvironmentDirectory observes a Worker-owned read without admitting model input. +func (w *Worker) ReadEnvironmentDirectory(ctx context.Context, environment store.Environment, path string) (proto.WorkspaceDirectoryResult, error) { + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + current, err := w.admission.GetEnvironment(ctx, environment.TenantID, environment.ID) + if err != nil { + return proto.WorkspaceDirectoryResult{}, err + } + if current.SessionID != environment.SessionID { + return proto.WorkspaceDirectoryResult{}, store.ErrNotFound + } + request := directoryReadRequest{ctx: ctx, environment: current, path: path, result: make(chan directoryReadResult, 1)} + select { + case w.directoryReads <- request: + case <-ctx.Done(): + return proto.WorkspaceDirectoryResult{}, ErrExecutionUnavailable + case <-w.stopped: + return proto.WorkspaceDirectoryResult{}, ErrExecutionUnavailable + } + select { + case result := <-request.result: + return result.directory, result.err + case <-ctx.Done(): + return proto.WorkspaceDirectoryResult{}, ErrExecutionUnavailable + case <-w.stopped: + return proto.WorkspaceDirectoryResult{}, ErrExecutionUnavailable + } +} + +func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRequest, reserved bool) (result directoryReadResult) { + result.err = ErrExecutionUnavailable + check, cancel := context.WithTimeout(owner, 5*time.Second) + defer cancel() + if w.CheckOwnership(check) != nil { + return + } + environment, err := w.dispatcher.Store.GetEnvironment(check, request.environment.TenantID, request.environment.ID) + if err != nil { + result.err = err + return + } + if environment.SessionID != request.environment.SessionID { + result.err = store.ErrNotFound + return + } + placement, err := parseEnvironmentPlacement(environment.Configuration) + if err != nil { + return + } + session, err := w.dispatcher.Store.GetSession(check, environment.TenantID, environment.SessionID) + if err != nil { + result.err = err + return + } + run := "" + if session.LastTurn != nil && (session.LastTurn.Status == store.TurnInProgress || session.LastTurn.Status == store.TurnWaiting) { + run = session.LastTurn.ID + } + if (run == "") != reserved { + return + } + if reserved { + ready, err := w.bindSessionDevice(check, session, func(id string) bool { return w.directoryDeviceReady(id, session.Engine, placement, true) }) + if err != nil || !ready { + return + } + } + // Capture retains the public Turn after its native Run has been released. + prepare := reserved || session.LastTurn != nil && session.LastTurn.ArtifactCaptureStarted + bound, err := w.dispatcher.Store.GetSessionDevice(check, session.TenantID, session.ID) + if err != nil || !environmentDeviceMatches(session, environment, bound, placement) || !w.directoryDeviceReady(bound.ID, session.Engine, placement, prepare) { + return + } + peer, err := w.dispatcher.Registry.LookupDevice(bound.ID) + if err != nil { + return + } + read := proto.WorkspaceReadPayload{EnvironmentID: environment.ID, Path: request.path, MaxEntries: proto.WorkspaceDirectoryMaxEntries} + if !prepare { + read.RunID = run + result = readEnvironmentDirectory(owner, peer, read) + return + } + result = w.dispatcher.readPreparedDirectory(owner, peer, session, environment, bound, read) + return +} + +func (w *Worker) directoryDeviceReady(id, engine string, placement environmentPlacement, prepare bool) bool { + if w.dispatcher.Registry == nil { + return false + } + peer, err := w.dispatcher.Registry.LookupDevice(id) + if err != nil { + return false + } + info, found, known := peer.AgentKindStatus(engine) + placementReady := info.Capabilities.RemoteEnvironment + if placement.Type == "openai_hosted" { + placementReady = info.Capabilities.LocalEnvironment + } + return known && found && info.Available && placementReady && (!prepare || (info.Capabilities.Preparation && info.Capabilities.WorkspaceReadPreparation)) +} + +func readEnvironmentDirectory(ctx context.Context, peer *gateway.Session, request proto.WorkspaceReadPayload) directoryReadResult { + result, err := peer.ListWorkspaceDirectory(ctx, request) + if err == nil && result.Outcome == "completed" && result.Directory != nil && !result.Directory.Truncated && proto.ValidWorkspaceDirectory(result.Directory, request.MaxEntries) { + return directoryReadResult{directory: *result.Directory} + } + if err == nil && result.Outcome == "rejected" && result.ErrorCode == "not_found" { + return directoryReadResult{err: store.ErrNotFound} + } + return directoryReadResult{err: ErrExecutionUnavailable} +} diff --git a/services/agents-api/internal/execution/environment_file_write.go b/services/agents-api/internal/execution/environment_file_write.go new file mode 100644 index 000000000..806b04a50 --- /dev/null +++ b/services/agents-api/internal/execution/environment_file_write.go @@ -0,0 +1,123 @@ +package execution + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type fileWriteResult struct { + size int64 + err error +} +type fileWriteRequest struct { + ctx context.Context + environment store.Environment + path string + data []byte + result chan fileWriteResult +} + +// WriteEnvironmentFile observes a Worker-owned mutation. Caller detachment never +// clears the durable write intent or starts a replacement operation. +func (w *Worker) WriteEnvironmentFile(ctx context.Context, environment store.Environment, path string, data []byte) (int64, error) { + if len(data) > proto.WorkspaceWriteMaxBytes { + return 0, store.ErrInvalidInput + } + request := fileWriteRequest{ctx: ctx, environment: environment, path: path, data: data, result: make(chan fileWriteResult, 1)} + select { + case w.fileWrites <- request: + case <-ctx.Done(): + return 0, ErrExecutionUnavailable + case <-w.stopped: + return 0, ErrExecutionUnavailable + } + select { + case result := <-request.result: + return result.size, result.err + case <-ctx.Done(): + return 0, ErrExecutionUnavailable + case <-w.stopped: + return 0, ErrExecutionUnavailable + } +} + +func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) fileWriteResult { + unavailable := fileWriteResult{err: ErrExecutionUnavailable} + if owner.Err() != nil || request.ctx.Err() != nil { + return unavailable + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(owner), 205*time.Second) + defer cancel() + if w.CheckOwnership(ctx) != nil { + return unavailable + } + environment, err := w.dispatcher.Store.GetEnvironment(ctx, request.environment.TenantID, request.environment.ID) + if err != nil { + return fileWriteResult{err: err} + } + if environment.SessionID != request.environment.SessionID { + return fileWriteResult{err: store.ErrNotFound} + } + placement, err := parseEnvironmentPlacement(environment.Configuration) + if err != nil || placement.Type != "openai_hosted" { + return unavailable + } + session, err := w.dispatcher.Store.GetSession(ctx, environment.TenantID, environment.SessionID) + if err != nil { + return fileWriteResult{err: err} + } + bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) + if err != nil || !environmentDeviceMatches(session, environment, bound, placement) || w.dispatcher.Registry == nil { + return unavailable + } + peer, err := w.dispatcher.Registry.LookupDevice(bound.ID) + if err != nil { + return unavailable + } + info, found, known := peer.AgentKindStatus(session.Engine) + if !known || !found || !info.Available || !info.Capabilities.LocalEnvironment { + return unavailable + } + dataDigest := sha256.Sum256(request.data) + body, _ := json.Marshal([]any{request.path, len(request.data), hex.EncodeToString(dataDigest[:])}) + digest := sha256.Sum256(body) + wire := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment.ID, SessionID: session.ID, Path: request.path, SizeBytes: len(request.data), SHA256: hex.EncodeToString(dataDigest[:])} + if !proto.ValidWorkspaceWriteRequest(wire) { + return fileWriteResult{err: store.ErrInvalidInput} + } + key := store.FileWriteIdentity{ID: uuid.NewString(), DeviceID: bound.ID, RequestSHA256: hex.EncodeToString(digest[:])} + intent, err := w.dispatcher.Store.ReserveEnvironmentFileWrite(ctx, environment.TenantID, environment.ID, key) + if err != nil { + return fileWriteResult{err: err} + } + if intent.Replayed { + return unavailable + } + result, err := peer.WriteWorkspaceFile(ctx, key.ID, proto.WorkspaceWritePayload{EnvironmentID: environment.ID, SessionID: session.ID, Path: request.path}, request.data) + if err != nil || (result.Outcome != "completed" && result.Outcome != "rejected") { + return unavailable + } + state := "rejected" + if result.Outcome == "completed" { + state = "committed" + } + settle, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer stop() + if _, err := w.dispatcher.Store.SettleEnvironmentFileWrite(settle, environment.TenantID, environment.ID, key, state); err != nil { + return unavailable + } + if state == "rejected" { + if result.ErrorCode == "invalid_request" || result.ErrorCode == "write_rejected" { + return fileWriteResult{err: store.ErrInvalidInput} + } + return unavailable + } + return fileWriteResult{size: int64(result.SizeBytes)} +} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go new file mode 100644 index 000000000..85cdf4ccf --- /dev/null +++ b/services/agents-api/internal/execution/environment_placement.go @@ -0,0 +1,119 @@ +package execution + +import ( + "bytes" + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type environmentPlacement struct { + Skills []agentskill.Metadata `json:"skills,omitempty"` + Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` + SystemPackages bool `json:"-"` + NetworkAccess string `json:"-"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` +} + +// LocalWorkspaceConfiguration recognizes the qualified stored V1 profile. It +// does not provision a Runtime, validate live authority, or admit hosted creation. +func LocalWorkspaceConfiguration(configuration json.RawMessage) bool { + placement, err := parseEnvironmentPlacement(configuration) + return err == nil && placement.Type == "openai_hosted" +} + +func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacement, error) { + var placement environmentPlacement + if json.Unmarshal(configuration, &placement) != nil { + return placement, store.ErrInvalidInput + } + switch placement.Type { + case "self_hosted": + if placement.WorkspaceDirectory != "" && len(placement.CapabilityDirectories) == 0 { + return placement, nil + } + case "openai_hosted": + // Qualified local execution currently supports enabled/disabled network only. + var local struct { + Skills []agentskill.Metadata `json:"skills,omitempty"` + Files []store.InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` + Type string `json:"type"` + CapabilityDirectories []string `json:"capability_directories"` + Network *struct { + Access string `json:"access"` + AllowedDomains []string `json:"allowed_domains"` + } `json:"network"` + } + decoder := json.NewDecoder(bytes.NewReader(configuration)) + decoder.DisallowUnknownFields() + if decoder.Decode(&local) == nil && len(local.CapabilityDirectories) == 0 { + placement.SystemPackages = local.Packages != nil && len(local.Packages.System) > 0 + placement.NetworkAccess = "enabled" + if local.Network != nil { + if len(local.Network.AllowedDomains) != 0 || (local.Network.Access != "enabled" && local.Network.Access != "disabled") { + return placement, store.ErrInvalidInput + } + placement.NetworkAccess = local.Network.Access + } + return placement, nil + } + } + return placement, store.ErrInvalidInput +} + +func environmentDeviceMatches(session store.Session, environment store.Environment, bound store.ExecutionDevice, placement environmentPlacement) bool { + if environment.SessionID != session.ID || environment.TenantID != session.TenantID { + return false + } + if placement.Type == "openai_hosted" { + return bound.EnvironmentID == environment.ID + } + return bound.EnvironmentID == "" +} + +func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session store.Session, environment store.Environment, bound store.ExecutionDevice, req *proto.PromptRequestPayload) (func(), error) { + placement, err := parseEnvironmentPlacement(environment.Configuration) + if err != nil || !environmentDeviceMatches(session, environment, bound, placement) { + return nil, store.ErrInvalidInput + } + if placement.Type == "openai_hosted" { + if placement.SystemPackages && !placement.ToolEnvironment { + return nil, store.ErrInvalidInput + } + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, Skills: placement.Skills} + // Keep the previously qualified explicit-disabled internal peer path intact. + // New bound-policy peers validate the exact policy during preparation. + boundPolicy := placement.NetworkAccess != "disabled" + if d.Registry != nil { + if peer, e := d.Registry.LookupDevice(bound.ID); e == nil { + info, found, known := peer.AgentKindStatus(session.Engine) + boundPolicy = boundPolicy || (known && found && info.Capabilities.LocalEnvironmentNetworkPolicy) + } + } + if boundPolicy { + req.LocalEnvironment.NetworkAccess = placement.NetworkAccess + } + return nil, nil + } + if d.EnvironmentConnection == nil { + return nil, errors.New("environment connection resolver is not configured") + } + connection, err := d.EnvironmentConnection(ctx, session, environment) + if err != nil { + return connection.Release, err + } + if connection.URL == "" || connection.Token == "" || connection.Release == nil { + return connection.Release, errors.New("environment connection is incomplete") + } + req.RemoteEnvironment = &proto.RemoteEnvironment{ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, ConnectionURL: connection.URL, ConnectionToken: connection.Token} + return connection.Release, nil +} diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go new file mode 100644 index 000000000..73edf19e0 --- /dev/null +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -0,0 +1,74 @@ +package execution + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) { + for _, configuration := range []string{ + `{"type":"openai_hosted","network":{}}`, + `{"type":"openai_hosted","network":{"access":"restricted"}}`, + `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":["example.com"]}}`, + `{"type":"openai_hosted","network":{"access":"disabled","allow":["example.com"]}}`, + `{"type":"openai_hosted","network":{"access":"disabled"},"workspace_directory":"/override"}`, + } { + if _, err := parseEnvironmentPlacement([]byte(configuration)); err == nil { + t.Fatalf("unqualified private profile accepted: %s", configuration) + } + } + session := store.Session{ID: "session", TenantID: "tenant"} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","network":{"access":"disabled"}}`)} + d := &Dispatcher{EnvironmentConnection: func(context.Context, store.Session, store.Environment) (EnvironmentConnection, error) { + t.Fatal("local placement resolved a remote transport") + return EnvironmentConnection{}, nil + }} + for _, scope := range []string{"", "other", environment.ID} { + var req proto.PromptRequestPayload + release, err := d.configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: scope}, &req) + if scope == environment.ID { + if err != nil || release != nil || req.LocalEnvironment == nil || req.LocalEnvironment.ID != environment.ID || req.RemoteEnvironment != nil || req.WorkDir != "" { + t.Fatal("local identity was not preserved", err) + } + } else if err == nil || req.LocalEnvironment != nil { + t.Fatal("unscoped or foreign authority accepted") + } + } +} + +func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { + session := store.Session{ID: "session", TenantID: "tenant"} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"system":["jq"]}}`)} + var req proto.PromptRequestPayload + _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, + store.ExecutionDevice{EnvironmentID: environment.ID}, &req) + if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment || !req.LocalEnvironment.SystemPackages { + t.Fatal("system initialization requirement was lost", err) + } + environment.Configuration = []byte(`{"type":"openai_hosted","packages":{"system":["jq"]}}`) + if !LocalWorkspaceConfiguration(environment.Configuration) { + t.Fatal("public admission requires a private execution receipt") + } + req = proto.PromptRequestPayload{} + if _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, + store.ExecutionDevice{EnvironmentID: environment.ID}, &req); err == nil || req.LocalEnvironment != nil { + t.Fatal("execution without the required initialization was admitted") + } +} + +func TestLocalNetworkDefaultsAndSupportedPolicies(t *testing.T) { + for _, configuration := range []string{`{"type":"openai_hosted"}`, `{"type":"openai_hosted","network":null}`, `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":[]}}`} { + got, err := parseEnvironmentPlacement([]byte(configuration)) + if err != nil || got.NetworkAccess != "enabled" { + t.Fatal("enabled default lost", got, err) + } + } + got, err := parseEnvironmentPlacement([]byte(`{"type":"openai_hosted","network":{"access":"disabled","allowed_domains":null}}`)) + if err != nil || got.NetworkAccess != "disabled" { + t.Fatal("disabled policy lost", got, err) + } +} diff --git a/services/agents-api/internal/execution/environment_test.go b/services/agents-api/internal/execution/environment_test.go new file mode 100644 index 000000000..6a8ce5019 --- /dev/null +++ b/services/agents-api/internal/execution/environment_test.go @@ -0,0 +1,30 @@ +package execution + +import ( + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "testing" +) + +func TestExecutionEnvironmentDoesNotDefaultToLocal(t *testing.T) { + cases := []struct { + name string + snapshot Snapshot + dir string + none, invalid bool + }{ + {"public none", Snapshot{Environment: &v1.Environment{Type: "none"}}, "", true, false}, + {"legacy device", Snapshot{Daemon: &DaemonConfig{WorkDir: "/workspace"}}, "/workspace", false, false}, + {"missing", Snapshot{}, "", false, true}, + {"conflicting", Snapshot{Environment: &v1.Environment{Type: "none"}, Daemon: &DaemonConfig{}}, "", false, true}, + {"unsupported", Snapshot{Environment: &v1.Environment{Type: "self_hosted"}}, "", false, true}, + {"relative", Snapshot{Daemon: &DaemonConfig{WorkDir: "workspace"}}, "", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir, none, err := resolveExecutionEnvironment(c.snapshot) + if (err != nil) != c.invalid || dir != c.dir || none != c.none { + t.Fatal(dir, none, err) + } + }) + } +} diff --git a/services/agents-api/internal/execution/finish.go b/services/agents-api/internal/execution/finish.go new file mode 100644 index 000000000..4d4915a11 --- /dev/null +++ b/services/agents-api/internal/execution/finish.go @@ -0,0 +1,39 @@ +package execution + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func finishDelivery(ctx context.Context, journal *journal, result *Result, cancelReply <-chan cancellationResult, pendingInput bool, functions *functionExchange) string { + if cancelReply != nil { + select { + case reply := <-cancelReply: + if recordCancellation(ctx, journal, reply, result) != nil { + result.ErrorCode = "event_persistence_failed" + return store.TurnFailed + } + if reply.err == nil && reply.ack.Applied { + return store.TurnCancelled + } + if reply.err != nil || reply.ack.ErrorCode != "run_inactive" { + result.ErrorCode = "cancel_unconfirmed" + return store.TurnFailed + } + case <-ctx.Done(): + result.ErrorCode = "cancel_unconfirmed" + return store.TurnFailed + } + } + if result.ErrorCode == "" { + if pendingInput { + result.ErrorCode = "input_outcome_unknown" + } else if functions.complete(ctx) != nil { + result.ErrorCode = "function_result_unconfirmed" + } else { + return store.TurnCompleted + } + } + return store.TurnFailed +} diff --git a/services/agents-api/internal/execution/functions.go b/services/agents-api/internal/execution/functions.go new file mode 100644 index 000000000..ca0e0baa5 --- /dev/null +++ b/services/agents-api/internal/execution/functions.go @@ -0,0 +1,180 @@ +package execution + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { + tools := make([]proto.FunctionTool, 0, len(raw)) + names := map[string]bool{} + if len(raw) > 64 { + return nil, errors.New("at most 64 function tools are supported") + } + for _, value := range raw { + var tool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + DeferLoading bool `json:"defer_loading"` + } + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&tool); err != nil { + return nil, err + } + var schema map[string]json.RawMessage + if tool.Type != "function" || tool.DeferLoading || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, errors.New("execution requires unique non-deferred functions with object schemas") + } + names[tool.Name] = true + tools = append(tools, proto.FunctionTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) + } + return tools, nil +} + +type functionReply struct { + ack proto.InteractionDecisionAckPayload + err error +} + +type functionExchange struct { + store *store.Store + tenant, session, turn string + tools []proto.FunctionTool + callID string + reply <-chan functionReply +} + +func (f *functionExchange) record(ctx context.Context, env proto.Envelope) error { + var call proto.FunctionCallPayload + if env.ID != f.turn || env.DecodePayload(&call) != nil { + return errors.New("invalid function callback") + } + declared := false + for _, tool := range f.tools { + declared = declared || tool.Name == call.Name + } + if !declared { + return errors.New("undeclared function callback") + } + err := f.store.RecordFunctionCall(ctx, f.tenant, f.session, f.turn, store.FunctionCall{ + CallID: items.Identity(f.turn, "tool:"+call.CallID), ExecutorCallID: call.CallID, Name: call.Name, Arguments: call.Arguments, + }) + return f.unlessCancelling(ctx, err) +} + +func (f *functionExchange) start(ctx context.Context, peer *gateway.Session) error { + if f.reply != nil || len(f.tools) == 0 { + return nil + } + calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + for _, call := range calls { + if len(call.Result) == 0 { + continue + } + result, err := functionResult(call) + if err != nil { + return err + } + env, err := proto.NewEnvelope(proto.TypeFunctionResult, f.turn, result) + if err != nil { + return err + } + replies := make(chan functionReply, 1) + f.callID, f.reply = call.CallID, replies + go func() { + ack, err := peer.SendAndWaitInteractionAck(ctx, env, result.DeliveryID) + replies <- functionReply{ack: ack, err: err} + }() + return nil + } + return nil +} + +func (f *functionExchange) confirm(ctx context.Context, reply functionReply) error { + id := f.callID + f.callID, f.reply = "", nil + if reply.err != nil { + return reply.err + } + if !reply.ack.Applied { + return fmt.Errorf("function result not applied: %s", reply.ack.ErrorCode) + } + return f.unlessCancelling(ctx, f.store.ConfirmFunctionResult(ctx, f.tenant, f.session, f.turn, id)) +} + +func (f *functionExchange) unlessCancelling(ctx context.Context, err error) error { + if errors.Is(err, store.ErrTurnConflict) { + turn, lookupErr := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) + if lookupErr == nil && !turn.CancelRequestedAt.IsZero() { + return nil + } + } + return err +} + +func (f *functionExchange) complete(ctx context.Context) error { + if len(f.tools) == 0 { + return nil + } + calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + if len(calls) != 0 { + return errors.New("function calls remain unapplied") + } + turn, err := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + if turn.Status == store.TurnWaiting { + return errors.New("function turn has not resumed") + } + return nil +} + +func functionResult(call store.FunctionCall) (proto.FunctionResultPayload, error) { + var value struct { + Success *bool `json:"success"` + Output json.RawMessage `json:"output"` + Error *string `json:"error"` + } + if err := json.Unmarshal(call.Result, &value); err != nil { + return proto.FunctionResultPayload{}, err + } + if value.Success == nil { + return proto.FunctionResultPayload{}, errors.New("function result requires success") + } + result := proto.FunctionResultPayload{DeliveryID: "function:" + call.CallID, CallID: call.ExecutorCallID, Success: *value.Success, Content: []proto.FunctionResultContent{}} + output := bytes.TrimSpace(value.Output) + if len(output) > 0 && !bytes.Equal(output, []byte("null")) { + if output[0] == '"' { + var text string + if err := json.Unmarshal(output, &text); err != nil { + return result, err + } + result.Content = append(result.Content, proto.FunctionResultContent{Type: "input_text", Text: &text}) + } else if err := json.Unmarshal(output, &result.Content); err != nil { + return result, err + } + } + if value.Error != nil { + result.Content = append(result.Content, proto.FunctionResultContent{Type: "input_text", Text: value.Error}) + } + return result, result.ValidateContent() +} diff --git a/services/agents-api/internal/execution/functions_test.go b/services/agents-api/internal/execution/functions_test.go new file mode 100644 index 000000000..fa15b17a1 --- /dev/null +++ b/services/agents-api/internal/execution/functions_test.go @@ -0,0 +1,53 @@ +package execution + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestFunctionDefinitionsRejectUnsupportedConfiguration(t *testing.T) { + valid := json.RawMessage(`{"type":"function","name":"lookup","description":"Find it","parameters":{"type":"object","properties":{}},"defer_loading":false}`) + tools, err := functionTools([]json.RawMessage{valid}) + if err != nil || len(tools) != 1 || tools[0].Name != "lookup" || tools[0].Description != "Find it" { + t.Fatal(tools, err) + } + for _, raw := range []string{`{"type":"mcp"}`, `{"type":"function","name":"lookup","parameters":null}`, `{"type":"function","name":"lookup","parameters":{},"defer_loading":true}`, `{"type":"function","name":"lookup","parameters":{},"unknown":true}`} { + if _, err := functionTools([]json.RawMessage{json.RawMessage(raw)}); err == nil { + t.Fatal("unsupported configuration admitted", raw) + } + } + if _, err := functionTools([]json.RawMessage{valid, valid}); err == nil { + t.Fatal("duplicate function names") + } +} + +func TestFunctionResultPreservesCompleteContent(t *testing.T) { + text := func(value string) proto.FunctionResultContent { + return proto.FunctionResultContent{Type: "input_text", Text: &value} + } + imageURL := "data:image/png;base64,test" + for _, test := range []struct { + raw string + success bool + content []proto.FunctionResultContent + }{ + {`{"success":true,"output":""}`, true, []proto.FunctionResultContent{text("")}}, + {`{"success":true,"output":null,"error":null}`, true, []proto.FunctionResultContent{}}, + {`{"success":false,"error":"failed"}`, false, []proto.FunctionResultContent{text("failed")}}, + {`{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,test"},{"type":"input_text","text":""}],"error":"failed"}`, false, []proto.FunctionResultContent{text("before"), {Type: "input_image", ImageURL: &imageURL}, text(""), text("failed")}}, + } { + result, err := functionResult(store.FunctionCall{CallID: "public", ExecutorCallID: "native", Result: json.RawMessage(test.raw)}) + if err != nil || result.CallID != "native" || result.DeliveryID != "function:public" || result.Success != test.success || !reflect.DeepEqual(result.Content, test.content) { + t.Fatal(result, err) + } + } + for _, raw := range []string{`{}`, `{"success":null}`, `{"success":true,"output":{}}`, `{"success":true,"output":[{"type":"input_text"}]}`, `{"success":true,"output":[{"type":"input_audio","audio_url":"a"}]}`} { + if _, err := functionResult(store.FunctionCall{Result: json.RawMessage(raw)}); err == nil { + t.Fatal("invalid stored result converted", raw) + } + } +} diff --git a/services/agents-api/internal/execution/input_text.go b/services/agents-api/internal/execution/input_text.go new file mode 100644 index 000000000..51b58e5a3 --- /dev/null +++ b/services/agents-api/internal/execution/input_text.go @@ -0,0 +1,73 @@ +package execution + +import ( + "context" + "encoding/json" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func messageText(raw json.RawMessage) (string, error) { + var input struct { + Text string `json:"text"` + Input []v1.InputMessage `json:"input"` + } + if json.Unmarshal(raw, &input) != nil { + return "", store.ErrInvalidInput + } + if len(input.Input) == 0 { + if strings.TrimSpace(input.Text) == "" { + return "", store.ErrInvalidInput + } + return input.Text, nil + } + messages := make([]string, 0, len(input.Input)) + for _, message := range input.Input { + if message.Role != "user" { + return "", store.ErrInvalidInput + } + var text strings.Builder + for _, content := range message.Content { + if content.Type != "input_text" { + return "", store.ErrInvalidInput + } + text.WriteString(content.Text) + } + if strings.TrimSpace(text.String()) == "" { + return "", store.ErrInvalidInput + } + messages = append(messages, text.String()) + } + return strings.Join(messages, "\n\n"), nil +} + +func (d *Dispatcher) initialInput(ctx context.Context, tenant, session, turn string) (string, int64, error) { + inputs, err := d.Store.ListTurnInputs(ctx, tenant, session, turn, 0, 100) + if err != nil { + return "", 0, err + } + var texts []string + var through int64 + size := 0 + for _, input := range inputs { + if input.Kind != "message" { + return "", 0, store.ErrInvalidInput + } + text, err := messageText(input.Payload) + if err != nil { + return "", 0, err + } + if size+len(text) > 512*1024 && len(texts) > 0 { + break + } + texts = append(texts, text) + size += len(text) + through = input.Sequence + } + if len(texts) == 0 { + return "", 0, store.ErrInvalidInput + } + return strings.Join(texts, "\n\n"), through, nil +} diff --git a/services/agents-api/internal/execution/journal.go b/services/agents-api/internal/execution/journal.go new file mode 100644 index 000000000..c68de7703 --- /dev/null +++ b/services/agents-api/internal/execution/journal.go @@ -0,0 +1,128 @@ +package execution + +import ( + "context" + "encoding/json" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type journal struct { + store eventWriter + tenant, session, turn string + next int32 + batch []store.ExecutionEvent + bytes int + pendingCount int + observeSubagents bool +} + +type eventWriter interface { + AppendTurnEvents(context.Context, string, string, string, int32, []store.ExecutionEvent) error +} + +func recordCancellation(ctx context.Context, journal *journal, reply cancellationResult, result *Result) error { + if reply.err != nil { + return nil + } + env, err := proto.NewEnvelope("cancel_receipt", journal.turn, reply.ack) + if err != nil { + return err + } + if reply.ack.Applied && reply.ack.Outcome != nil { + raw, err := json.Marshal(reply.ack.Outcome) + if err != nil { + return err + } + if err := result.mergeDone(raw); err != nil { + return err + } + } + return journal.observe(ctx, env) +} + +func (j *journal) observe(ctx context.Context, env proto.Envelope) error { + if err := j.enqueue(env); err != nil { + return err + } + if j.bytes > 768*1024 || len(j.batch) >= 64 { + return j.flush(ctx) + } + return nil +} + +func (j *journal) enqueue(env proto.Envelope) error { + if env.Type == proto.TypeSubagentIdentity && !j.observeSubagents { + return store.ErrInvalidInput + } + switch env.Type { + case proto.TypeDelta, proto.TypeOutputMessage, proto.TypeThinking, proto.TypeToolCall, proto.TypeCommandOutput, proto.TypeUsage, + proto.TypeError, proto.TypeDone, proto.TypePromptSteerAck, proto.TypeSubagentIdentity, "cancel_receipt": + default: + return nil + } + if len(env.Payload) > 512*1024 { + return store.ErrEventLimit + } + j.batch = append(j.batch, store.ExecutionEvent{Kind: env.Type, Payload: env.Payload}) + j.bytes += len(env.Payload) + return nil +} + +func (j *journal) flush(ctx context.Context) error { + if len(j.batch) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + for len(j.batch) > 0 { + count, size := 0, 0 + limit := 64 + if j.pendingCount > 0 { + limit = j.pendingCount + } + for count < len(j.batch) && count < limit { + length := len(j.batch[count].Payload) + if count > 0 && size+length > 768*1024 { + break + } + size += length + count++ + } + // An uncertain commit must retry the same batch even after more frames arrive. + j.pendingCount = count + if err := j.store.AppendTurnEvents(ctx, j.tenant, j.session, j.turn, j.next, j.batch[:count]); err != nil { + return err + } + j.pendingCount = 0 + j.next += int32(count) + j.bytes -= size + j.batch = j.batch[count:] + } + j.batch = nil + return nil +} + +// Cancellation receipts use a separate waiter; preceding frames can still be queued. +func (j *journal) drain(upstream <-chan proto.Envelope, result *Result) error { + var observedErr error + for range 256 { + select { + case env, ok := <-upstream: + if !ok { + return observedErr + } + if err := j.enqueue(env); err != nil { + observedErr = err + } + if err := result.mergeObservation(env); err != nil { + observedErr = err + } + default: + return observedErr + } + } + return observedErr +} diff --git a/services/agents-api/internal/execution/journal_test.go b/services/agents-api/internal/execution/journal_test.go new file mode 100644 index 000000000..78935a8bc --- /dev/null +++ b/services/agents-api/internal/execution/journal_test.go @@ -0,0 +1,161 @@ +package execution + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type recoveringWriter struct { + fail bool + events []store.ExecutionEvent +} + +type ambiguousWriter struct { + firstCount int + written int +} + +func (w *ambiguousWriter) AppendTurnEvents(_ context.Context, _, _, _ string, first int32, events []store.ExecutionEvent) error { + if w.firstCount == 0 { + w.firstCount, w.written = len(events), len(events) + return context.DeadlineExceeded + } + if first == 1 { + if len(events) != w.firstCount { + return store.ErrIdempotencyConflict + } + return nil + } + if int(first) != w.written+1 { + return errors.New("wrong next batch ordinal") + } + w.written += len(events) + return nil +} + +func TestJournalKeepsBatchIdentityAfterAnUncertainCommit(t *testing.T) { + writer := &ambiguousWriter{} + j := journal{store: writer, next: 1} + for i := range 3 { + env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "text", Sequence: uint64(i + 1)}) + if err := j.observe(context.Background(), env); err != nil { + t.Fatal(err) + } + } + if err := j.flush(context.Background()); err == nil { + t.Fatal("expected uncertain commit") + } + env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "later", Sequence: 4}) + if err := j.observe(context.Background(), env); err != nil { + t.Fatal(err) + } + if err := j.flush(context.Background()); err != nil { + t.Fatal(err) + } + if writer.written != 4 || j.next != 5 { + t.Fatalf("lost or duplicated events: written=%d next=%d", writer.written, j.next) + } +} + +func (w *recoveringWriter) AppendTurnEvents(_ context.Context, _, _, _ string, first int32, events []store.ExecutionEvent) error { + if w.fail { + w.fail = false + return context.DeadlineExceeded + } + if int(first) != len(w.events)+1 || len(events) > 64 { + return errors.New("unexpected batch boundary") + } + w.events = append(w.events, events...) + return nil +} + +func TestJournalRetainsTriggeringFrameAcrossFlushFailure(t *testing.T) { + for _, size := range []int{10, 300 * 1024} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + writer := &recoveringWriter{fail: true} + j := journal{store: writer, next: 1} + var expected []string + for i := range 65 { + env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: strings.Repeat("x", size), Sequence: uint64(i + 1)}) + expected = append(expected, string(env.Payload)) + if err := j.observe(context.Background(), env); err != nil { + break + } + } + if writer.fail { + t.Fatal("flush failure was not exercised") + } + if err := j.flush(context.Background()); err != nil { + t.Fatal(err) + } + if len(writer.events) != len(expected) { + t.Fatalf("lost received frame: got %d want %d", len(writer.events), len(expected)) + } + for i, event := range writer.events { + if string(event.Payload) != expected[i] { + t.Fatalf("frame %d changed", i) + } + } + }) + } +} + +func TestJournalDrainRetainsTerminalContinuityAndUsageOnFailure(t *testing.T) { + writer := &recoveringWriter{fail: true} + j := journal{store: writer, next: 1} + upstream := make(chan proto.Envelope, 64) + for i := range 63 { + env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "partial", Sequence: uint64(i + 1)}) + upstream <- env + } + done, _ := proto.NewEnvelope(proto.TypeDone, "run", proto.DonePayload{Content: "Final", Usage: proto.Usage{InputTokens: 10, OutputTokens: 4}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread"}}) + upstream <- done + close(upstream) + result := Result{ErrorCode: "event_persistence_failed"} + if err := j.drain(upstream, &result); err != nil { + t.Fatal(err) + } + if err := j.flush(context.Background()); err == nil { + t.Fatal("flush should fail") + } + if result.ErrorCode != "event_persistence_failed" || result.Done.Metadata[proto.DoneMetaAgentSessionID] != "native-thread" || result.Done.Usage.InputTokens != 10 || result.Done.Usage.OutputTokens != 4 { + t.Fatalf("terminal data lost or failure cleared: %+v", result) + } + if err := j.flush(context.Background()); err != nil { + t.Fatal(err) + } + if len(writer.events) != 64 || writer.events[63].Kind != proto.TypeDone { + t.Fatal("terminal frame lost") + } +} + +func TestCancellationReceiptContinuitySurvivesFlushFailure(t *testing.T) { + writer := &recoveringWriter{fail: true} + j := &journal{store: writer, next: 1} + for i := range 63 { + env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "partial", Sequence: uint64(i + 1)}) + if err := j.enqueue(env); err != nil { + t.Fatal(err) + } + } + result := Result{ErrorCode: "event_persistence_failed"} + reply := cancellationResult{ack: proto.InteractionDecisionAckPayload{Applied: true, Outcome: &proto.DonePayload{Usage: proto.Usage{InputTokens: 9}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancel-native"}}}} + if err := recordCancellation(context.Background(), j, reply, &result); err == nil { + t.Fatal("flush should fail") + } + if result.Done.Metadata[proto.DoneMetaAgentSessionID] != "cancel-native" || result.Done.Usage.InputTokens != 9 || result.ErrorCode != "event_persistence_failed" { + t.Fatalf("lost cancellation outcome: %+v", result) + } + if err := j.flush(context.Background()); err != nil { + t.Fatal(err) + } + if len(writer.events) != 64 { + t.Fatal("receipt or partial text lost") + } +} diff --git a/services/agents-api/internal/execution/mcode_profile_test.go b/services/agents-api/internal/execution/mcode_profile_test.go new file mode 100644 index 000000000..8cbf8e5d0 --- /dev/null +++ b/services/agents-api/internal/execution/mcode_profile_test.go @@ -0,0 +1,25 @@ +package execution + +import "testing" + +func TestMCodeOperationQualification(t *testing.T) { + p := Policy{} + for _, tc := range []struct { + name, body string + valid bool + }{ + {"text", `{"agent":{"model":"real-model"},"environment":{"type":"none"}}`, true}, + {"hosted", `{"agent":{"model":"real-model"},"environment":{"type":"openai_hosted"}}`, true}, + {"verbosity", `{"agent":{"model":"real-model","text":{"verbosity":"high"}},"environment":{"type":"none"}}`, false}, + {"subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true}},"environment":{"type":"none"}}`, false}, + {"functions", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, false}, + {"mcp", `{"agent":{"model":"real-model","tools":[{"type":"mcp","server_label":"x","server_url":"https://example.invalid/mcp"}]},"environment":{"type":"none"}}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := p.ValidateSessionConfiguration("mcode", []byte(tc.body)) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v err=%v", tc.valid, err) + } + }) + } +} diff --git a/services/agents-api/internal/execution/mcp.go b/services/agents-api/internal/execution/mcp.go new file mode 100644 index 000000000..0e9c97d09 --- /dev/null +++ b/services/agents-api/internal/execution/mcp.go @@ -0,0 +1,52 @@ +package execution + +import ( + "bytes" + "encoding/json" + "errors" + "net/url" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executionTools(raw []json.RawMessage) ([]proto.FunctionTool, []proto.MCPHTTPServer, error) { + functions := make([]json.RawMessage, 0, len(raw)) + var servers []proto.MCPHTTPServer + names := map[string]bool{} + for _, value := range raw { + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(value, &kind) != nil { + return nil, nil, errors.New("invalid execution tool") + } + if kind.Type != "mcp" { + functions = append(functions, value) + continue + } + var tool v1.MCPTool + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || tool.ConnectionOrigin != "service" || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { + return nil, nil, errors.New("unsupported execution MCP configuration") + } + u, err := url.Parse(tool.Transport.ServerURL) + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { + return nil, nil, errors.New("unsupported execution MCP URL") + } + if tool.AllowedTools != nil { + for _, name := range *tool.AllowedTools { + if name == "" { + return nil, nil, errors.New("invalid execution MCP tool name") + } + } + } + names[tool.ServerLabel] = true + servers = append(servers, proto.MCPHTTPServer{ServerLabel: tool.ServerLabel, + ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) + } + resolved, err := functionTools(functions) + return resolved, servers, err +} diff --git a/services/agents-api/internal/execution/mcp_credentials.go b/services/agents-api/internal/execution/mcp_credentials.go new file mode 100644 index 000000000..9aad2c6cf --- /dev/null +++ b/services/agents-api/internal/execution/mcp_credentials.go @@ -0,0 +1,75 @@ +package execution + +import ( + "encoding/json" + "errors" + "net/url" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// Resolve only the frozen decision. Never search current Vault contents during +// dispatch: a later credential must not change an anonymous or selected server. +func selectedMCPCredentials(snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { + invalid := errors.New("invalid frozen MCP credential binding") + tools := map[string]v1.MCPTool{} + for _, raw := range snapshot.Agent.Tools { + var tool v1.MCPTool + if json.Unmarshal(raw, &tool) != nil { + return nil, invalid + } + if tool.Type == "mcp" { + tools[tool.ServerLabel] = tool + } + } + attached := map[uuid.UUID]bool{} + for _, raw := range snapshot.VaultIDs { + id, err := uuid.Parse(raw) + if err != nil { + return nil, invalid + } + attached[id] = true + } + seen := map[string]bool{} + selected := map[string]store.MCPCredentialBinding{} + for _, binding := range snapshot.MCPCredentials { + tool, exists := tools[binding.ServerLabel] + if !exists || seen[binding.ServerLabel] || tool.Transport.ServerURL != binding.ServerURL { + return nil, invalid + } + seen[binding.ServerLabel] = true + if binding.CredentialID == "" { + if binding.VaultID != "" || binding.AuthType != "" || tool.CredentialID != nil { + return nil, invalid + } + continue + } + vaultID, err := uuid.Parse(binding.VaultID) + if err != nil || !attached[vaultID] || binding.AuthType != "static_bearer" { + return nil, invalid + } + id, err := uuid.Parse(binding.CredentialID) + if err != nil { + return nil, invalid + } + if tool.CredentialID != nil { + declared, err := uuid.Parse(*tool.CredentialID) + if err != nil || declared != id { + return nil, invalid + } + } + endpoint, err := url.Parse(binding.ServerURL) + if err != nil || endpoint.Scheme != "https" { + return nil, invalid + } + selected[binding.ServerLabel] = binding + } + for label, tool := range tools { + if !seen[label] && (tool.CredentialID != nil || len(snapshot.VaultIDs) > 0 || len(snapshot.MCPCredentials) > 0) { + return nil, invalid + } + } + return selected, nil +} diff --git a/services/agents-api/internal/execution/mcp_credentials_test.go b/services/agents-api/internal/execution/mcp_credentials_test.go new file mode 100644 index 000000000..1735442b3 --- /dev/null +++ b/services/agents-api/internal/execution/mcp_credentials_test.go @@ -0,0 +1,58 @@ +package execution + +import ( + "encoding/json" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestMCPFrozenCredentialAdmission(t *testing.T) { + vault, credential := uuid.NewString(), uuid.NewString() + for _, mode := range []string{"implicit", "explicit", "anonymous", "missing", "unattached", "wrong URL", "wrong auth", "changed selection", "HTTP", "remote", "self-hosted explicit", "self-hosted implicit", "self-hosted anonymous"} { + t.Run(mode, func(t *testing.T) { + tool := v1.MCPTool{Type: "mcp", ServerLabel: "tools", ConnectionOrigin: "service", Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example/tools"}} + binding := store.MCPCredentialBinding{ServerLabel: "tools", ServerURL: tool.Transport.ServerURL, VaultID: vault, CredentialID: credential, AuthType: "static_bearer"} + snapshot := Snapshot{Agent: v1.Agent{Model: "model"}, Environment: &v1.Environment{Type: "none"}, VaultIDs: []string{vault}} + switch mode { + case "explicit", "missing", "changed selection", "self-hosted explicit": + tool.CredentialID = &credential + case "anonymous", "self-hosted anonymous": + binding.VaultID, binding.CredentialID, binding.AuthType = "", "", "" + case "unattached": + snapshot.VaultIDs = nil + case "wrong URL": + binding.ServerURL += "/other" + case "wrong auth": + binding.AuthType = "other" + case "HTTP": + tool.Transport.ServerURL, binding.ServerURL = "http://mcp.example/tools", "http://mcp.example/tools" + case "remote": + snapshot.Daemon = &DaemonConfig{WorkDir: "/tmp"} + } + if strings.HasPrefix(mode, "self-hosted") { + snapshot.Environment = &v1.Environment{Type: "self_hosted", WorkspaceDirectory: "/work"} + } + if mode == "changed selection" { + binding.CredentialID = uuid.NewString() + } + snapshot.MCPCredentials = []store.MCPCredentialBinding{binding} + if mode == "missing" { + snapshot.MCPCredentials = nil + } + rawTool, _ := json.Marshal(tool) + snapshot.Agent.Tools = []json.RawMessage{rawTool} + raw, _ := json.Marshal(snapshot) + valid := mode == "implicit" || mode == "explicit" || mode == "anonymous" || mode == "self-hosted anonymous" || mode == "self-hosted implicit" || mode == "self-hosted explicit" + for _, engine := range []string{"codex", "claude_sdk"} { + supported := valid && (engine == "codex" || !strings.HasPrefix(mode, "self-hosted")) + if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != supported { + t.Fatal("frozen binding profile decision differs", engine, err) + } + } + }) + } +} diff --git a/services/agents-api/internal/execution/mcp_support.go b/services/agents-api/internal/execution/mcp_support.go new file mode 100644 index 000000000..97c92cc8c --- /dev/null +++ b/services/agents-api/internal/execution/mcp_support.go @@ -0,0 +1,59 @@ +package execution + +import ( + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (p Policy) mcpCredentialBindings(engine string, snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { + selected, err := selectedMCPCredentials(snapshot) + if err != nil { + return nil, err + } + profile, qualified := p.Engines.Lookup(engine) + if len(selected) > 0 && (!qualified || !profile.MCPBearer) { + return nil, errors.New("The configured engine currently supports anonymous HTTP MCP only.") + } + return selected, nil +} + +// Selection, final preclaim and request construction use the same combination +// checks. This function never reads plaintext credentials or native configuration. +func (p Policy) mcpExecutionCredentials(engine string, snapshot Snapshot, servers []proto.MCPHTTPServer, caps device.KindCapabilities) (map[string]store.MCPCredentialBinding, error) { + fail := func(message string) (map[string]store.MCPCredentialBinding, error) { + return nil, errors.New(message) + } + if len(servers) > 0 && (!caps.MCPHTTPTools || snapshot.Environment == nil || (snapshot.Environment.Type != "none" && snapshot.Environment.Type != "self_hosted") || snapshot.Daemon != nil) { + return fail("device must support the service-side HTTP MCP profile") + } + selected, err := p.mcpCredentialBindings(engine, snapshot) + if err != nil { + return nil, err + } + for _, server := range servers { + if server.Required && !caps.MCPHTTPRequired { + return fail("device must advertise mcp_http_required") + } + } + if len(servers) > 0 && snapshot.Environment.Type == "self_hosted" { + if !caps.MCPHTTPRemoteEnvironment { + return fail("device must support service-side HTTP MCP with a remote environment") + } + if len(selected) > 0 && !caps.MCPHTTPRemoteBearerAuth { + return fail("device must advertise mcp_http_remote_bearer_auth") + } + if !caps.Preparation || !caps.RemoteEnvironment { + return fail("device must advertise preparation and remote_environment") + } + } + if len(selected) > 0 && !caps.MCPHTTPBearerAuth { + return fail("device must advertise mcp_http_bearer_auth") + } + if len(servers) > 0 && snapshot.Environment.Type == "none" && !caps.EnvironmentNone { + return fail("device must advertise environment_none") + } + return selected, nil +} diff --git a/services/agents-api/internal/execution/mcp_support_test.go b/services/agents-api/internal/execution/mcp_support_test.go new file mode 100644 index 000000000..c1862f4aa --- /dev/null +++ b/services/agents-api/internal/execution/mcp_support_test.go @@ -0,0 +1,142 @@ +package execution + +import ( + "encoding/json" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func mcpSupportFixture(t *testing.T) (Snapshot, []proto.MCPHTTPServer, device.KindCapabilities) { + t.Helper() + vault, credential := uuid.NewString(), uuid.NewString() + tool := json.RawMessage(`{"type":"mcp","server_label":"tickets","connection_origin":"service","transport":{"type":"http","server_url":"https://mcp.example/tools"}}`) + snapshot := Snapshot{Agent: v1.Agent{Model: "model", Tools: []json.RawMessage{tool}}, Environment: &v1.Environment{Type: "none"}, VaultIDs: []string{vault}, + MCPCredentials: []store.MCPCredentialBinding{{ServerLabel: "tickets", ServerURL: "https://mcp.example/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}}} + _, servers, err := executionTools(snapshot.Agent.Tools) + if err != nil { + t.Fatal(err) + } + caps := device.KindCapabilities{EnvironmentNone: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: true, + Preparation: true, RemoteEnvironment: true, MCPHTTPRemoteEnvironment: true, MCPHTTPRemoteBearerAuth: true} + return snapshot, servers, caps +} + +func TestMCPPublicBearerPolicyIsIndependentOfRuntimeCapabilities(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk", "unknown"} { + t.Run(engine, func(t *testing.T) { + snapshot, servers, caps := mcpSupportFixture(t) + raw, _ := json.Marshal(snapshot) + allowed := engine == "codex" || engine == "claude_sdk" + if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != allowed { + t.Fatal("creation bypassed public credential policy", err) + } + if (Policy{}).canAdmitInputs(engine, raw) != allowed { + t.Fatal("later input bypassed public credential policy") + } + if _, err := (Policy{}).mcpExecutionCredentials(engine, snapshot, servers, caps); (err == nil) != allowed { + t.Fatal("runtime capabilities widened public admission", err) + } + request, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{Engine: engine}, snapshot, caps, store.SessionExecutionBinding{}) + if err == nil || request.MCPHTTPServers != nil { + t.Fatal("credential execution without a store was admitted") + } + if allowed && err.Error() != "authenticated MCP execution is unavailable" { + t.Fatal("accepted profile did not reach scoped credential lookup", err) + } + if !allowed && err.Error() != "The configured engine currently supports anonymous HTTP MCP only." { + t.Fatal("unverified profile bypassed public policy", err) + } + }) + } +} + +func TestMCPExecutionChecksRequireVerifiedCapabilityCombinations(t *testing.T) { + for _, placement := range []string{"none", "self_hosted"} { + for _, missing := range []string{"", "mcp", "bearer", "placement", "required", "preparation", "remote-mcp", "remote-bearer", "daemon", "environment"} { + t.Run(placement+"/"+missing, func(t *testing.T) { + snapshot, servers, caps := mcpSupportFixture(t) + snapshot.Environment.Type = placement + snapshot.Environment.WorkspaceDirectory = "/work" + servers[0].Required = true + var tool v1.MCPTool + if err := json.Unmarshal(snapshot.Agent.Tools[0], &tool); err != nil { + t.Fatal(err) + } + tool.Required = true + snapshot.Agent.Tools[0], _ = json.Marshal(tool) + switch missing { + case "mcp": + caps.MCPHTTPTools = false + case "bearer": + caps.MCPHTTPBearerAuth = false + case "placement": + caps.EnvironmentNone, caps.RemoteEnvironment = false, false + case "required": + caps.MCPHTTPRequired = false + case "preparation": + caps.Preparation = false + case "remote-mcp": + caps.MCPHTTPRemoteEnvironment = false + case "remote-bearer": + caps.MCPHTTPRemoteBearerAuth = false + case "daemon": + snapshot.Daemon = &DaemonConfig{WorkDir: "/work"} + case "environment": + snapshot.Environment = nil + } + allowed := missing == "" || placement == "none" && (missing == "preparation" || missing == "remote-mcp" || missing == "remote-bearer") + selected, err := (Policy{}).mcpExecutionCredentials("codex", snapshot, servers, caps) + if (err == nil) != allowed || allowed && len(selected) != 1 { + t.Fatal("incorrect combined MCP capability decision", err) + } + if !allowed { + request, requestErr := (&Dispatcher{}).executionRequest(t.Context(), store.Session{Engine: "codex"}, snapshot, caps, store.SessionExecutionBinding{}) + if requestErr == nil || request.MCPHTTPServers != nil || requestErr.Error() == "authenticated MCP execution is unavailable" { + t.Fatal("request bypassed capability checks before credential lookup", requestErr) + } + } + }) + } + } +} + +func TestMCPAnonymousExecutionPreservesFrozenDecision(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk"} { + for _, mode := range []string{"unattached", "frozen anonymous", "invalid binding"} { + t.Run(engine+"/"+mode, func(t *testing.T) { + snapshot, _, caps := mcpSupportFixture(t) + caps.MCPHTTPBearerAuth = false + if mode == "unattached" { + snapshot.VaultIDs, snapshot.MCPCredentials = nil, nil + } else { + snapshot.MCPCredentials[0].VaultID = "" + snapshot.MCPCredentials[0].CredentialID = "" + snapshot.MCPCredentials[0].AuthType = "" + if mode == "invalid binding" { + snapshot.MCPCredentials[0].ServerURL += "/different" + } + } + raw, _ := json.Marshal(snapshot) + allowed := mode != "invalid binding" + if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != allowed || (Policy{}).canAdmitInputs(engine, raw) != allowed { + t.Fatal("anonymous binding validation changed", err) + } + request, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{Engine: engine}, snapshot, caps, store.SessionExecutionBinding{}) + if !allowed { + if err == nil || request.MCPHTTPServers != nil { + t.Fatal("invalid frozen binding reached dispatch") + } + return + } + if err != nil || request.MCPHTTPServers == nil || len(*request.MCPHTTPServers) != 1 || (*request.MCPHTTPServers)[0].BearerToken != nil { + t.Fatal("anonymous dispatch gained a credential requirement", err) + } + }) + } + } +} diff --git a/services/agents-api/internal/execution/mcp_test.go b/services/agents-api/internal/execution/mcp_test.go new file mode 100644 index 000000000..4b7ad6e97 --- /dev/null +++ b/services/agents-api/internal/execution/mcp_test.go @@ -0,0 +1,34 @@ +package execution + +import ( + "encoding/json" + "testing" +) + +func TestMCPRequiresSupportedServicePlacement(t *testing.T) { + tool := json.RawMessage(`{"type":"mcp","server_label":"tickets","transport":{"type":"http","server_url":"https://mcp.example/mcp"},"connection_origin":"service","allowed_tools":[]}`) + for _, profile := range []struct { + engine, environment string + valid bool + }{ + {"codex", `{"type":"none"}`, true}, + {"claude_sdk", `{"type":"none"}`, true}, + {"claude_sdk", `{"type":"self_hosted","workspace_directory":"/work"}`, false}, + {"unavailable", `{"type":"none"}`, false}, + {"codex", `null`, false}, + {"codex", `{"type":"self_hosted","workspace_directory":"/work"}`, true}, + } { + raw, err := json.Marshal(map[string]any{"agent": map[string]any{"model": "model", "tools": []json.RawMessage{tool}}, "environment": json.RawMessage(profile.environment)}) + if err != nil { + t.Fatal(err) + } + if err := (Policy{}).ValidateSessionConfiguration(profile.engine, raw); (err == nil) != profile.valid { + t.Fatalf("%s/%s: %v", profile.engine, profile.environment, err) + } + } + function := json.RawMessage(`{"type":"function","name":"lookup","description":"Read","parameters":{"type":"object"},"defer_loading":false}`) + functions, servers, err := executionTools([]json.RawMessage{tool, function}) + if err != nil || len(functions) != 1 || functions[0].Name != "lookup" || len(servers) != 1 || servers[0].AllowedTools == nil || len(*servers[0].AllowedTools) != 0 { + t.Fatal("mixed tool configuration lost the deny-all declaration", functions, servers, err) + } +} diff --git a/services/agents-api/internal/execution/model_execution.go b/services/agents-api/internal/execution/model_execution.go new file mode 100644 index 000000000..affa2a176 --- /dev/null +++ b/services/agents-api/internal/execution/model_execution.go @@ -0,0 +1,31 @@ +package execution + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (d *Dispatcher) sessionModelOptions(ctx context.Context, session store.Session, model string) (map[string]any, error) { + if d.Store == nil { + return nil, errors.New("session model configuration is unavailable") + } + provider, err := d.Store.SessionModelExecution(ctx, session.TenantID, session.ID) + if err != nil { + return nil, err + } + if err := provider.ValidateHarness(session.Engine); err != nil { + return nil, err + } + switch session.Engine { + case "codex": + return map[string]any{"codex_provider": map[string]any{"base_url": provider.BaseURL, "bearer_token": provider.APIKey, "wire_api": "responses"}}, nil + case "claude_sdk": + return map[string]any{"claude_provider": map[string]any{"base_url": provider.BaseURL, "bearer_token": provider.APIKey}}, nil + case "mcode": + return map[string]any{"mcode_provider": map[string]any{"name": "Configured provider", "kind": "custom", "enabled": true, "npm": "@ai-sdk/anthropic", "options": map[string]any{"baseURL": provider.BaseURL, "apiKey": provider.APIKey}, "models": map[string]any{model: map[string]any{"name": model, "tool_call": true, "limit": map[string]any{"context": provider.ContextWindow, "output": provider.MaxOutputTokens}}}}}, nil + default: + return nil, errors.New("unsupported model provider harness") + } +} diff --git a/services/agents-api/internal/execution/model_execution_test.go b/services/agents-api/internal/execution/model_execution_test.go new file mode 100644 index 000000000..20fd6b548 --- /dev/null +++ b/services/agents-api/internal/execution/model_execution_test.go @@ -0,0 +1,23 @@ +package execution + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "testing" +) + +func TestSessionModelExecutionNeverFallsBackToOperatorCredentials(t *testing.T) { + called := false + d := Dispatcher{Options: func(context.Context, store.Session) (map[string]any, error) { + called = true + return map[string]any{"model": "operator-fallback"}, nil + }} + _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{ModelProviderConfigured: true}, device.KindCapabilities{}, store.SessionExecutionBinding{}) + if err == nil || called { + t.Fatal("missing Session credentials used operator fallback") + } + if _, err = d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{}, device.KindCapabilities{}, store.SessionExecutionBinding{}); err != nil || !called { + t.Fatal("legacy operator configuration lost", err) + } +} diff --git a/services/agents-api/internal/execution/policy.go b/services/agents-api/internal/execution/policy.go new file mode 100644 index 000000000..c6feb4664 --- /dev/null +++ b/services/agents-api/internal/execution/policy.go @@ -0,0 +1,10 @@ +package execution + +import "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + +// Policy supplies immutable service qualification to admission and dispatch. +// The zero value uses built-in profiles. Custom composition must supply the same +// policy to the HTTP handler and Dispatcher; Runtime claims never add profiles. +type Policy struct { + Engines engine.Catalog +} diff --git a/services/agents-api/internal/execution/preparation.go b/services/agents-api/internal/execution/preparation.go new file mode 100644 index 000000000..0181bb6ef --- /dev/null +++ b/services/agents-api/internal/execution/preparation.go @@ -0,0 +1,111 @@ +package execution + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type preparedStart struct { + peer *gateway.Session + requestID string + handle string + sub *gateway.Subscription +} + +func newPreparedStart(peer *gateway.Session) (*preparedStart, error) { + id := uuid.NewString() + sub, err := peer.SubscribePreparation(id) + if err != nil { + return nil, err + } + return &preparedStart{peer: peer, requestID: id, sub: sub}, nil +} + +func (p *preparedStart) close() { + if p.handle != "" { + _ = send(context.Background(), p.peer, proto.TypeExecutionRelease, p.requestID, proto.ExecutionReleasePayload{Handle: p.handle}) + } + p.peer.UnsubscribePreparation(p.requestID) +} + +func (p *preparedStart) controlStatus(env proto.Envelope) (proto.PreparationStatusPayload, error) { + var status proto.PreparationStatusPayload + if env.Type != proto.TypePreparationStatus || env.ID != p.requestID || env.DecodePayload(&status) != nil { + return status, errors.New("invalid preparation control response") + } + if status.State == "rejected" { + return status, nil + } + if status.Handle == "" || status.Revision == 0 || (p.handle != "" && p.handle != status.Handle) { + return status, errors.New("preparation identity changed") + } + p.handle = status.Handle + return status, nil +} + +func (p *preparedStart) observation(env proto.Envelope) (proto.PreparationStatusPayload, error) { + status, err := p.controlStatus(env) + if err != nil { + return status, err + } + if status.State == "rejected" { + return status, errors.New("preparation control rejected") + } + switch status.State { + case "preparing", "ready", "starting", "started": + return status, nil + default: + return status, errors.New("preparation is no longer available") + } +} + +func (d *Dispatcher) awaitPreparation(ctx context.Context, tenant, session string, pending store.EnvironmentInputReservation, prepared *preparedStart) (store.EnvironmentInputReservation, error) { + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return pending, ctx.Err() + case <-tick.C: + current, err := d.Store.ExpireEnvironmentInput(ctx, tenant, session, pending.ID) + if err != nil || current.State != store.EnvironmentInputPending { + return current, err + } + case env, ok := <-prepared.sub.Events: + if !ok { + return pending, errors.New("preparation control stream closed") + } + status, err := prepared.observation(env) + if err != nil { + return pending, err + } + if status.State == "ready" && status.RunID == "" { + return pending, nil + } + if status.State != "preparing" { + return pending, errors.New("unexpected preparation state before admission") + } + } + } +} + +func (p *preparedStart) start(ctx context.Context, request proto.PromptRequestPayload) error { + return send(ctx, p.peer, proto.TypeExecutionStart, p.requestID, proto.ExecutionStartPayload{Handle: p.handle, RunID: request.RunID, Prompt: request.Prompt}) +} + +func (p *preparedStart) started(env proto.Envelope, runID string) (bool, error) { + status, err := p.observation(env) + if err != nil { + return false, err + } + if status.RunID != runID || (status.State != "starting" && status.State != "started") { + return false, errors.New("unexpected preparation state after admission") + } + return status.State == "started", nil +} diff --git a/services/agents-api/internal/execution/prepared_dispatch.go b/services/agents-api/internal/execution/prepared_dispatch.go new file mode 100644 index 000000000..50cd3bd04 --- /dev/null +++ b/services/agents-api/internal/execution/prepared_dispatch.go @@ -0,0 +1,105 @@ +package execution + +import ( + "context" + "encoding/json" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type EnvironmentConnection struct { + URL, Token string + Release func() +} + +type EnvironmentRun struct { + Reservation store.EnvironmentInputReservation + Turn store.Turn +} + +// RunEnvironmentInput owns a private preparation through its first admitted Run. +func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (run EnvironmentRun, err error) { + if err = d.Store.CheckExecutionOwnership(ctx); err != nil { + return run, err + } + run.Reservation, err = d.Store.ExpireEnvironmentInput(ctx, tenantID, sessionID, reservationID) + if err != nil || run.Reservation.State != store.EnvironmentInputPending { + return run, err + } + session, err := d.Store.GetSession(ctx, tenantID, sessionID) + if err != nil { + return run, err + } + environment, err := d.Store.GetSessionEnvironment(ctx, tenantID, sessionID) + if err != nil { + return run, err + } + var snapshot Snapshot + if json.Unmarshal(session.Configuration, &snapshot) != nil || snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { + return run, store.ErrInvalidInput + } + bound, err := d.Store.GetSessionExecutionBinding(ctx, tenantID, sessionID) + if err != nil { + return run, err + } + peer, err := d.Registry.LookupDevice(bound.Device.ID) + if err != nil { + return run, err + } + caps, err := d.engineCapabilities(peer, session.Engine, snapshot) + if err != nil { + return run, err + } + owner, cancel := context.WithCancel(ctx) + defer cancel() + req, err := d.executionRequest(owner, session, snapshot, caps, bound) + if err != nil { + return run, err + } + var messages []string + for _, input := range run.Reservation.Inputs { + if input.Kind != "message" { + return run, store.ErrInvalidInput + } + text, err := messageText(input.Payload) + if err != nil { + return run, err + } + messages = append(messages, text) + } + release, err := d.configurePreparedEnvironment(owner, session, environment, bound.Device, &req) + if release != nil { + defer release() + } + if err != nil { + return run, err + } + prepared, err := newPreparedStart(peer) + if err != nil { + return run, err + } + defer prepared.close() + if err = send(owner, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{Configuration: req}); err != nil { + return run, err + } + run.Reservation, err = d.awaitPreparation(owner, tenantID, sessionID, run.Reservation, prepared) + if err != nil || run.Reservation.State != store.EnvironmentInputPending { + return run, err + } + run.Reservation, err = d.Store.PromoteEnvironmentInput(owner, tenantID, sessionID, reservationID) + if err != nil || run.Reservation.State != store.EnvironmentInputAdmitted { + return run, err + } + if run.Reservation.Receipts[0].Replayed { + return run, nil + } + req.RunID = run.Reservation.Receipts[0].TurnID + req.Prompt = strings.Join(messages, "\n\n") + through := run.Reservation.Receipts[len(run.Reservation.Receipts)-1].Sequence + result, status := d.deliver(owner, tenantID, sessionID, peer, req, through, prepared) + result, status = d.captureCompletedArtifacts(owner, peer, session, environment, bound.Device, req.RunID, result, status) + run.Turn, err = d.finishRun(tenantID, sessionID, req.RunID, snapshot.Agent.Model, result, status) + return run, err +} diff --git a/services/agents-api/internal/execution/recovery_test.go b/services/agents-api/internal/execution/recovery_test.go new file mode 100644 index 000000000..837096bf1 --- /dev/null +++ b/services/agents-api/internal/execution/recovery_test.go @@ -0,0 +1,30 @@ +package execution + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExistingSessionRecoveryRequiresVerifiedCapability(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk", "future-engine"} { + for _, started := range []bool{false, true} { + for _, nativeID := range []string{"", "native"} { + for _, capable := range []bool{false, true} { + wantRecovery := started && nativeID == "" + req, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{ID: "session", Engine: engine}, Snapshot{}, device.KindCapabilities{NativeSessionRecovery: capable}, store.SessionExecutionBinding{HasStartedTurn: started, NativeSessionID: nativeID}) + if wantRecovery && !capable { + if err == nil { + t.Fatal("unverified recovery admitted", engine) + } + continue + } + if err != nil || req.RequireExistingNativeSession != wantRecovery || req.AgentSessionID != nativeID || !req.StrictResume || req.AgentStateKey != "agents-api-session" { + t.Fatalf("engine=%s started=%v id=%s capability=%v request=%+v err=%v", engine, started, nativeID, capable, req, err) + } + } + } + } + } +} diff --git a/services/agents-api/internal/execution/request.go b/services/agents-api/internal/execution/request.go new file mode 100644 index 000000000..a60182a71 --- /dev/null +++ b/services/agents-api/internal/execution/request.go @@ -0,0 +1,72 @@ +package execution + +import ( + "context" + "errors" + "maps" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session, snapshot Snapshot, caps device.KindCapabilities, bound store.SessionExecutionBinding) (proto.PromptRequestPayload, error) { + recoverNativeSession := bound.HasStartedTurn && bound.NativeSessionID == "" + if recoverNativeSession && !caps.NativeSessionRecovery { + return proto.PromptRequestPayload{}, errors.New("native session recovery is unavailable") + } + functions, mcp, err := executionTools(snapshot.Agent.Tools) + if err != nil { + return proto.PromptRequestPayload{}, err + } + options := map[string]any{} + if snapshot.ModelProviderConfigured { + options, err = d.sessionModelOptions(ctx, session, snapshot.Agent.Model) + if err != nil { + return proto.PromptRequestPayload{}, err + } + } else if d.Options != nil { + options, err = d.Options(ctx, session) + if err != nil { + return proto.PromptRequestPayload{}, err + } + options = maps.Clone(options) + if options == nil { + options = map[string]any{} + } + } + options["model"], options["system_prompt"] = snapshot.Agent.Model, snapshot.Agent.Instructions + delete(options, "override_system_prompt") + verbosity := snapshot.Agent.Text.Verbosity + if verbosity == "" { + verbosity = "medium" + } + controls := &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: verbosity} + request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: functions, + AgentOptions: options, ExecutionControls: controls, AgentStateKey: "agents-api-" + session.ID, + AgentSessionID: bound.NativeSessionID, ReleaseOnCompletion: true, StrictResume: true, + RequireExistingNativeSession: recoverNativeSession, + ObserveMessages: caps.MessageItems, ObserveToolObservations: true, + ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, + DisableSubagents: !snapshot.Agent.MultiAgent.Enabled} + if len(mcp) != 0 { + selected, err := d.mcpExecutionCredentials(session.Engine, snapshot, mcp, caps) + if err != nil { + return proto.PromptRequestPayload{}, err + } + if len(selected) > 0 && d.Store == nil { + return proto.PromptRequestPayload{}, errors.New("authenticated MCP execution is unavailable") + } + for i := range mcp { + if binding, ok := selected[mcp[i].ServerLabel]; ok { + token, err := d.Store.MCPBearerToken(ctx, session.TenantID, snapshot.VaultIDs, binding) + if err != nil { + return proto.PromptRequestPayload{}, err + } + mcp[i].BearerToken = &token + } + } + request.MCPHTTPServers = &mcp + } + return request, nil +} diff --git a/services/agents-api/internal/execution/runtime_connections.go b/services/agents-api/internal/execution/runtime_connections.go new file mode 100644 index 000000000..d63f0106e --- /dev/null +++ b/services/agents-api/internal/execution/runtime_connections.go @@ -0,0 +1,59 @@ +package execution + +import ( + "context" + + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Access is serialized by the existing lifecycle gate. Durable generations fence +// old observations; this map only remembers the currently observed socket. +type runtimeConnection struct { + peer *gateway.Session + generation string + revision int64 + connected bool +} + +func (r *runtimeLifecycle) observeConnection(ctx context.Context, owner store.RuntimeAllocation) error { + if owner.Initialization != "complete" { + return nil + } + bound, err := r.store.GetSessionDevice(ctx, owner.TenantID, owner.SessionID) + if err != nil { + return err + } + if bound.ID != owner.DeviceID || bound.EnvironmentID != owner.EnvironmentID { + return store.ErrDeviceBindingConflict + } + peer, err := r.registry.LookupDevice(owner.DeviceID) + connected := err == nil && !peer.IsClosed() + current := r.connections[owner.ID] + if connected { + // Initial connection is published only after bootstrap ownership is + // settled. Native execution readiness remains a separate preparation. + if !owner.CreateSettled || owner.State != "running" { + return nil + } + if current == nil || current.peer != peer { + generation := uuid.NewString() + if err := r.store.ReplaceEnvironmentConnection(ctx, owner.TenantID, owner.EnvironmentID, generation); err != nil { + return err + } + current = &runtimeConnection{peer: peer, generation: generation} + r.connections[owner.ID] = current + } + } + if current == nil || current.connected == connected { + return nil + } + current.revision++ + if err := r.store.ObserveEnvironmentConnection(ctx, owner.TenantID, owner.EnvironmentID, current.generation, current.revision, connected); err != nil { + return err + } + current.connected = connected + return nil +} diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go new file mode 100644 index 000000000..36f8ee8fc --- /dev/null +++ b/services/agents-api/internal/execution/runtime_initialization.go @@ -0,0 +1,169 @@ +package execution + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Progress is process-local: a recovered running installation is never replayed. +type runtimeInitialization struct { + owner store.RuntimeAllocation + next, count int + files int + operations []runtimeSetupOperation + deadline time.Time +} + +func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner store.RuntimeAllocation) error { + if owner.Initialization == "complete" { + return nil + } + if owner.Initialization == "running" { + if r.initializing != nil && r.initializing.owner.ID == owner.ID { + return nil + } + _, err := r.store.RequestRuntimeCleanup(ctx, owner) + return err + } + if r.initializing != nil { + return nil + } + environment, err := r.store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) + if err != nil { + return err + } + var cfg struct { + Initialization bool `json:"initialization"` + Files []store.InitialFileMetadata `json:"files"` + } + if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { + _, err = r.store.RequestRuntimeCleanup(ctx, owner) + return err + } + setup, err := r.store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) + if err != nil { + return err + } + operations := setupOperations(setup) + if len(cfg.Files)+len(operations) == 0 || cfg.Initialization != !setup.Empty() { + _, err = r.store.RequestRuntimeCleanup(ctx, owner) + return err + } + claimed, err := r.store.ClaimRuntimeInitialization(ctx, owner) + if err != nil { + return err + } + r.initializing = &runtimeInitialization{owner: claimed, count: len(cfg.Files) + len(operations), files: len(cfg.Files), operations: operations, deadline: time.Now().Add(30 * time.Minute)} + return nil +} + +// One bounded operation follows a full maintenance scan; other allocations get serviced between operations. +func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { + active := r.initializing + if active == nil { + return nil + } + owner, err := r.store.GetRuntimeAllocation(ctx, active.owner.TenantID, active.owner.EnvironmentID) + if err != nil { + return err + } + if owner.State == "cleanup_pending" || owner.State == "released" || owner.SessionDeleted || owner.Expired { + r.initializing = nil + return nil + } + if owner.Initialization == "complete" { + r.initializing = nil + return nil + } + if owner.ID != active.owner.ID || owner.Initialization != "running" { + r.initializing = nil + return sandbox.ErrOwnership + } + operation, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if time.Now().After(active.deadline) { + r.initializing = nil + return sandbox.ErrCommandUnconfirmed + } + if err := r.store.CheckExecutionOwnership(operation); err != nil { + r.initializing = nil + return err + } + if active.next < active.files { + var file store.InitialFileMetadata + var body []byte + file, body, err = r.store.ReadInitialEnvironmentFile(operation, owner.TenantID, owner.SessionID, active.next) + if err == nil { + err = installInitialFile(operation, r.config.Providers[owner.ProviderKey], runtimeReference(owner), file, body) + } + } else { + err = runRuntimeSetup(operation, r.config.Providers[owner.ProviderKey], runtimeReference(owner), active.operations[active.next-active.files]) + } + if err != nil { + // Clearing the in-memory owner makes the next observation request cleanup, + // even when the failed operation consumed its entire deadline. + r.initializing = nil + return err + } + active.next++ + if active.next == active.count { + _, err = r.store.CompleteRuntimeInitialization(operation, owner) + r.initializing = nil + return err + } + return nil +} + +func installInitialFile(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, file store.InitialFileMetadata, body []byte) error { + if provider == nil || file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > store.MaxInitialFileBytes || !strings.HasPrefix(file.Path, "/workspace/") { + return sandbox.ErrInvalid + } + digest := sha256.Sum256(body) + input := make([]byte, 0, len(body)+len(digest)) + input = append(input, body...) + input = append(input, digest[:]...) + result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: []string{"/usr/bin/python3", "-I", "-S", "-c", initialFileInstaller, strings.TrimPrefix(file.Path, "/workspace/"), strconv.Itoa(len(body))}, Stdin: input}) + if err != nil { + return err + } + var receipt struct { + Version int `json:"version"` + Outcome string `json:"outcome"` + SizeBytes *int64 `json:"size_bytes"` + } + if result.ExitCode != 0 || result.Stderr != "" || json.Unmarshal([]byte(result.Stdout), &receipt) != nil || receipt.Version != 1 || receipt.Outcome != "completed" || receipt.SizeBytes == nil || *receipt.SizeBytes != int64(len(body)) { + return errors.New("initial environment file installation unconfirmed") + } + return nil +} + +// Isolated Python creates only fd-anchored workspace parents, then replaces itself with the existing atomic writer. +const initialFileInstaller = `import os, sys +parts = sys.argv[1].split('/') +if any(not p or p in ('.', '..') or any(c in p for c in ('\\', '\x00', '\r', '\n')) for p in parts): + raise SystemExit(2) +flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +fd = os.open('/', flags) +for component in ('environment', 'workspace'): + child = os.open(component, flags, dir_fd=fd) + os.close(fd) + fd = child +for component in parts[:-1]: + try: + os.mkdir(component, mode=0o700, dir_fd=fd) + except FileExistsError: + pass + child = os.open(component, flags, dir_fd=fd) + os.close(fd) + fd = child +os.close(fd) +os.execv('/usr/local/bin/agents-api-codex-write', ['agents-api-codex-write', '/environment/workspace', sys.argv[1], sys.argv[2], '/environment/staging']) +` diff --git a/services/agents-api/internal/execution/runtime_initialization_real_test.go b/services/agents-api/internal/execution/runtime_initialization_real_test.go new file mode 100644 index 000000000..a067cbe91 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_initialization_real_test.go @@ -0,0 +1,96 @@ +package execution + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type installerObservation struct { + sandbox.Provider + t *testing.T +} + +func (p installerObservation) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + result, err := p.Provider.RunCommand(ctx, r, c) + if err != nil || result.ExitCode != 0 { + p.t.Logf("trusted fixture installer exit=%d stdout=%q stderr=%q error=%v", result.ExitCode, result.Stdout, result.Stderr, err) + } + return result, err +} + +func TestRealE2BInitialFileInstaller(t *testing.T) { + keyFile, template := os.Getenv("PARSAR_E2B_TEST_KEY_FILE"), os.Getenv("PARSAR_E2B_TEST_TEMPLATE") + if keyFile == "" || template == "" { + t.Skip("actual E2B account and qualified Runtime template required") + } + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal("private E2B key unavailable") + } + provider, err := e2b.New(e2b.Config{InstallationID: uuid.NewString(), APIKey: strings.TrimSpace(string(key)), Template: template, LeaseSeconds: 7200}) + if err != nil { + t.Fatal(err) + } + p := installerObservation{Provider: provider, t: t} + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://example.com/api/v1", Credential: uuid.NewString(), NetworkAccess: "enabled"} + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + if err := p.Kill(ctx, b.Reference); err != nil { + t.Error(err) + } + }) + if _, err := p.Create(ctx, b); err != nil { + t.Fatal(err) + } + for _, body := range [][]byte{{}, []byte("binary\x00\xff\n"), bytes.Repeat([]byte{0xA5}, 50<<20)} { + file := store.InitialFileMetadata{Path: "/workspace/nested/input.bin"} + size := int64(len(body)) + file.SizeBytes = &size + operation, stop := context.WithTimeout(ctx, 2*time.Minute) + err := installInitialFile(operation, p, b.Reference, file, body) + stop() + if err != nil { + t.Fatal("actual shared installer", err) + } + result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sha256sum", "/environment/workspace/nested/input.bin"}}) + digest := sha256.Sum256(body) + if err != nil || result.ExitCode != 0 || !strings.HasPrefix(result.Stdout, hex.EncodeToString(digest[:])) { + t.Fatal("installed bytes differ", err) + } + } + result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", `from pathlib import Path +outside = Path('/tmp/initial-file-outside') +outside.mkdir() +(outside / 'data').write_text('preserved') +Path('/environment/workspace/escape-parent').symlink_to(outside, target_is_directory=True) +Path('/environment/workspace/escape-file').symlink_to(outside / 'data') +`}}) + if err != nil || result.ExitCode != 0 { + t.Fatal("symlink fixture", err) + } + for _, destination := range []string{"/workspace/escape-parent/data", "/workspace/escape-file"} { + size := int64(7) + if err := installInitialFile(ctx, p, b.Reference, store.InitialFileMetadata{Path: destination, SizeBytes: &size}, []byte("changed")); err == nil { + t.Fatal("initialization followed symlink", destination) + } + } + result, err = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/tmp/initial-file-outside/data"}}) + if err != nil || result.ExitCode != 0 || result.Stdout != "preserved" { + t.Fatal("initialization changed bytes outside the workspace", err) + } + +} diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go new file mode 100644 index 000000000..f336bac1e --- /dev/null +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -0,0 +1,304 @@ +package execution + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/url" + "time" + + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// RuntimeProviders is trusted operator wiring, not public Environment input. +// Each stable key identifies one provider backend/installation across restarts; +// changing that target requires a new key, preserving the old cleanup adapter. +type RuntimeProviders struct { + CoreURL string + DefaultProvider string + EngineProviders map[string]string + Providers map[string]sandbox.Provider +} + +type runtimeLifecycle struct { + store *store.Store + registry *gateway.Registry + config RuntimeProviders + gate chan struct{} + ctx context.Context + stop context.CancelFunc + cursor string + pendingCursor string + connections map[string]*runtimeConnection + initializing *runtimeInitialization +} + +func newRuntimeLifecycle(s *store.Store, registry *gateway.Registry, config *RuntimeProviders) (*runtimeLifecycle, error) { + if config == nil { + return nil, nil + } + u, err := url.Parse(config.CoreURL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(config.Providers) == 0 || registry == nil { + return nil, sandbox.ErrInvalid + } + copied := RuntimeProviders{EngineProviders: make(map[string]string, len(config.EngineProviders)), CoreURL: config.CoreURL, DefaultProvider: config.DefaultProvider, Providers: make(map[string]sandbox.Provider, len(config.Providers))} + for key, provider := range config.Providers { + id, err := uuid.Parse(key) + if err != nil || id == uuid.Nil || id.String() != key || provider == nil { + return nil, sandbox.ErrInvalid + } + copied.Providers[key] = provider + } + for kind, key := range config.EngineProviders { + if key == "" || copied.Providers[key] == nil { + return nil, sandbox.ErrInvalid + } + copied.EngineProviders[kind] = key + } + if copied.DefaultProvider != "" && copied.Providers[copied.DefaultProvider] == nil { + return nil, sandbox.ErrInvalid + } + ctx, stop := context.WithCancel(context.Background()) + return &runtimeLifecycle{store: s, registry: registry, config: copied, gate: make(chan struct{}, 1), ctx: ctx, stop: stop, connections: make(map[string]*runtimeConnection)}, nil +} + +func (r *runtimeLifecycle) lock(ctx context.Context) error { + select { + case r.gate <- struct{}{}: + if err := r.ctx.Err(); err != nil { + <-r.gate + return err + } + if err := ctx.Err(); err != nil { + <-r.gate + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-r.ctx.Done(): + return r.ctx.Err() + } +} + +// ProvisionEnvironment is an internal bootstrap operation for an already +// authorized hosted Environment. It does not enable public hosted admission. +func (w *Worker) ProvisionEnvironment(ctx context.Context, tenant, environment, providerKey string) (store.RuntimeAllocation, error) { + if w.runtimes == nil { + return store.RuntimeAllocation{}, ErrExecutionUnavailable + } + r := w.runtimes + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + detach := context.AfterFunc(r.ctx, cancel) + defer func() { detach(); cancel() }() + if err := r.lock(ctx); err != nil { + return store.RuntimeAllocation{}, err + } + defer func() { <-r.gate }() + return r.provision(ctx, tenant, environment, providerKey) +} + +// provision runs under the lifecycle gate and uses the durable one-shot receipt. +func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, providerKey string) (store.RuntimeAllocation, error) { + provider := r.config.Providers[providerKey] + if provider == nil { + return store.RuntimeAllocation{}, sandbox.ErrInvalid + } + environmentValue, err := r.store.GetEnvironment(ctx, tenant, environment) + if err != nil { + return store.RuntimeAllocation{}, err + } + placement, err := parseEnvironmentPlacement(environmentValue.Configuration) + if err != nil || placement.Type != "openai_hosted" { + return store.RuntimeAllocation{}, sandbox.ErrInvalid + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return store.RuntimeAllocation{}, err + } + token := hex.EncodeToString(secret) + owner, err := r.store.ReserveRuntimeAllocation(ctx, tenant, environment, providerKey, device.HashCredential(token)) + if err != nil || owner.Replayed { + return owner, err + } + if err := r.store.CheckExecutionOwnership(ctx); err != nil { + return owner, err + } + info, err := provider.Create(ctx, sandbox.Bootstrap{ + Reference: runtimeReference(owner), SessionID: owner.SessionID, DeviceID: owner.DeviceID, + CoreURL: r.config.CoreURL, Credential: token, NetworkAccess: placement.NetworkAccess, + }) + if err != nil { + // Explicit invalid/foreign bootstrap cannot become an authorized Runtime. + // Other failures may hide a successful Create; retain recovery for those. + if errors.Is(err, sandbox.ErrInvalid) || errors.Is(err, sandbox.ErrOwnership) { + if _, cleanupErr := r.store.RequestRuntimeCleanup(ctx, owner); cleanupErr != nil { + return owner, cleanupErr + } + } + // Reconciliation observes the original allocation; it never sends Create again. + return owner, err + } + if info.Reference != runtimeReference(owner) || info.ProviderID == "" || info.State != "running" || !info.BootstrapComplete { + return owner, sandbox.ErrOwnership + } + return r.store.ObserveRuntimeRunning(ctx, owner) +} + +// ReconcileManagedRuntimes is also callable before serving admission. One scan +// observes existing allocations and bootstraps committed resources without an +// allocation. It never retries an existing Create or native work. +func (w *Worker) ReconcileManagedRuntimes(ctx context.Context) error { + if w.runtimes == nil { + return nil + } + r := w.runtimes + ctx, cancel := context.WithCancel(ctx) + detach := context.AfterFunc(r.ctx, cancel) + defer func() { detach(); cancel() }() + if err := r.lock(ctx); err != nil { + return err + } + defer func() { <-r.gate }() + rows, err := r.store.ListRuntimeAllocations(ctx, r.cursor) + if err != nil { + return err + } + if len(rows) == 0 { + r.cursor = "" + if err := r.advanceInitialization(ctx); err != nil { + if ownership := r.store.CheckExecutionOwnership(ctx); ownership != nil { + return ownership + } + log.Ctx(ctx).Warn("managed Runtime file initialization incomplete") + } + return r.provisionPending(ctx) + } + for _, owner := range rows { + r.cursor = owner.ID + operation, stop := context.WithTimeout(ctx, 30*time.Second) + err := r.observe(operation, owner) + stop() + if err != nil { + if ownership := r.store.CheckExecutionOwnership(ctx); ownership != nil { + return ownership + } + // Provider errors can include operator configuration. Log safe identity + // only; retain the durable owner for the next bounded observation. + log.Ctx(ctx).Warn("managed Runtime observation incomplete", "allocation_id", owner.ID) + } + } + return r.provisionPending(ctx) +} + +func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAllocation) error { + if owner.Initialization == "running" && (r.initializing == nil || r.initializing.owner.ID != owner.ID) { + var err error + owner, err = r.store.RequestRuntimeCleanup(ctx, owner) + if err != nil { + return err + } + } + if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { + var err error + owner, err = r.store.RequestRuntimeCleanup(ctx, owner) + if err != nil { + return err + } + delete(r.connections, owner.ID) + if r.initializing != nil && r.initializing.owner.ID == owner.ID { + r.initializing = nil + } + } else if err := r.observeConnection(ctx, owner); err != nil { + return err + } + provider := r.config.Providers[owner.ProviderKey] + if provider == nil { + return sandbox.ErrInvalid + } + if err := r.store.CheckExecutionOwnership(ctx); err != nil { + return err + } + info, err := provider.GetInfo(ctx, runtimeReference(owner)) + if err != nil && !errors.Is(err, sandbox.ErrNotFound) { + return err + } + running := err == nil && info.Reference == runtimeReference(owner) && info.ProviderID != "" && info.State == "running" + if err == nil && info.Reference != runtimeReference(owner) { + return sandbox.ErrOwnership + } + if running && info.BootstrapComplete && !owner.CreateSettled { + // Only the adapter can qualify completion of its bootstrap writes. + owner, err = r.store.SettleRuntimeCreation(ctx, owner) + if err != nil { + return err + } + } + if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { + owner, err = r.store.RequestRuntimeCleanup(ctx, owner) + if err != nil { + return err + } + if err := r.store.CheckExecutionOwnership(ctx); err != nil { + return err + } + if err := provider.Kill(ctx, runtimeReference(owner)); err != nil { + return err + } + if !owner.CreateSettled { + return nil // Keep scanning unknown creation; absence is not a final receipt. + } + _, err = r.store.ReleaseRuntimeAllocation(ctx, owner) + return err + } + // A stopped or missing container does not authorize destroying retained + // workspace/history. Preserve it until explicit cleanup or actual expiry. + if !running || !info.BootstrapComplete { + return nil + } + owner, err = r.store.ObserveRuntimeRunning(ctx, owner) + if err != nil { + return err + } + if err := r.observeInitialization(ctx, owner); err != nil { + return err + } + if peer, err := r.registry.LookupDevice(owner.DeviceID); err != nil || peer.IsClosed() { + return nil + } + renewed, err := provider.Renew(ctx, runtimeReference(owner)) + if err != nil { + return err + } + if renewed.Reference != runtimeReference(owner) || renewed.State != "running" || !renewed.BootstrapComplete { + return sandbox.ErrOwnership + } + _, err = r.store.KeepRuntimeAllocation(ctx, owner) + return err +} + +func runtimeReference(owner store.RuntimeAllocation) sandbox.Reference { + return sandbox.Reference{TenantID: owner.TenantID, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} +} + +func (w *Worker) runManagedRuntimes(ctx context.Context) error { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + if err := w.ReconcileManagedRuntimes(ctx); err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/services/agents-api/internal/execution/runtime_pending.go b/services/agents-api/internal/execution/runtime_pending.go new file mode 100644 index 000000000..b66078333 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_pending.go @@ -0,0 +1,41 @@ +package execution + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// provisionPending shares the existing lifecycle owner and serial gate. This +// also recovers idle Session creation interrupted after its database commit. +func (r *runtimeLifecycle) provisionPending(ctx context.Context) error { + if r.config.DefaultProvider == "" && len(r.config.EngineProviders) == 0 { + return nil + } + rows, err := r.store.ListUnallocatedHostedEnvironments(ctx, r.pendingCursor) + if err != nil { + return err + } + if len(rows) == 0 { + r.pendingCursor = "" + return nil + } + for _, environment := range rows { + r.pendingCursor = environment.ID + provider := r.config.ProviderForEngine(environment.Engine) + if provider == "" { + continue + } + operation, cancel := context.WithTimeout(ctx, 30*time.Second) + _, err := r.provision(operation, environment.TenantID, environment.ID, provider) + cancel() + if err != nil { + if ownership := r.store.CheckExecutionOwnership(ctx); ownership != nil { + return ownership + } + log.Ctx(ctx).Warn("managed Runtime bootstrap incomplete", "environment_id", environment.ID) + } + } + return nil +} diff --git a/services/agents-api/internal/execution/runtime_provider_selection.go b/services/agents-api/internal/execution/runtime_provider_selection.go new file mode 100644 index 000000000..b893f39f4 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_provider_selection.go @@ -0,0 +1,8 @@ +package execution + +func (r RuntimeProviders) ProviderForEngine(kind string) string { + if len(r.EngineProviders) == 0 { + return r.DefaultProvider + } + return r.EngineProviders[kind] +} diff --git a/services/agents-api/internal/execution/runtime_provider_selection_test.go b/services/agents-api/internal/execution/runtime_provider_selection_test.go new file mode 100644 index 000000000..6a757c5a1 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_provider_selection_test.go @@ -0,0 +1,13 @@ +package execution + +import "testing" + +func TestRuntimeProviderSelectionDoesNotFallBack(t *testing.T) { + config := RuntimeProviders{DefaultProvider: "original", EngineProviders: map[string]string{"codex": "codex-image", "mcode": "mcode-image"}} + if config.ProviderForEngine("mcode") != "mcode-image" || config.ProviderForEngine("claude_sdk") != "" { + t.Fatal("selected an unrelated runtime") + } + if (RuntimeProviders{DefaultProvider: "original"}).ProviderForEngine("codex") != "original" { + t.Fatal("changed legacy default") + } +} diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go new file mode 100644 index 000000000..ed61ec3ae --- /dev/null +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -0,0 +1,89 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// runtimeSetupOperation is the packaged initializer's confidential stdin contract. +// Public templates and native harness configuration never cross this boundary. +type runtimeSetupOperation struct { + Skill *store.InlineSkill `json:"-"` + Name string `json:"name,omitempty"` + Files []agentskill.File `json:"files,omitempty"` + Version int `json:"version"` + Action string `json:"action"` + Network string `json:"network,omitempty"` + Env map[string]string `json:"env"` + Packages []string `json:"packages,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { + if setup.Empty() { + return nil + } + env := setup.Env + if env == nil { + env = map[string]string{} + } + result := []runtimeSetupOperation{{Version: 1, Action: "configure", Env: env}} + for i := range setup.Skills { + result = append(result, runtimeSetupOperation{Version: 1, Action: "skill", Skill: &setup.Skills[i]}) + } + // The public network policy applies after setup completes. Provisioning uses + // the isolated initializer's network; adapters enforce the runtime policy. + const network = "enabled" + if len(setup.Packages.System) > 0 { + result = append(result, runtimeSetupOperation{Version: 1, Action: "system", Network: network, Packages: setup.Packages.System}) + } + if len(setup.Packages.NPM) > 0 { + result = append(result, runtimeSetupOperation{Version: 1, Action: "npm", Network: network, Packages: setup.Packages.NPM}) + } + if len(setup.Packages.Python) > 0 { + result = append(result, runtimeSetupOperation{Version: 1, Action: "python", Network: network, Packages: setup.Packages.Python}) + } + for _, command := range setup.Commands { + cwd := command.CWD + if cwd == "" { + cwd = "/workspace" + } + result = append(result, runtimeSetupOperation{Version: 1, Action: "setup", Network: network, Command: command.Command, CWD: cwd}) + } + return result +} + +func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, operation runtimeSetupOperation) error { + if provider == nil { + return sandbox.ErrInvalid + } + if operation.Skill != nil { + files, err := agentskill.Read(operation.Skill.Archive, operation.Skill.Metadata) + if err != nil { + return err + } + operation.Name, operation.Files = operation.Skill.Metadata.Name, files + } + input, err := json.Marshal(operation) + if err != nil { + return err + } + result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: []string{"/usr/bin/python3", "-I", "-S", "/usr/local/bin/agents-api-runtime-initialize"}, Stdin: input}) + if err != nil { + return err + } + var receipt struct { + Version int `json:"version"` + Outcome string `json:"outcome"` + } + if result.ExitCode != 0 || result.Stderr != "" || json.Unmarshal([]byte(result.Stdout), &receipt) != nil || receipt.Version != 1 || receipt.Outcome != "completed" { + return errors.New("environment initialization operation unconfirmed") + } + return nil +} diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go new file mode 100644 index 000000000..b6af76f22 --- /dev/null +++ b/services/agents-api/internal/execution/support.go @@ -0,0 +1,123 @@ +package execution + +import ( + "encoding/json" + "errors" + "path" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// ValidateSessionConfiguration checks engine placement and configuration before persistence. +func (p Policy) ValidateSessionConfiguration(engine string, configuration json.RawMessage) error { + var snapshot Snapshot + if json.Unmarshal(configuration, &snapshot) != nil { + return store.ErrInvalidInput + } + profile, ok := p.Engines.Lookup(engine) + if !ok || (snapshot.Environment != nil && !profile.Accepts(snapshot.Environment.Type)) { + return store.ErrInvalidInput + } + _, err := p.mcpCredentialBindings(engine, snapshot) + if err != nil { + return err + } + if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" { + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !path.IsAbs(snapshot.Environment.WorkspaceDirectory) || strings.ContainsAny(snapshot.Environment.WorkspaceDirectory, "\x00\r\n\\") || len(snapshot.Environment.CapabilityDirectories) != 0 { + return store.ErrInvalidInput + } + } + if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { + // Only this placement/engine combination has current native qualification. + // Runtime capability checks still apply before any execution claim. + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { + return store.ErrInvalidInput + } + configuration, err := json.Marshal(snapshot.Environment) + if err != nil || !LocalWorkspaceConfiguration(configuration) { + return store.ErrInvalidInput + } + } + return validateProfileConfiguration(profile, snapshot) +} + +func (p Policy) canAdmitInputs(engine string, configuration json.RawMessage) bool { + var snapshot Snapshot + if json.Unmarshal(configuration, &snapshot) != nil || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil { + return false + } + return p.ValidateSessionConfiguration(engine, configuration) == nil +} + +func (p Policy) validateEngineInputs(engine string, inputs []store.Input) error { + profile, ok := p.Engines.Lookup(engine) + if !ok { + return store.ErrInvalidInput + } + return validateProfileInputs(profile, inputs) +} + +// engineCapabilities is shared by device selection and the final preclaim check. +// Capability bits describe the adapter; supported values still depend on its profile. +func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapshot Snapshot) (device.KindCapabilities, error) { + fail := func(message string) (device.KindCapabilities, error) { + return device.KindCapabilities{}, errors.New(message) + } + profile, ok := p.Engines.Lookup(engine) + if !ok || (snapshot.Environment != nil && !profile.Accepts(snapshot.Environment.Type)) { + return fail("execution engine placement is not supported") + } + if profile.ValidateConfiguration != nil { + if err := validateProfileConfiguration(profile, snapshot); err != nil { + return device.KindCapabilities{}, err + } + } + info, found, known := peer.AgentKindStatus(engine) + caps := info.Capabilities + if !known || !found || !info.Available || !caps.Streaming || !caps.Steering || !caps.DurableTurns || !caps.DurableInputReceipts { + return fail("device must advertise streaming, steering and durable turns for this engine") + } + if !caps.ExecutionControls { + return fail("device must advertise execution_controls") + } + if profile.WebSearchControl && !caps.WebSearchControl { + return fail("device must advertise web_search_control") + } + if profile.TextVerbosity && !caps.TextVerbosity { + return fail("device must advertise text_verbosity") + } + if !caps.ToolObservations { + return fail("device must advertise tool_observations") + } + if !snapshot.Agent.MultiAgent.Enabled && !caps.SubagentControl { + return fail("device must advertise subagent_control") + } + functions, mcp, err := executionTools(snapshot.Agent.Tools) + if err != nil { + return fail("invalid execution tool configuration") + } + if len(functions) > 0 && !caps.FunctionTools { + return fail("device must advertise function_tools") + } + if _, err := p.mcpExecutionCredentials(engine, snapshot, mcp, caps); err != nil { + return device.KindCapabilities{}, err + } + if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" && (!caps.Preparation || !caps.RemoteEnvironment) { + return fail("device must advertise preparation and remote_environment") + } + if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { + if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { + return fail("device must advertise local preparation, workspace reads and output export") + } + if (snapshot.Environment.Network == nil || snapshot.Environment.Network.Access != "disabled") && !caps.LocalEnvironmentNetworkPolicy { + return fail("device must advertise local_environment_network_policy") + } + } + if snapshot.Environment != nil && snapshot.Environment.Type == "none" && !caps.EnvironmentNone { + return fail("device must advertise environment_none") + } + return caps, nil +} diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go new file mode 100644 index 000000000..42586ebd2 --- /dev/null +++ b/services/agents-api/internal/execution/worker.go @@ -0,0 +1,273 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Worker owns queued work; the database lease excludes a second execution service. +type Worker struct { + dispatcher *Dispatcher + admission *store.Store + lease *store.ExecutionLease + directoryReads chan directoryReadRequest + fileWrites chan fileWriteRequest + stopped chan struct{} + stopOnce sync.Once + runtimes *runtimeLifecycle +} + +func StartWorker(ctx context.Context, dispatcher *Dispatcher) (*Worker, error) { + lease, err := dispatcher.Store.AcquireExecutionLease(ctx) + if err != nil { + return nil, err + } + owned := *dispatcher + owned.Store = lease.Store() + worker := &Worker{dispatcher: &owned, admission: dispatcher.Store, lease: lease, directoryReads: make(chan directoryReadRequest), fileWrites: make(chan fileWriteRequest), stopped: make(chan struct{})} + worker.runtimes, err = newRuntimeLifecycle(owned.Store, owned.Registry, owned.ManagedRuntimes) + if err != nil { + _ = lease.Close(context.Background()) + return nil, err + } + if err := owned.Store.ReconcileEnvironmentConnections(ctx); err != nil { + if worker.runtimes != nil { + worker.runtimes.stop() + } + _ = lease.Close(context.Background()) + return nil, err + } + if err := worker.reconcile(ctx); err != nil { + if worker.runtimes != nil { + worker.runtimes.stop() + } + _ = lease.Close(context.Background()) + return nil, err + } + return worker, nil +} + +// CheckOwnership checks the same database lease used for execution writes. +func (w *Worker) CheckOwnership(ctx context.Context) error { return w.lease.Ping(ctx) } + +func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, inputs []store.Input) ([]store.InputReceipt, error) { + value, err := w.admission.GetSession(ctx, tenant, session) + if err != nil { + return nil, err + } + if preparedEnvironmentConfiguration(value.Configuration) { + return w.submitEnvironmentInputs(ctx, value, key, inputs) + } + if !w.dispatcher.canAdmitInputs(value.Engine, value.Configuration) { + return nil, store.ErrInvalidInput + } + if err := w.dispatcher.validateEngineInputs(value.Engine, inputs); err != nil { + return nil, err + } + return w.admission.SubmitInputs(ctx, tenant, session, key, inputs) +} + +// CreateSession validates execution support before reserving or admitting initial work. +func (w *Worker) CreateSession(ctx context.Context, tenant string, input store.CreateSessionInput) (store.Session, error) { + if err := w.validateCreation(ctx, input); err != nil { + return store.Session{}, err + } + return w.admission.CreateSession(ctx, tenant, input) +} + +// CreateSessionStream applies the same execution admission before creating a stream. +func (w *Worker) CreateSessionStream(ctx context.Context, tenant string, input store.CreateSessionInput) (store.SessionCreation, error) { + if err := w.validateCreation(ctx, input); err != nil { + return store.SessionCreation{}, err + } + return w.admission.CreateSessionStream(ctx, tenant, input) +} + +// Run retains queued work across restarts, but never replays an uncertain claim. +func (w *Worker) Run(ctx context.Context) error { + defer w.stopOnce.Do(func() { close(w.stopped) }) + ctx, cancel := context.WithCancel(ctx) + var running sync.WaitGroup + defer func() { + cancel() + if w.runtimes != nil { + w.runtimes.stop() + } + running.Wait() + if w.runtimes != nil { + // Drain an external provisioning caller before releasing the writer lease. + w.runtimes.gate <- struct{}{} + <-w.runtimes.gate + } + if w.dispatcher.CloseEnvironmentConnections != nil { + w.dispatcher.CloseEnvironmentConnections() + } + closeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + _ = w.lease.Close(closeCtx) + }() + active := make(map[string]bool) + type completion struct { + id string + err error + } + completed := make(chan completion, 4) + lifecycleDone := make(chan error, 1) + if w.runtimes != nil { + running.Add(1) + go func() { + defer running.Done() + lifecycleDone <- w.runManagedRuntimes(ctx) + }() + } + type readCompletion struct { + id string + request directoryReadRequest + result directoryReadResult + } + type writeCompletion struct { + request fileWriteRequest + result fileWriteResult + } + writesCompleted := make(chan writeCompletion, 4) + readsCompleted := make(chan readCompletion, 4) + reads := 0 + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + schedule := workerSchedule{} + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-lifecycleDone: + return err + case request := <-w.fileWrites: + if request.ctx.Err() != nil || active[request.environment.SessionID] || len(active) == 4 { + request.result <- fileWriteResult{err: ErrExecutionUnavailable} + continue + } + active[request.environment.SessionID] = true + running.Add(1) + go func() { + defer running.Done() + writesCompleted <- writeCompletion{request: request, result: w.runFileWrite(ctx, request)} + }() + case write := <-writesCompleted: + delete(active, write.request.environment.SessionID) + write.request.result <- write.result + case request := <-w.directoryReads: + if request.ctx.Err() != nil || reads == 4 || (!active[request.environment.SessionID] && len(active) == 4) { + request.reply(directoryReadResult{err: ErrExecutionUnavailable}) + continue + } + reserved := !active[request.environment.SessionID] + if reserved { + active[request.environment.SessionID] = true + } + reads++ + running.Add(1) + go func() { + defer running.Done() + result := w.runDirectoryRead(ctx, request, reserved) + id := "" + if reserved { + id = request.environment.SessionID + } + readsCompleted <- readCompletion{id: id, request: request, result: result} + }() + case read := <-readsCompleted: + reads-- + if read.id != "" { + delete(active, read.id) + } + read.request.reply(read.result) + case result := <-completed: + delete(active, result.id) + if result.err != nil { + return result.err + } + case <-ticker.C: + check, stop := context.WithTimeout(ctx, 5*time.Second) + err := w.lease.Ping(check) + stop() + if err != nil { + return err + } + if _, err := w.dispatcher.Store.ExpireEnvironmentInputs(ctx); err != nil { + return err + } + if len(active) == 4 { + continue + } + devices := w.dispatcher.Registry.Devices() + if len(devices) == 0 { + continue + } + work, err := schedule.selectWork(ctx, w, devices, active) + if err != nil { + return err + } + for _, item := range work { + running.Add(1) + go func() { + defer running.Done() + var err error + if item.reservationID != "" { + err = w.runEnvironmentInput(ctx, item) + } else { + err = w.runClaim(ctx, item.ExecutionWork) + } + completed <- completion{id: item.SessionID, err: err} + }() + } + } + } +} + +func (w *Worker) reconcile(ctx context.Context) error { + cursor := "" + for { + work, err := w.dispatcher.Store.ListExecutionWork(ctx, cursor, []string{store.TurnInProgress, store.TurnWaiting}, nil) + if err != nil { + return err + } + if len(work) == 0 { + return nil + } + for _, item := range work { + _, err := w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: item.Status, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) + if err != nil && !errors.Is(err, store.ErrTurnConflict) { + return err + } + cursor = item.TurnID + } + } +} + +func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { + _, err := w.dispatcher.Run(ctx, item.TenantID, item.SessionID, item.TurnID) + if err == nil || errors.Is(err, store.ErrTurnConflict) { + return nil + } + finish, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + turn, err := w.dispatcher.Store.GetTurn(finish, item.TenantID, item.SessionID, item.TurnID) + if err != nil { + return err + } + if turn.Status == store.TurnCompleted || turn.Status == store.TurnFailed || turn.Status == store.TurnCancelled { + return nil + } + log.Ctx(ctx).Error("agents-api dispatch did not complete", "turn_id", item.TurnID) + _, err = w.dispatcher.Store.TransitionTurn(finish, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: turn.Status, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_unavailable"}`)}) + if errors.Is(err, store.ErrTurnConflict) { + return nil + } + return err +} diff --git a/services/agents-api/internal/execution/worker_device.go b/services/agents-api/internal/execution/worker_device.go new file mode 100644 index 000000000..c4cb1ce9d --- /dev/null +++ b/services/agents-api/internal/execution/worker_device.go @@ -0,0 +1,89 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func (w *Worker) bind(ctx context.Context, item store.ExecutionWork) (bool, error) { + ready, err := w.bindDevice(ctx, item.TenantID, item.SessionID) + if !errors.Is(err, store.ErrDeviceBindingConflict) { + return ready, err + } + _, err = w.dispatcher.Store.TransitionTurn(ctx, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_device_unavailable"}`)}) + if errors.Is(err, store.ErrTurnConflict) { + err = nil + } + return false, err +} + +func (w *Worker) bindDevice(ctx context.Context, tenantID, sessionID string) (bool, error) { + session, err := w.dispatcher.Store.GetSession(ctx, tenantID, sessionID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + var snapshot Snapshot + if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { + return false, err + } + if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" && w.dispatcher.EnvironmentConnection == nil { + return false, nil + } + return w.bindSessionDevice(ctx, session, func(id string) bool { return w.ready(id, session.Engine, snapshot) }) +} + +func (w *Worker) bindSessionDevice(ctx context.Context, session store.Session, ready func(string) bool) (bool, error) { + var snapshot Snapshot + if json.Unmarshal(session.Configuration, &snapshot) != nil { + return false, store.ErrInvalidInput + } + if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { + environment, err := w.dispatcher.Store.GetSessionEnvironment(ctx, session.TenantID, session.ID) + if err != nil { + return false, err + } + placement, err := parseEnvironmentPlacement(environment.Configuration) + if err != nil { + return false, nil + } + bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + return err == nil && environmentDeviceMatches(session, environment, bound, placement) && ready(bound.ID), err + } + bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) + if err == nil { + return bound.EnvironmentID == "" && ready(bound.ID), nil + } + if !errors.Is(err, store.ErrNotFound) { + return false, err + } + devices, err := w.dispatcher.Store.ListExecutionDevices(ctx, session.TenantID) + if err != nil { + return false, err + } + for _, device := range devices { + if !ready(device.ID) { + continue + } + err := w.dispatcher.Store.BindSessionDevice(ctx, session.TenantID, session.ID, device.ID) + return err == nil, err + } + return false, nil +} + +func (w *Worker) ready(deviceID, engine string, snapshot Snapshot) bool { + peer, err := w.dispatcher.Registry.LookupDevice(deviceID) + if err != nil { + return false + } + _, err = w.dispatcher.engineCapabilities(peer, engine, snapshot) + return err == nil +} diff --git a/services/agents-api/internal/execution/worker_schedule.go b/services/agents-api/internal/execution/worker_schedule.go new file mode 100644 index 000000000..05717a432 --- /dev/null +++ b/services/agents-api/internal/execution/worker_schedule.go @@ -0,0 +1,96 @@ +package execution + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type workerSchedule struct { + turnCursor, environmentCursor string + nextEnvironmentScan time.Time + environmentFirst bool +} + +type scheduledWork struct { + store.ExecutionWork + reservationID string +} + +func (s *workerSchedule) selectWork(ctx context.Context, w *Worker, devices []string, active map[string]bool) ([]scheduledWork, error) { + turns, err := w.dispatcher.Store.ListExecutionWork(ctx, s.turnCursor, []string{store.TurnQueued}, devices) + if err != nil { + return nil, err + } + if len(turns) == 0 { + s.turnCursor = "" + } + var environments []store.EnvironmentInputWork + if !time.Now().Before(s.nextEnvironmentScan) { + environments, err = w.dispatcher.Store.ListEnvironmentInputWork(ctx, s.environmentCursor, devices) + if err != nil { + return nil, err + } + s.nextEnvironmentScan = time.Now().Add(5 * time.Second) + if len(environments) == 0 { + s.environmentCursor = "" + } + } + var selected []scheduledWork + for len(turns)+len(environments) > 0 && len(active) < 4 { + var item scheduledWork + if len(environments) > 0 && (s.environmentFirst || len(turns) == 0) { + value := environments[0] + environments = environments[1:] + s.environmentCursor = value.ReservationID + item = scheduledWork{ExecutionWork: store.ExecutionWork{TenantID: value.TenantID, SessionID: value.SessionID}, reservationID: value.ReservationID} + s.environmentFirst = false + } else { + item.ExecutionWork = turns[0] + turns = turns[1:] + s.turnCursor = item.TurnID + s.environmentFirst = true + } + if active[item.SessionID] { + continue + } + var ready bool + if item.reservationID == "" { + ready, err = w.bind(ctx, item.ExecutionWork) + } else { + ready, err = w.bindDevice(ctx, item.TenantID, item.SessionID) + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrDeviceBindingConflict) { + continue + } + } + if err != nil { + return nil, err + } + if !ready { + continue + } + active[item.SessionID] = true + selected = append(selected, item) + } + return selected, nil +} + +func (w *Worker) runEnvironmentInput(ctx context.Context, item scheduledWork) error { + run, err := w.dispatcher.RunEnvironmentInput(ctx, item.TenantID, item.SessionID, item.reservationID) + if err == nil { + return nil + } + if run.Reservation.State == store.EnvironmentInputAdmitted { + return err + } + if run.Reservation.State != store.EnvironmentInputPending && !errors.Is(err, store.ErrNotFound) { + return err + } + if ctx.Err() == nil && !errors.Is(err, store.ErrNotFound) { + log.Ctx(ctx).Warn("agents-api environment preparation did not complete", "reservation_id", item.reservationID) + } + return nil +} diff --git a/services/agents-api/internal/executor/codex/config.go b/services/agents-api/internal/executor/codex/config.go new file mode 100644 index 000000000..12473f607 --- /dev/null +++ b/services/agents-api/internal/executor/codex/config.go @@ -0,0 +1,58 @@ +// Package codex implements the native executor registry, separate from the public Agents API. +package codex + +import ( + "context" + "errors" + "net" + "net/url" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type EnvironmentStore interface { + GetEnvironment(context.Context, string, string) (store.Environment, error) + AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) +} + +// ScopedKey binds a purpose-specific native transport credential to one Environment. +type ScopedKey struct { + TokenSHA256 string `json:"token_sha256"` + TenantID string `json:"tenant_id"` + EnvironmentID string `json:"environment_id"` +} + +type Config struct { + Store EnvironmentStore + CheckOwnership func(context.Context) error + // Callbacks must honor their context and use the current execution writer. + // They run outside the registry mutex and must not call Registry.Close. + ReplaceConnection func(context.Context, string, string, string) error + ObserveConnection func(context.Context, string, string, string, int64, bool) error + PublicURL string +} + +func validateConfig(c Config) (string, error) { + if c.Store == nil || c.CheckOwnership == nil || c.ReplaceConnection == nil || c.ObserveConnection == nil { + return "", errors.New("executor registry requires Store, execution ownership and connection lifecycle callbacks") + } + u, err := url.Parse(c.PublicURL) + if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return "", errors.New("executor URL must be an absolute origin without credentials") + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + ip := net.ParseIP(u.Hostname()) + if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { + return "", errors.New("executor HTTP is allowed only on loopback") + } + u.Scheme = "ws" + default: + return "", errors.New("executor URL must use HTTPS or loopback HTTP") + } + u.Path, u.RawPath = "", "" + return strings.TrimRight(u.String(), "/"), nil +} diff --git a/services/agents-api/internal/executor/codex/credentials.go b/services/agents-api/internal/executor/codex/credentials.go new file mode 100644 index 000000000..7895bc0a7 --- /dev/null +++ b/services/agents-api/internal/executor/codex/credentials.go @@ -0,0 +1,84 @@ +package codex + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func (r *Registry) executorCredential(w http.ResponseWriter, req *http.Request, environment string) (ScopedKey, bool) { + // Registration maps and durable ownership must use the same exact identity. + id, err := uuid.Parse(environment) + if err != nil || id.String() != environment { + writeError(w, http.StatusUnauthorized) + return ScopedKey{}, false + } + hash, ok := credentialHash(req) + if !ok { + writeError(w, http.StatusUnauthorized) + return ScopedKey{}, false + } + r.mu.Lock() + _, wrongPurpose := r.harnessKeys[hash] + r.mu.Unlock() + if wrongPurpose { + writeError(w, http.StatusUnauthorized) + return ScopedKey{}, false + } + ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) + defer cancel() + digest := hex.EncodeToString(hash[:]) + tenant, err := r.source.AuthenticateEnvironmentExecutor(ctx, environment, digest) + if !writeCredentialError(w, err) { + return ScopedKey{}, false + } + return ScopedKey{TenantID: tenant, EnvironmentID: environment, TokenSHA256: digest}, true +} + +func credentialHash(req *http.Request) ([32]byte, bool) { + parts := strings.Fields(req.Header.Get("Authorization")) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return [32]byte{}, false + } + return sha256.Sum256([]byte(parts[1])), true +} + +func (r *Registry) currentExecutor(ctx context.Context, key ScopedKey) error { + tenant, err := r.source.AuthenticateEnvironmentExecutor(ctx, key.EnvironmentID, key.TokenSHA256) + if err == nil && tenant != key.TenantID { + return store.ErrNotFound + } + return err +} + +func (r *Registry) checkCurrentExecutor(w http.ResponseWriter, req *http.Request, key ScopedKey) bool { + ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) + defer cancel() + return writeCredentialError(w, r.currentExecutor(ctx, key)) +} + +func (r *Registry) executorAuthorized(ctx context.Context, key ScopedKey) error { + if err := r.authorized(ctx, key); err != nil { + return err + } + return r.currentExecutor(ctx, key) +} + +func writeCredentialError(w http.ResponseWriter, err error) bool { + if err == nil { + return true + } + status := http.StatusServiceUnavailable + if errors.Is(err, store.ErrNotFound) { + status = http.StatusUnauthorized + } + writeError(w, status) + return false +} diff --git a/services/agents-api/internal/executor/codex/credentials_test.go b/services/agents-api/internal/executor/codex/credentials_test.go new file mode 100644 index 000000000..7365a4a2b --- /dev/null +++ b/services/agents-api/internal/executor/codex/credentials_test.go @@ -0,0 +1,59 @@ +package codex + +import ( + "context" + "sync" + "testing" +) + +type delayedCredential struct { + EnvironmentStore + hash string + once sync.Once + observed chan struct{} + release chan struct{} +} + +func (s *delayedCredential) AuthenticateEnvironmentExecutor(ctx context.Context, environment, hash string) (string, error) { + tenant, err := s.EnvironmentStore.AuthenticateEnvironmentExecutor(ctx, environment, hash) + if hash == s.hash { + s.once.Do(func() { + close(s.observed) + select { + case <-s.release: + case <-ctx.Done(): + } + }) + } + return tenant, err +} + +func TestRotatedCredentialFencesDelayedRegistration(t *testing.T) { + f := newFixture(t) + key := f.keys[0] + source := &delayedCredential{EnvironmentStore: f.source, hash: key.TokenSHA256, observed: make(chan struct{}), release: make(chan struct{})} + f.registry.source = source + done := make(chan struct{}) + go func() { + defer close(done) + f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 401) + }() + <-source.observed + next := "new-executor-credential" + f.source.mu.Lock() + f.source.keys[key.EnvironmentID] = digest(next) + f.source.mu.Unlock() + reg := f.register(t, key.EnvironmentID, next, nativeRequest(), 200) + socket := dial(t, reg.URL) + defer socket.Close() + close(source.release) + <-done + awaitPresence(t, f, key.EnvironmentID, true) + f.registry.mu.Lock() + current := f.registry.registrations[key.EnvironmentID].id + f.registry.mu.Unlock() + if current != reg.ExecutorRegistrationID { + t.Fatal("delayed revoked request replaced the current registration") + } + nativePOST(t, f, key.EnvironmentID, "validate", f.tokens[0], ValidationRequest{}, 401, nil) +} diff --git a/services/agents-api/internal/executor/codex/harness.go b/services/agents-api/internal/executor/codex/harness.go new file mode 100644 index 000000000..066726ae5 --- /dev/null +++ b/services/agents-api/internal/executor/codex/harness.go @@ -0,0 +1,185 @@ +package codex + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "time" +) + +const maxHarnessGrants = 32 + +type harnessGrant struct { + credential *harnessCredential + publicKey PublicKey + authorization [32]byte + expires time.Time + executor *connection + harness *connection + validated bool +} + +// @Summary Authorize an execution-owned harness key without disrupting an existing connection +// @Tags Native executor registry +// @Accept json +// @Produce json +// @Param environment path string true "Environment ID" +// @Param request body ConnectRequest true "Native harness public key" +// @Success 200 {object} ConnectResponse +// @Failure 400,401,404,429,503 {object} RegistryError +// @Router /cloud/environment/{environment}/connect [post] +func (r *Registry) connect(w http.ResponseWriter, req *http.Request) { + environment := req.PathValue("environment") + credential, ok := r.harnessCredential(req, environment) + if !ok { + writeError(w, http.StatusUnauthorized) + return + } + if !r.check(w, req, credential.key) { + return + } + var body ConnectRequest + if !decodeRequest(w, req, &body) { + return + } + if !body.HarnessPublicKey.valid() { + writeError(w, http.StatusBadRequest) + return + } + ticket, err := capability() + if err != nil { + writeError(w, http.StatusServiceUnavailable) + return + } + authorization, err := capability() + if err != nil { + writeError(w, http.StatusServiceUnavailable) + return + } + r.mu.Lock() + if !r.harnessCredentialActiveLocked(credential) { + r.mu.Unlock() + writeError(w, http.StatusUnauthorized) + return + } + reg := r.registrations[environment] + if r.closed || reg == nil || reg.socket == nil { + r.mu.Unlock() + writeError(w, http.StatusServiceUnavailable) + return + } + now := time.Now() + for hash, grant := range reg.grants { + if grant.executor != reg.socket || (grant.harness == nil && !now.Before(grant.expires)) { + delete(reg.grants, hash) + } + } + if len(reg.grants) >= maxHarnessGrants { + r.mu.Unlock() + writeError(w, http.StatusTooManyRequests) + return + } + reg.grants[sha256.Sum256([]byte(ticket))] = &harnessGrant{credential: credential, publicKey: body.HarnessPublicKey, authorization: sha256.Sum256([]byte(authorization)), expires: now.Add(ticketLifetime), executor: reg.socket} + response := ConnectResponse{ + RegistrationResponse: RegistrationResponse{EnvironmentID: environment, ExecutorRegistrationID: reg.id, SecurityProfile: securityProfile, URL: r.publicWS + "/cloud/environment/" + environment + "/harness/" + reg.id + "?ticket=" + ticket}, + ExecutorPublicKey: reg.publicKey, HarnessKeyAuthorization: authorization, + } + r.mu.Unlock() + writeJSON(w, http.StatusOK, response) +} + +// @Summary Validate a connected harness key proved by the native Noise handshake +// @Tags Native executor registry +// @Accept json +// @Produce json +// @Param environment path string true "Environment ID" +// @Param request body ValidationRequest true "Native harness key authorization" +// @Success 200 {object} ValidationResponse +// @Failure 400,401,404,503 {object} RegistryError +// @Router /cloud/environment/{environment}/validate [post] +func (r *Registry) validate(w http.ResponseWriter, req *http.Request) { + environment := req.PathValue("environment") + key, ok := r.executorCredential(w, req, environment) + if !ok { + return + } + if !r.check(w, req, key) { + return + } + var body ValidationRequest + if !decodeRequest(w, req, &body) { + return + } + if !body.HarnessPublicKey.valid() { + writeError(w, http.StatusBadRequest) + return + } + authorization := sha256.Sum256([]byte(body.HarnessKeyAuthorization)) + valid := false + r.mu.Lock() + reg := r.registrations[environment] + if !r.closed && reg != nil && reg.id == body.ExecutorRegistrationID && reg.key.TokenSHA256 == key.TokenSHA256 && reg.socket != nil { + for _, grant := range reg.grants { + if r.harnessCredentialActiveLocked(grant.credential) && grant.executor == reg.socket && grant.harness != nil && grant.harness == reg.socket.peer && !grant.validated && time.Now().Before(grant.expires) && grant.publicKey == body.HarnessPublicKey && subtle.ConstantTimeCompare(authorization[:], grant.authorization[:]) == 1 { + grant.validated = true + valid = true + break + } + } + } + r.mu.Unlock() + writeJSON(w, http.StatusOK, ValidationResponse{Valid: valid}) +} + +// @Summary Attach one harness using a short-lived connection capability +// @Tags Native executor registry +// @Param environment path string true "Environment ID" +// @Param registration path string true "Registration ID" +// @Param ticket query string true "Private connection capability" +// @Success 101 {string} string "WebSocket upgrade" +// @Failure 401,404,409,503 {object} RegistryError +// @Router /cloud/environment/{environment}/harness/{registration} [get] +func (r *Registry) connectHarness(w http.ResponseWriter, req *http.Request) { + environment, id := req.PathValue("environment"), req.PathValue("registration") + ticket := sha256.Sum256([]byte(req.URL.Query().Get("ticket"))) + r.mu.Lock() + reg := r.registrations[environment] + var grant *harnessGrant + if reg != nil { + grant = reg.grants[ticket] + } + valid := r.harnessAttachable(reg, grant, id) + r.mu.Unlock() + if !valid { + writeError(w, http.StatusUnauthorized) + return + } + if !r.check(w, req, grant.credential.key) || !r.checkCurrentExecutor(w, req, reg.key) { + return + } + r.mu.Lock() + if r.registrations[environment] != reg || !r.harnessAttachable(reg, grant, id) { + r.mu.Unlock() + writeError(w, http.StatusUnauthorized) + return + } + if reg.socket.peer != nil { + r.mu.Unlock() + writeError(w, http.StatusConflict) + return + } + socket, err := nativeUpgrader.Upgrade(w, req, nil) + if err != nil { + r.mu.Unlock() + return + } + c := &connection{socket: socket, peer: reg.socket} + reg.socket.peer = c + grant.harness = c + r.mu.Unlock() + r.serveConnection(environment, reg, c) +} + +func (r *Registry) harnessAttachable(reg *registration, grant *harnessGrant, id string) bool { + return !r.closed && reg != nil && reg.id == id && grant != nil && r.harnessCredentialActiveLocked(grant.credential) && grant.executor == reg.socket && grant.harness == nil && time.Now().Before(grant.expires) +} diff --git a/services/agents-api/internal/executor/codex/harness_credentials.go b/services/agents-api/internal/executor/codex/harness_credentials.go new file mode 100644 index 000000000..bc5a2df91 --- /dev/null +++ b/services/agents-api/internal/executor/codex/harness_credentials.go @@ -0,0 +1,104 @@ +package codex + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +const maxHarnessCredentials = 32 + +var ErrHarnessCapacity = errors.New("native harness credential capacity reached") + +type harnessCredential struct { + key ScopedKey + hash [32]byte + owner context.Context + stop func() bool +} + +// IssueHarnessCredential authorizes one execution owner, spanning preparation and its Run. +// The caller must release ownership after execution; cancellation also revokes it. +// Only the returned bearer is secret. The registry retains its digest, never the bearer. +func (r *Registry) IssueHarnessCredential(owner context.Context, tenant, environment string) (string, func(), error) { + tenantID, tenantErr := uuid.Parse(tenant) + environmentID, environmentErr := uuid.Parse(environment) + if tenantErr != nil || environmentErr != nil || tenantID == uuid.Nil || environmentID == uuid.Nil { + return "", nil, store.ErrInvalidInput + } + key := ScopedKey{TenantID: tenantID.String(), EnvironmentID: environmentID.String()} + if err := r.authorized(owner, key); err != nil { + return "", nil, err + } + token, err := capability() + if err != nil { + return "", nil, err + } + hash := sha256.Sum256([]byte(token)) + key.TokenSHA256 = hex.EncodeToString(hash[:]) + credential := &harnessCredential{key: key, hash: hash, owner: owner} + release := func() { r.releaseHarnessCredential(credential) } + r.mu.Lock() + defer r.mu.Unlock() + if err := owner.Err(); err != nil { + return "", nil, err + } + if r.closed { + return "", nil, errors.New("native executor registry is closed") + } + if len(r.harnessKeys) >= maxHarnessCredentials { + return "", nil, ErrHarnessCapacity + } + if r.harnessKeys[hash] != nil { + return "", nil, errors.New("native harness credential collision") + } + r.harnessKeys[hash] = credential + credential.stop = context.AfterFunc(owner, release) + return token, release, nil +} + +func (r *Registry) harnessCredential(req *http.Request, environment string) (*harnessCredential, bool) { + hash, ok := credentialHash(req) + if !ok { + return nil, false + } + r.mu.Lock() + defer r.mu.Unlock() + credential := r.harnessKeys[hash] + return credential, r.harnessCredentialActiveLocked(credential) && credential.key.EnvironmentID == environment +} + +func (r *Registry) harnessCredentialActiveLocked(credential *harnessCredential) bool { + return !r.closed && credential != nil && credential.owner.Err() == nil && r.harnessKeys[credential.hash] == credential +} + +func (r *Registry) releaseHarnessCredential(credential *harnessCredential) { + r.mu.Lock() + if r.harnessKeys[credential.hash] != credential { + r.mu.Unlock() + return + } + delete(r.harnessKeys, credential.hash) + credential.stop() + reg := r.registrations[credential.key.EnvironmentID] + if reg == nil { + r.mu.Unlock() + return + } + var observation *connectionObservation + for ticket, grant := range reg.grants { + if grant.credential == credential { + delete(reg.grants, ticket) + if grant.harness != nil { + observation = r.closeConnectionLocked(reg, grant.harness) + } + } + } + r.mu.Unlock() + _ = r.deliverObservation(observation) +} diff --git a/services/agents-api/internal/executor/codex/harness_credentials_test.go b/services/agents-api/internal/executor/codex/harness_credentials_test.go new file mode 100644 index 000000000..2cf487d96 --- /dev/null +++ b/services/agents-api/internal/executor/codex/harness_credentials_test.go @@ -0,0 +1,179 @@ +package codex + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func issueHarness(t *testing.T, f fixture, owner context.Context) (string, func()) { + t.Helper() + key := f.keys[0] + token, release, err := f.registry.IssueHarnessCredential(owner, key.TenantID, key.EnvironmentID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) + return token, release +} + +func TestHarnessCredentialCapacityAndOwnership(t *testing.T) { + f := newFixture(t) + k := f.keys[0] + for _, ids := range [][2]string{{"invalid", k.EnvironmentID}, {k.TenantID, "invalid"}, {uuid.Nil.String(), k.EnvironmentID}} { + if _, _, err := f.registry.IssueHarnessCredential(t.Context(), ids[0], ids[1]); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal("invalid ownership accepted", err) + } + } + for _, ids := range [][2]string{{f.keys[1].TenantID, k.EnvironmentID}, {k.TenantID, uuid.NewString()}} { + if _, _, err := f.registry.IssueHarnessCredential(t.Context(), ids[0], ids[1]); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign or absent Environment accepted", err) + } + } + owner, cancel := context.WithCancel(t.Context()) + cancel() + if _, _, err := f.registry.IssueHarnessCredential(owner, k.TenantID, k.EnvironmentID); !errors.Is(err, context.Canceled) { + t.Fatal("cancelled owner accepted", err) + } + var firstToken string + var firstRelease func() + for i := range maxHarnessCredentials { + token, release := issueHarness(t, f, t.Context()) + if i == 0 { + firstToken, firstRelease = token, release + } + } + if _, _, err := f.registry.IssueHarnessCredential(t.Context(), k.TenantID, k.EnvironmentID); !errors.Is(err, ErrHarnessCapacity) { + t.Fatal("credential capacity not enforced", err) + } + firstRelease() + firstRelease() + next, _ := issueHarness(t, f, t.Context()) + if next == firstToken { + t.Fatal("replacement credential reused bearer") + } + nativePOST(t, f, k.EnvironmentID, "connect", firstToken, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) + f.registry.Close() + if _, _, err := f.registry.IssueHarnessCredential(t.Context(), k.TenantID, k.EnvironmentID); err == nil { + t.Fatal("closed registry issued a credential") + } + f.registry.mu.Lock() + defer f.registry.mu.Unlock() + if len(f.registry.harnessKeys) != 0 { + t.Fatal("closed registry retained credentials") + } +} + +func TestHarnessCredentialReleaseFencesGrantsAndPreservesSuccessor(t *testing.T) { + for _, cause := range []string{"release", "owner", "shutdown"} { + t.Run(cause, func(t *testing.T) { + f := newFixture(t) + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + token, release := issueHarness(t, f, owner) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + grant := harnessMaterial(t, f, token) + harness := dial(t, grant.URL) + defer harness.Close() + validateGrant(t, f, validationBody(grant), true) + pending := harnessMaterial(t, f, token) + switch cause { + case "release": + release() + case "owner": + cancel() + case "shutdown": + f.registry.Close() + } + nativePOST(t, f, k.EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) + expectClosed(t, harness) + expectClosed(t, executor) + awaitLifecycle(t, f, k.EnvironmentID, reg.ExecutorRegistrationID, 2, false) + rejectSocket(t, pending.URL, 401) + if cause == "shutdown" { + return + } + nextToken, _ := issueHarness(t, f, t.Context()) + nextExecutor := dial(t, reg.URL) + defer nextExecutor.Close() + nextGrant := harnessMaterial(t, f, nextToken) + nextHarness := dial(t, nextGrant.URL) + defer nextHarness.Close() + release() + validateGrant(t, f, validationBody(grant), false) + validateGrant(t, f, validationBody(nextGrant), true) + relayBytes(t, nextHarness, nextExecutor, []byte("successor survives old release")) + }) + } +} + +type delayedHarnessOwnership struct { + EnvironmentStore + armed atomic.Bool + observed chan struct{} + resume chan struct{} +} + +func (s *delayedHarnessOwnership) GetEnvironment(ctx context.Context, tenant, environment string) (store.Environment, error) { + value, err := s.EnvironmentStore.GetEnvironment(ctx, tenant, environment) + if s.armed.Swap(false) { + close(s.observed) + select { + case <-s.resume: + case <-ctx.Done(): + } + } + return value, err +} + +func TestHarnessReleaseFencesAuthorizationAlreadyInFlight(t *testing.T) { + for _, operation := range []string{"connect", "attach", "validate"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + source := &delayedHarnessOwnership{EnvironmentStore: f.source, observed: make(chan struct{}), resume: make(chan struct{})} + f.registry.source = source + token, release := issueHarness(t, f, t.Context()) + reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + var grant ConnectResponse + if operation != "connect" { + grant = harnessMaterial(t, f, token) + } + if operation == "validate" { + harness := dial(t, grant.URL) + defer harness.Close() + } + source.armed.Store(true) + done := make(chan struct{}) + go func() { + defer close(done) + switch operation { + case "connect": + nativePOST(t, f, f.keys[0].EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) + case "attach": + rejectSocket(t, grant.URL, 401) + case "validate": + validateGrant(t, f, validationBody(grant), false) + } + }() + <-source.observed + release() + close(source.resume) + <-done + f.registry.mu.Lock() + remaining := len(f.registry.registrations[f.keys[0].EnvironmentID].grants) + f.registry.mu.Unlock() + if remaining != 0 { + t.Fatal("in-flight authorization recreated a released grant") + } + }) + } +} diff --git a/services/agents-api/internal/executor/codex/harness_test.go b/services/agents-api/internal/executor/codex/harness_test.go new file mode 100644 index 000000000..74c7ecb1e --- /dev/null +++ b/services/agents-api/internal/executor/codex/harness_test.go @@ -0,0 +1,248 @@ +package codex + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func relayFixture(t *testing.T) (fixture, []string) { + t.Helper() + f := newFixture(t) + tokens := []string{} + for _, key := range f.keys { + token, release, err := f.registry.IssueHarnessCredential(t.Context(), key.TenantID, key.EnvironmentID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) + tokens = append(tokens, token) + } + return f, tokens +} + +func nativePOST(t *testing.T, f fixture, environment, route, token string, body any, status int, result any) { + t.Helper() + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, f.server.URL+"/cloud/environment/"+environment+"/"+route, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := f.server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != status { + t.Fatalf("native %s status %d, expected %d", route, resp.StatusCode, status) + } + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + t.Fatal(err) + } + } +} +func harnessMaterial(t *testing.T, f fixture, token string) ConnectResponse { + t.Helper() + var result ConnectResponse + nativePOST(t, f, f.keys[0].EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 200, &result) + if result.EnvironmentID != f.keys[0].EnvironmentID || result.ExecutorRegistrationID == "" || result.HarnessKeyAuthorization == "" || result.ExecutorPublicKey != nativeRequest().ExecutorPublicKey { + t.Fatal("invalid native connect material") + } + return result +} +func validationBody(grant ConnectResponse) ValidationRequest { + return ValidationRequest{grant.ExecutorRegistrationID, nativeRequest().ExecutorPublicKey, grant.HarnessKeyAuthorization} +} +func validateGrant(t *testing.T, f fixture, body ValidationRequest, want bool) { + t.Helper() + var result ValidationResponse + nativePOST(t, f, f.keys[0].EnvironmentID, "validate", f.tokens[0], body, 200, &result) + if result.Valid != want { + t.Fatalf("native authorization %v, expected %v", result.Valid, want) + } +} +func relayBytes(t *testing.T, source, target *websocket.Conn, data []byte) { + t.Helper() + if err := source.WriteMessage(websocket.BinaryMessage, data); err != nil { + t.Fatal(err) + } + _ = target.SetReadDeadline(time.Now().Add(2 * time.Second)) + kind, got, err := target.ReadMessage() + if err != nil || kind != websocket.BinaryMessage || !bytes.Equal(data, got) { + t.Fatal("opaque frame did not arrive unchanged") + } +} +func expectClosed(t *testing.T, c *websocket.Conn) { + t.Helper() + _ = c.SetReadDeadline(time.Now().Add(2 * heartbeatInterval)) + if _, _, err := c.ReadMessage(); err == nil { + t.Fatal("closed peer remained usable") + } +} + +func TestHarnessAuthorizationOpaqueRelayAndRefresh(t *testing.T) { + f, tokens := relayFixture(t) + k := f.keys[0] + request := ConnectRequest{nativeRequest().ExecutorPublicKey} + nativePOST(t, f, k.EnvironmentID, "connect", tokens[0], request, 503, nil) + for _, token := range []string{f.tokens[0], tokens[1], "caller-or-device"} { + nativePOST(t, f, k.EnvironmentID, "connect", token, request, 401, nil) + } + nativePOST(t, f, k.EnvironmentID, "register", tokens[0], nativeRequest(), 401, nil) + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + grant := harnessMaterial(t, f, tokens[0]) + validateGrant(t, f, validationBody(grant), false) + rejectSocket(t, strings.Replace(grant.URL, "/harness/", "/executor/", 1), 401) + harness := dial(t, grant.URL) + defer harness.Close() + rejectSocket(t, grant.URL, 401) + wrong := validationBody(grant) + wrong.HarnessKeyAuthorization = "wrong" + validateGrant(t, f, wrong, false) + wrong = validationBody(grant) + wrong.HarnessPublicKey.X25519 = nativeRequest().ExecutorPublicKey.X25519[0:1] + "Q" + nativeRequest().ExecutorPublicKey.X25519[2:] + validateGrant(t, f, wrong, false) + wrong = validationBody(grant) + wrong.ExecutorRegistrationID = uuid.NewString() + validateGrant(t, f, wrong, false) + nativePOST(t, f, k.EnvironmentID, "validate", tokens[0], validationBody(grant), 401, nil) + validateGrant(t, f, validationBody(grant), true) + validateGrant(t, f, validationBody(grant), false) + data := bytes.Repeat([]byte{0, 255, 81, 2}, maxRelayMessageSize/4) + relayBytes(t, harness, executor, data) + relayBytes(t, executor, harness, []byte{0, 0, 83, 254}) + refresh := harnessMaterial(t, f, tokens[0]) + if refresh.ExecutorRegistrationID != grant.ExecutorRegistrationID { + t.Fatal("refresh changed executor registration") + } + rejectSocket(t, refresh.URL, 409) + validateGrant(t, f, validationBody(refresh), false) + relayBytes(t, harness, executor, []byte("healthy after same-key refresh")) + f.registry.mu.Lock() + for _, g := range f.registry.registrations[k.EnvironmentID].grants { + g.expires = time.Now().Add(-time.Second) + } + f.registry.mu.Unlock() + rejectSocket(t, refresh.URL, 401) + relayBytes(t, executor, harness, []byte("expiry must not terminate an established pair")) + harness.Close() + expectClosed(t, executor) + awaitPresence(t, f, k.EnvironmentID, false) + rejectSocket(t, grant.URL, 401) + rejectSocket(t, refresh.URL, 401) + next := dial(t, reg.URL) + defer next.Close() + validateGrant(t, f, validationBody(grant), false) + fresh := harnessMaterial(t, f, tokens[0]) + resumed := dial(t, fresh.URL) + defer resumed.Close() + relayBytes(t, resumed, next, []byte("fresh generation")) +} + +func TestHarnessPairClosesOnReplacementDeletionOwnershipOrPeerLoss(t *testing.T) { + for _, cause := range []string{"executor", "replacement", "deleted", "lease"} { + t.Run(cause, func(t *testing.T) { + f, tokens := relayFixture(t) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + grant := harnessMaterial(t, f, tokens[0]) + harness := dial(t, grant.URL) + defer harness.Close() + switch cause { + case "executor": + executor.Close() + case "replacement": + replacement := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + next := dial(t, replacement.URL) + defer next.Close() + nextGrant := harnessMaterial(t, f, tokens[0]) + nextHarness := dial(t, nextGrant.URL) + defer nextHarness.Close() + relayBytes(t, nextHarness, next, []byte("replacement survives stale callbacks")) + validateGrant(t, f, validationBody(grant), false) + default: + f.source.mu.Lock() + if cause == "deleted" { + delete(f.source.values, k.EnvironmentID) + } else { + f.source.lost = true + } + f.source.mu.Unlock() + } + expectClosed(t, harness) + expectClosed(t, executor) + rejectSocket(t, grant.URL, 401) + }) + } +} + +func TestHarnessGrantsAreBoundedAndPruneExpiredPending(t *testing.T) { + f, tokens := relayFixture(t) + reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + for range maxHarnessGrants { + harnessMaterial(t, f, tokens[0]) + } + nativePOST(t, f, f.keys[0].EnvironmentID, "connect", tokens[0], ConnectRequest{nativeRequest().ExecutorPublicKey}, 429, nil) + f.registry.mu.Lock() + for _, grant := range f.registry.registrations[f.keys[0].EnvironmentID].grants { + grant.expires = time.Now().Add(-time.Second) + } + f.registry.mu.Unlock() + fresh := harnessMaterial(t, f, tokens[0]) + c := dial(t, fresh.URL) + c.Close() + expectClosed(t, executor) +} + +func TestHarnessPairRejectsTextOversizeAndBackpressure(t *testing.T) { + for _, fault := range []string{"text", "oversize", "backpressure"} { + t.Run(fault, func(t *testing.T) { + f, tokens := relayFixture(t) + reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + grant := harnessMaterial(t, f, tokens[0]) + harness := dial(t, grant.URL) + defer harness.Close() + switch fault { + case "text": + _ = harness.WriteMessage(websocket.TextMessage, []byte("invalid")) + case "oversize": + _ = harness.WriteMessage(websocket.BinaryMessage, make([]byte, maxRelayMessageSize+1)) + case "backpressure": + _ = harness.SetWriteDeadline(time.Now().Add(2 * relayWriteTimeout)) + data := make([]byte, maxRelayMessageSize) + for range 256 { + if err := harness.WriteMessage(websocket.BinaryMessage, data); err != nil { + break + } + } + } + expectClosed(t, harness) + // The stalled destination may retain already-forwarded frames in its kernel buffer. + awaitPresence(t, f, f.keys[0].EnvironmentID, false) + }) + } +} + +func TestHarnessCredentialCannotRegisterExecutor(t *testing.T) { + f, tokens := relayFixture(t) + f.register(t, f.keys[0].EnvironmentID, tokens[0], nativeRequest(), 401) +} diff --git a/services/agents-api/internal/executor/codex/lifecycle.go b/services/agents-api/internal/executor/codex/lifecycle.go new file mode 100644 index 000000000..19eaadcc3 --- /dev/null +++ b/services/agents-api/internal/executor/codex/lifecycle.go @@ -0,0 +1,118 @@ +package codex + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +const observationTimeout = 4 * time.Second + +type connectionObservation struct { + tenant, environment, generation string + revision int64 + connected bool +} + +// Capture and retain delivery while the socket mutation is still under the lock. +// The existing request or connection owns synchronous delivery outside that lock. +func (r *Registry) connectionObservationLocked(reg *registration, connected bool) *connectionObservation { + reg.revision++ + r.observations.Add(1) + return &connectionObservation{reg.key.TenantID, reg.key.EnvironmentID, reg.id, reg.revision, connected} +} + +func (r *Registry) replaceGeneration(reg *registration) error { + defer r.observations.Done() + ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) + err := r.replaceConnection(ctx, reg.key.TenantID, reg.key.EnvironmentID, reg.id) + cancel() + if err != nil { + r.lifecycleFailure("replace", reg.key.EnvironmentID, reg.id, err) + } + return err +} + +func (r *Registry) deliverObservation(observation *connectionObservation) error { + if observation == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) + defer cancel() + return r.deliverObservationContext(ctx, observation) +} + +func (r *Registry) deliverObservationContext(ctx context.Context, observation *connectionObservation) error { + defer r.observations.Done() + err := r.observeConnection(ctx, observation.tenant, observation.environment, observation.generation, observation.revision, observation.connected) + if err != nil { + r.lifecycleFailure("observe", observation.environment, observation.generation, err) + } + return err +} + +func (r *Registry) lifecycleFailure(operation, environment, generation string, err error) { + log.Bg().Error("native executor connection lifecycle write failed", "operation", operation, "environment_id", environment) + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrInvalidInput) { + // A deleted or terminal target cannot authorize a connection, but does not disable peers. + r.mu.Lock() + if reg := r.registrations[environment]; reg != nil && (operation == "replace" || reg.id == generation) { + delete(r.registrations, environment) + r.closeConnectionLocked(reg, reg.socket) + } + r.mu.Unlock() + return + } + r.mu.Lock() + if r.lifecycleErr == nil { + r.lifecycleErr = err + } + r.mu.Unlock() + // Do not wait here: this call can itself own an observation being drained. + r.closeConnections() +} + +// LifecycleError reports the first persistence failure that closed the registry. +func (r *Registry) LifecycleError() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.lifecycleErr +} + +// Close stops admission and drains accepted lifecycle writes before returning. +// The execution owner must retain its lease until Close completes. +func (r *Registry) Close() { + r.closeConnections() + r.observations.Wait() +} + +func (r *Registry) closeConnections() { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return + } + r.closed = true + for hash, credential := range r.harnessKeys { + credential.stop() + delete(r.harnessKeys, hash) + } + var observations []*connectionObservation + for environment, reg := range r.registrations { + if observation := r.closeConnectionLocked(reg, reg.socket); observation != nil { + observations = append(observations, observation) + } + delete(r.registrations, environment) + } + r.mu.Unlock() + // One budget covers the complete shutdown batch, including callbacks waiting + // for the leased writer. Expired callbacks still settle their delivery count. + ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) + defer cancel() + for _, observation := range observations { + _ = r.deliverObservationContext(ctx, observation) + } +} diff --git a/services/agents-api/internal/executor/codex/lifecycle_test.go b/services/agents-api/internal/executor/codex/lifecycle_test.go new file mode 100644 index 000000000..c82779949 --- /dev/null +++ b/services/agents-api/internal/executor/codex/lifecycle_test.go @@ -0,0 +1,353 @@ +package codex + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type lifecycleFixture struct { + mu sync.Mutex + states map[string]connectionObservation + calls []connectionObservation +} + +func (f *lifecycleFixture) replace(ctx context.Context, tenant, environment, generation string) error { + if err := ctx.Err(); err != nil { + return err + } + f.mu.Lock() + defer f.mu.Unlock() + if f.states[environment].generation != generation { + value := connectionObservation{tenant: tenant, environment: environment, generation: generation} + f.states[environment] = value + f.calls = append(f.calls, value) + } + return nil +} + +func (f *lifecycleFixture) observe(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + if err := ctx.Err(); err != nil { + return err + } + f.mu.Lock() + defer f.mu.Unlock() + value := connectionObservation{tenant, environment, generation, revision, connected} + f.calls = append(f.calls, value) + previous := f.states[environment] + if previous.generation == generation && previous.revision < revision { + f.states[environment] = value + } + return nil +} + +func (f *lifecycleFixture) state(environment string) connectionObservation { + f.mu.Lock() + defer f.mu.Unlock() + return f.states[environment] +} + +func awaitLifecycle(t *testing.T, f fixture, environment, generation string, revision int64, connected bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value := f.lifecycle.state(environment) + if value.generation == generation && value.revision == revision && value.connected == connected { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("lifecycle did not converge", f.lifecycle.state(environment)) +} + +func awaitLifecycleSignal(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("lifecycle operation did not complete") + } +} + +func TestConnectionLifecycleRequiresBothCallbacks(t *testing.T) { + f := newFixture(t) + config := Config{Store: f.source, CheckOwnership: f.source.owner, PublicURL: f.server.URL} + if _, err := New(config); err == nil { + t.Fatal("missing lifecycle callbacks accepted") + } + config.ReplaceConnection = f.lifecycle.replace + if _, err := New(config); err == nil { + t.Fatal("missing observation callback accepted") + } + config.ReplaceConnection = nil + config.ObserveConnection = f.lifecycle.observe + if _, err := New(config); err == nil { + t.Fatal("missing replacement callback accepted") + } +} + +func TestConnectionLifecycleTracksSocketReconnectAndShutdown(t *testing.T) { + f := newFixture(t) + key := f.keys[0] + reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 0, false) + executor := dial(t, reg.URL) + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 1, true) + executor.Close() + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) + executor = dial(t, reg.URL) + defer executor.Close() + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 3, true) + f.registry.Close() + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 4, false) + f.lifecycle.mu.Lock() + defer f.lifecycle.mu.Unlock() + if len(f.lifecycle.calls) != 5 { + t.Fatal("socket cleanup emitted duplicate observations", f.lifecycle.calls) + } +} + +func TestConnectionLifecycleReplacementFencesDelayedLoss(t *testing.T) { + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var once, releaseOnce sync.Once + f := newFixture(t, func(config *Config) { + observe := config.ObserveConnection + config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + blocked := false + if !connected { + once.Do(func() { + blocked = true + close(entered) + select { + case <-release: + case <-ctx.Done(): + } + }) + } + err := observe(ctx, tenant, environment, generation, revision, connected) + if blocked { + close(finished) + } + return err + } + }) + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + key := f.keys[0] + first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, first.URL) + awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) + executor.Close() + awaitLifecycleSignal(t, entered) + next := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + successor := dial(t, next.URL) + defer successor.Close() + awaitLifecycle(t, f, key.EnvironmentID, next.ExecutorRegistrationID, 1, true) + releaseOnce.Do(func() { close(release) }) + awaitLifecycleSignal(t, finished) + awaitLifecycle(t, f, key.EnvironmentID, next.ExecutorRegistrationID, 1, true) + awaitPresence(t, f, key.EnvironmentID, true) +} + +func TestConnectionLifecycleCloseDrainsOutOfOrderWrites(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + f := newFixture(t, func(config *Config) { + observe := config.ObserveConnection + config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + if connected { + close(entered) + select { + case <-release: + case <-ctx.Done(): + } + } + return observe(ctx, tenant, environment, generation, revision, connected) + } + }) + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + key := f.keys[0] + reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + awaitLifecycleSignal(t, entered) + closed := make(chan struct{}) + go func() { f.registry.Close(); close(closed) }() + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) + select { + case <-closed: + t.Fatal("Close returned while an accepted observation was still in flight") + default: + } + releaseOnce.Do(func() { close(release) }) + awaitLifecycleSignal(t, closed) + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) + expectClosed(t, executor) +} + +func TestConnectionLifecycleReplacementWritesOutsideRegistryMutex(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var armed atomic.Bool + var releaseOnce sync.Once + f := newFixture(t, func(config *Config) { + replace := config.ReplaceConnection + config.ReplaceConnection = func(ctx context.Context, tenant, environment, generation string) error { + if armed.Swap(false) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + } + } + return replace(ctx, tenant, environment, generation) + } + }) + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + key := f.keys[0] + first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, first.URL) + defer executor.Close() + awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) + armed.Store(true) + finished := make(chan struct{}) + go func() { + defer close(finished) + f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 503) + }() + awaitLifecycleSignal(t, entered) + closed := make(chan struct{}) + go func() { f.registry.Close(); close(closed) }() + awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 2, false) + select { + case <-closed: + t.Fatal("Close did not retain the accepted replacement") + default: + } + releaseOnce.Do(func() { close(release) }) + awaitLifecycleSignal(t, finished) + awaitLifecycleSignal(t, closed) + f.registry.mu.Lock() + defer f.registry.mu.Unlock() + if len(f.registry.registrations) != 0 { + t.Fatal("late replacement reopened a closed registry") + } +} + +func TestConnectionLifecycleFailureRetiresTargetOrClosesRegistry(t *testing.T) { + for _, failure := range []error{store.ErrNotFound, store.ErrInvalidInput, errors.New("observation storage unavailable")} { + t.Run(failure.Error(), func(t *testing.T) { + var armed atomic.Bool + failed := make(chan struct{}) + f := newFixture(t, func(config *Config) { + observe := config.ObserveConnection + config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + if connected && armed.Swap(false) { + defer close(failed) + return failure + } + return observe(ctx, tenant, environment, generation, revision, connected) + } + }) + other := f.keys[1] + first := f.register(t, other.EnvironmentID, f.tokens[1], nativeRequest(), 200) + healthy := dial(t, first.URL) + defer healthy.Close() + awaitLifecycle(t, f, other.EnvironmentID, first.ExecutorRegistrationID, 1, true) + key := f.keys[0] + reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + armed.Store(true) + executor := dial(t, reg.URL) + defer executor.Close() + awaitLifecycleSignal(t, failed) + expectClosed(t, executor) + wantClosed := !errors.Is(failure, store.ErrNotFound) && !errors.Is(failure, store.ErrInvalidInput) + f.registry.mu.Lock() + closed := f.registry.closed + _, retained := f.registry.registrations[key.EnvironmentID] + f.registry.mu.Unlock() + if closed != wantClosed || retained { + t.Fatal("lifecycle failure did not close the expected scope", closed, retained) + } + if wantClosed { + expectClosed(t, healthy) + if !errors.Is(f.registry.LifecycleError(), failure) { + t.Fatal("persistence failure was not retained") + } + } else { + connected, err := f.registry.Connected(t.Context(), other.TenantID, other.EnvironmentID) + if err != nil || !connected || f.registry.LifecycleError() != nil { + t.Fatal("target retirement disabled another Environment", err) + } + } + f.registry.Close() + }) + } +} + +func TestConnectionLifecycleReplaceFailureDoesNotPublish(t *testing.T) { + for _, failure := range []error{store.ErrNotFound, store.ErrInvalidInput, errors.New("replacement storage unavailable")} { + t.Run(failure.Error(), func(t *testing.T) { + var armed atomic.Bool + f := newFixture(t, func(config *Config) { + replace := config.ReplaceConnection + config.ReplaceConnection = func(ctx context.Context, tenant, environment, generation string) error { + if armed.Swap(false) { + return failure + } + return replace(ctx, tenant, environment, generation) + } + }) + key := f.keys[0] + first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) + executor := dial(t, first.URL) + defer executor.Close() + awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) + armed.Store(true) + f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 503) + expectClosed(t, executor) + f.registry.mu.Lock() + _, retained := f.registry.registrations[key.EnvironmentID] + f.registry.mu.Unlock() + if retained { + t.Fatal("failed replacement left its predecessor or an unpublished successor usable") + } + f.registry.Close() + }) + } +} + +func TestConnectionLifecycleShutdownSharesOneDeadline(t *testing.T) { + var mu sync.Mutex + var deadlines []time.Time + f := newFixture(t, func(config *Config) { + observe := config.ObserveConnection + config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + if !connected { + deadline, ok := ctx.Deadline() + if !ok { + t.Error("shutdown observation has no deadline") + } + mu.Lock() + deadlines = append(deadlines, deadline) + mu.Unlock() + } + return observe(ctx, tenant, environment, generation, revision, connected) + } + }) + for i, key := range f.keys { + reg := f.register(t, key.EnvironmentID, f.tokens[i], nativeRequest(), 200) + executor := dial(t, reg.URL) + defer executor.Close() + awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 1, true) + } + f.registry.Close() + mu.Lock() + defer mu.Unlock() + if len(deadlines) != 2 || !deadlines[0].Equal(deadlines[1]) { + t.Fatal("shutdown granted a fresh timeout to each Environment", deadlines) + } +} diff --git a/services/agents-api/internal/executor/codex/messages.go b/services/agents-api/internal/executor/codex/messages.go new file mode 100644 index 000000000..436a6d749 --- /dev/null +++ b/services/agents-api/internal/executor/codex/messages.go @@ -0,0 +1,86 @@ +package codex + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" +) + +const securityProfile = "noise_hybrid_ik_v1" +const noiseSuite = "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256" + +// PublicKey follows the pinned native exec-server NoiseChannelPublicKey wire type. +type PublicKey struct { + Suite string `json:"suite"` + X25519 string `json:"x25519_public_key"` + MLKEM768 string `json:"mlkem768_public_key"` +} + +func (k PublicKey) valid() bool { + dh, err := base64.StdEncoding.Strict().DecodeString(k.X25519) + kem, kemErr := base64.StdEncoding.Strict().DecodeString(k.MLKEM768) + return k.Suite == noiseSuite && err == nil && len(dh) == 32 && kemErr == nil && len(kem) == 1184 +} + +type RegistrationRequest struct { + SecurityProfile string `json:"security_profile"` + ExecutorPublicKey PublicKey `json:"executor_public_key"` +} + +type RegistrationResponse struct { + EnvironmentID string `json:"environment_id"` + URL string `json:"url"` + SecurityProfile string `json:"security_profile"` + ExecutorRegistrationID string `json:"executor_registration_id"` +} + +type RegistryError struct { + Error ErrorDetail `json:"error"` +} +type ErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int) { + writeJSON(w, status, RegistryError{Error: ErrorDetail{Code: "executor_registry_error", Message: http.StatusText(status)}}) +} + +type ConnectRequest struct { + HarnessPublicKey PublicKey `json:"harness_public_key"` +} +type ConnectResponse struct { + RegistrationResponse + ExecutorPublicKey PublicKey `json:"executor_public_key"` + HarnessKeyAuthorization string `json:"harness_key_authorization"` +} +type ValidationRequest struct { + ExecutorRegistrationID string `json:"executor_registration_id"` + HarnessPublicKey PublicKey `json:"harness_public_key"` + HarnessKeyAuthorization string `json:"harness_key_authorization"` +} +type ValidationResponse struct { + Valid bool `json:"valid"` +} + +func decodeRequest(w http.ResponseWriter, req *http.Request, body any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, 8*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(body); err != nil { + writeError(w, http.StatusBadRequest) + return false + } + if err := decoder.Decode(new(any)); err != io.EOF { + writeError(w, http.StatusBadRequest) + return false + } + return true +} diff --git a/services/agents-api/internal/executor/codex/registry.go b/services/agents-api/internal/executor/codex/registry.go new file mode 100644 index 000000000..01b024680 --- /dev/null +++ b/services/agents-api/internal/executor/codex/registry.go @@ -0,0 +1,188 @@ +package codex + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +const ticketLifetime = 5 * time.Minute + +// Registry owns replaceable connections, never durable public readiness. +type Registry struct { + source EnvironmentStore + checkOwnership func(context.Context) error + replaceConnection func(context.Context, string, string, string) error + observeConnection func(context.Context, string, string, string, int64, bool) error + observations sync.WaitGroup + lifecycleErr error + publicWS string + harnessKeys map[[32]byte]*harnessCredential + registrationMu sync.Mutex + mu sync.Mutex + closed bool + registrations map[string]*registration +} + +type registration struct { + id string + key ScopedKey + publicKey PublicKey + ticket [32]byte + expires time.Time + socket *connection + grants map[[32]byte]*harnessGrant + revision int64 +} + +func New(c Config) (*Registry, error) { + url, err := validateConfig(c) + if err != nil { + return nil, err + } + return &Registry{harnessKeys: make(map[[32]byte]*harnessCredential), source: c.Store, checkOwnership: c.CheckOwnership, + replaceConnection: c.ReplaceConnection, observeConnection: c.ObserveConnection, + publicWS: url, registrations: make(map[string]*registration)}, nil +} + +// PublicURL returns the validated origin used for native executor registration. +func (r *Registry) PublicURL() string { + if origin, ok := strings.CutPrefix(r.publicWS, "wss://"); ok { + return "https://" + origin + } + return "http://" + strings.TrimPrefix(r.publicWS, "ws://") +} + +func (r *Registry) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /cloud/environment/{environment}/register", r.register) + mux.HandleFunc("GET /cloud/environment/{environment}/executor/{registration}", r.connectExecutor) + mux.HandleFunc("POST /cloud/environment/{environment}/connect", r.connect) + mux.HandleFunc("POST /cloud/environment/{environment}/validate", r.validate) + mux.HandleFunc("GET /cloud/environment/{environment}/harness/{registration}", r.connectHarness) + return mux +} + +func (r *Registry) authorized(ctx context.Context, key ScopedKey) error { + // Client disconnects must not cancel a query on the shared execution lease. + ownerCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if err := r.checkOwnership(ownerCtx); err != nil { + r.closeConnections() + return err + } + _, err := r.source.GetEnvironment(ctx, key.TenantID, key.EnvironmentID) + return err +} + +func (r *Registry) check(w http.ResponseWriter, req *http.Request, key ScopedKey) bool { + ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) + defer cancel() + err := r.authorized(ctx, key) + if err == nil { + return true + } + status := http.StatusServiceUnavailable + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrInvalidInput) { + status = http.StatusNotFound + } + writeError(w, status) + return false +} + +// @Summary Register a native executor (internal transport, not public Environment readiness) +// @Tags Native executor registry +// @Accept json +// @Produce json +// @Param environment path string true "Environment ID" +// @Param request body RegistrationRequest true "Native executor key" +// @Success 200 {object} RegistrationResponse +// @Failure 400,401,404,503 {object} RegistryError +// @Router /cloud/environment/{environment}/register [post] +func (r *Registry) register(w http.ResponseWriter, req *http.Request) { + environment := req.PathValue("environment") + key, ok := r.executorCredential(w, req, environment) + if !ok { + return + } + if !r.check(w, req, key) { + return + } + var body RegistrationRequest + if !decodeRequest(w, req, &body) { + return + } + if body.SecurityProfile != securityProfile || !body.ExecutorPublicKey.valid() { + writeError(w, http.StatusBadRequest) + return + } + ticket, err := capability() + if err != nil { + writeError(w, http.StatusServiceUnavailable) + return + } + next := ®istration{id: uuid.NewString(), key: key, publicKey: body.ExecutorPublicKey, ticket: sha256.Sum256([]byte(ticket)), expires: time.Now().Add(ticketLifetime), grants: make(map[[32]byte]*harnessGrant)} + // Order credential observation and replacement without blocking relay heartbeats on a database read. + r.registrationMu.Lock() + if !r.checkCurrentExecutor(w, req, key) { + r.registrationMu.Unlock() + return + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + r.registrationMu.Unlock() + writeError(w, http.StatusServiceUnavailable) + return + } + r.observations.Add(1) + r.mu.Unlock() + if err := r.replaceGeneration(next); err != nil { + r.registrationMu.Unlock() + writeError(w, http.StatusServiceUnavailable) + return + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + r.registrationMu.Unlock() + writeError(w, http.StatusServiceUnavailable) + return + } + previous := r.registrations[environment] + r.registrations[environment] = next + if previous != nil { + r.closeConnectionLocked(previous, previous.socket) + } + r.mu.Unlock() + r.registrationMu.Unlock() + writeJSON(w, http.StatusOK, RegistrationResponse{EnvironmentID: environment, ExecutorRegistrationID: next.id, SecurityProfile: securityProfile, URL: r.publicWS + "/cloud/environment/" + environment + "/executor/" + next.id + "?ticket=" + ticket}) +} + +// Connected reports a current authenticated socket, not harness readiness or filesystem isolation. +func (r *Registry) Connected(ctx context.Context, tenant, environment string) (bool, error) { + if err := r.authorized(ctx, ScopedKey{TenantID: tenant, EnvironmentID: environment}); err != nil { + return false, err + } + r.mu.Lock() + defer r.mu.Unlock() + reg := r.registrations[environment] + return !r.closed && reg != nil && reg.socket != nil, nil +} + +func capability() (string, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(secret), nil +} diff --git a/services/agents-api/internal/executor/codex/registry_test.go b/services/agents-api/internal/executor/codex/registry_test.go new file mode 100644 index 000000000..2b9e24c51 --- /dev/null +++ b/services/agents-api/internal/executor/codex/registry_test.go @@ -0,0 +1,295 @@ +package codex + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type environmentFixture struct { + mu sync.Mutex + values map[string]string + keys map[string]string + lost bool +} + +func (s *environmentFixture) GetEnvironment(ctx context.Context, tenant, id string) (store.Environment, error) { + if err := ctx.Err(); err != nil { + return store.Environment{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.values[id] != tenant { + return store.Environment{}, store.ErrNotFound + } + return store.Environment{ID: id, TenantID: tenant}, nil +} +func (s *environmentFixture) AuthenticateEnvironmentExecutor(ctx context.Context, id, digest string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.values[id] == "" || s.keys[id] != digest { + return "", store.ErrNotFound + } + return s.values[id], nil +} + +func (s *environmentFixture) owner(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.lost { + return errors.New("lost") + } + return nil +} + +func digest(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} +func nativeRequest() RegistrationRequest { + return RegistrationRequest{SecurityProfile: securityProfile, ExecutorPublicKey: PublicKey{Suite: noiseSuite, X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))}} +} + +type fixture struct { + registry *Registry + server *httptest.Server + source *environmentFixture + keys []ScopedKey + tokens []string + lifecycle *lifecycleFixture +} + +func newFixture(t *testing.T, configure ...func(*Config)) fixture { + t.Helper() + source := &environmentFixture{values: map[string]string{}, keys: map[string]string{}} + keys, tokens := []ScopedKey{}, []string{} + for range 2 { + token := uuid.NewString() + k := ScopedKey{TokenSHA256: digest(token), TenantID: uuid.NewString(), EnvironmentID: uuid.NewString()} + keys = append(keys, k) + tokens = append(tokens, token) + source.values[k.EnvironmentID] = k.TenantID + source.keys[k.EnvironmentID] = k.TokenSHA256 + } + server := httptest.NewUnstartedServer(nil) + lifecycle := &lifecycleFixture{states: make(map[string]connectionObservation)} + config := Config{Store: source, CheckOwnership: source.owner, ReplaceConnection: lifecycle.replace, + ObserveConnection: lifecycle.observe, PublicURL: "http://" + server.Listener.Addr().String()} + for _, option := range configure { + option(&config) + } + registry, err := New(config) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = registry.Handler() + server.Start() + t.Cleanup(func() { registry.Close(); server.Close() }) + return fixture{registry, server, source, keys, tokens, lifecycle} +} +func (f fixture) register(t *testing.T, environment, token string, body any, expected int) RegistrationResponse { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, f.server.URL+"/cloud/environment/"+environment+"/register", bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := f.server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != expected { + t.Fatalf("registration status %d, expected %d", resp.StatusCode, expected) + } + var result RegistrationResponse + if expected == 200 { + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.EnvironmentID != environment || result.ExecutorRegistrationID == "" || result.SecurityProfile != securityProfile { + t.Fatal("invalid native response") + } + } + return result +} +func dial(t *testing.T, url string) *websocket.Conn { + t.Helper() + c, resp, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + if resp != nil { + t.Fatalf("socket rejected: %d", resp.StatusCode) + } + t.Fatal("socket failed") + } + return c +} +func rejectSocket(t *testing.T, url string, status int) { + t.Helper() + c, resp, err := websocket.DefaultDialer.Dial(url, nil) + if c != nil { + c.Close() + } + if resp != nil { + defer resp.Body.Close() + } + if err == nil || resp == nil || resp.StatusCode != status { + t.Fatal("unexpected socket authorization outcome") + } +} +func awaitPresence(t *testing.T, f fixture, id string, want bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + connected, err := f.registry.Connected(t.Context(), f.keys[0].TenantID, id) + if err == nil && connected == want { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("presence did not converge") +} + +func TestRegistrationScopesAndRealSocketReplacement(t *testing.T) { + f := newFixture(t) + first := f.keys[0] + f.register(t, first.EnvironmentID, f.tokens[1], nativeRequest(), 401) + f.register(t, first.EnvironmentID, "caller-or-device-key", nativeRequest(), 401) + f.register(t, uuid.NewString(), f.tokens[0], nativeRequest(), 401) + bad := nativeRequest() + bad.ExecutorPublicKey.Suite = "wrong" + f.register(t, first.EnvironmentID, f.tokens[0], bad, 400) + reg := f.register(t, first.EnvironmentID, f.tokens[0], nativeRequest(), 200) + rejectSocket(t, reg.URL+"invalid", 401) + c := dial(t, reg.URL) + defer c.Close() + awaitPresence(t, f, first.EnvironmentID, true) + rejectSocket(t, reg.URL, 409) + replacement := f.register(t, first.EnvironmentID, f.tokens[0], nativeRequest(), 200) + rejectSocket(t, reg.URL, 401) + next := dial(t, replacement.URL) + defer next.Close() + awaitPresence(t, f, first.EnvironmentID, true) + _ = c.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err := c.ReadMessage(); err == nil { + t.Fatal("replaced socket survived") + } + awaitPresence(t, f, first.EnvironmentID, true) + next.Close() + awaitPresence(t, f, first.EnvironmentID, false) + reconnect := dial(t, replacement.URL) + reconnect.Close() + awaitPresence(t, f, first.EnvironmentID, false) + if _, err := f.registry.Connected(t.Context(), f.keys[1].TenantID, first.EnvironmentID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign presence visible") + } +} + +func TestExpiredTicketAndClosedRegistryRejectAccess(t *testing.T) { + f := newFixture(t) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + f.registry.mu.Lock() + f.registry.registrations[k.EnvironmentID].expires = time.Now().Add(-time.Second) + f.registry.mu.Unlock() + rejectSocket(t, reg.URL, 401) + f.registry.Close() + rejectSocket(t, reg.URL, 401) + f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 503) +} + +func TestOwnershipLossAndDeletionClosePresence(t *testing.T) { + for _, loss := range []string{"deleted", "lease"} { + t.Run(loss, func(t *testing.T) { + f := newFixture(t) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + c := dial(t, reg.URL) + defer c.Close() + f.source.mu.Lock() + if loss == "deleted" { + delete(f.source.values, k.EnvironmentID) + } else { + f.source.lost = true + } + f.source.mu.Unlock() + _ = c.SetReadDeadline(time.Now().Add(2 * heartbeatInterval)) + if _, _, err := c.ReadMessage(); err == nil { + t.Fatal("unauthorized socket survived") + } + expected := 401 + if loss == "lease" { + expected = 503 + } + f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), expected) + }) + } +} + +func TestExecutorPresenceHasNoCommandRelay(t *testing.T) { + f := newFixture(t) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + c := dial(t, reg.URL) + defer c.Close() + if err := c.WriteMessage(websocket.BinaryMessage, []byte("no command relay")); err != nil { + t.Fatal(err) + } + _ = c.SetReadDeadline(time.Now().Add(time.Second)) + _, _, err := c.ReadMessage() + if !websocket.IsCloseError(err, websocket.CloseUnsupportedData) { + t.Fatal("command traffic accepted") + } + awaitPresence(t, f, k.EnvironmentID, false) +} + +func TestExecutorConfigRejectsUnsafeOrAmbiguousBindings(t *testing.T) { + f := newFixture(t) + base := Config{Store: f.source, CheckOwnership: f.source.owner, ReplaceConnection: f.lifecycle.replace, + ObserveConnection: f.lifecycle.observe, PublicURL: f.server.URL} + for _, url := range []string{"http://executor.example", "https://user:secret@example", "https://example/path", "https://example?token=x", "ws://localhost"} { + c := base + c.PublicURL = url + if _, err := New(c); err == nil { + t.Fatal("unsafe URL accepted") + } + } +} + +func TestRegistrationCallerCancellationKeepsOtherConnections(t *testing.T) { + f := newFixture(t) + k := f.keys[0] + reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) + c := dial(t, reg.URL) + defer c.Close() + awaitPresence(t, f, k.EnvironmentID, true) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := f.registry.Connected(ctx, k.TenantID, k.EnvironmentID); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + awaitPresence(t, f, k.EnvironmentID, true) +} diff --git a/services/agents-api/internal/executor/codex/socket.go b/services/agents-api/internal/executor/codex/socket.go new file mode 100644 index 000000000..b1e367cca --- /dev/null +++ b/services/agents-api/internal/executor/codex/socket.go @@ -0,0 +1,150 @@ +package codex + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +const heartbeatInterval = 5 * time.Second +const maxRelayMessageSize = 256 * 1024 +const relayWriteTimeout = 5 * time.Second + +var nativeUpgrader = websocket.Upgrader{HandshakeTimeout: 5 * time.Second, ReadBufferSize: 1024, WriteBufferSize: 1024} + +type connection struct { + socket *websocket.Conn + peer *connection +} + +// @Summary Attach an executor using a registration-scoped connection capability +// @Tags Native executor registry +// @Param environment path string true "Environment ID" +// @Param registration path string true "Registration ID" +// @Param ticket query string true "Private connection capability" +// @Success 101 {string} string "WebSocket upgrade" +// @Failure 401,404,409,503 {object} RegistryError +// @Router /cloud/environment/{environment}/executor/{registration} [get] +func (r *Registry) connectExecutor(w http.ResponseWriter, req *http.Request) { + environment, id := req.PathValue("environment"), req.PathValue("registration") + ticket := sha256.Sum256([]byte(req.URL.Query().Get("ticket"))) + r.mu.Lock() + reg := r.registrations[environment] + valid := !r.closed && reg != nil && reg.id == id && time.Now().Before(reg.expires) && subtle.ConstantTimeCompare(ticket[:], reg.ticket[:]) == 1 + r.mu.Unlock() + if !valid { + writeError(w, http.StatusUnauthorized) + return + } + if !r.check(w, req, reg.key) || !r.checkCurrentExecutor(w, req, reg.key) { + return + } + r.mu.Lock() + if r.closed || r.registrations[environment] != reg || !time.Now().Before(reg.expires) || reg.socket != nil { + r.mu.Unlock() + writeError(w, http.StatusConflict) + return + } + socket, err := nativeUpgrader.Upgrade(w, req, nil) + if err != nil { + r.mu.Unlock() + return + } + c := &connection{socket: socket} + reg.socket = c + observation := r.connectionObservationLocked(reg, true) + r.mu.Unlock() + if err := r.deliverObservation(observation); err != nil { + return + } + r.serveConnection(environment, reg, c) +} + +func (r *Registry) serveConnection(environment string, reg *registration, c *connection) { + defer func() { + r.mu.Lock() + observation := r.closeConnectionLocked(reg, c) + r.mu.Unlock() + _ = r.deliverObservation(observation) + }() + socket := c.socket + socket.SetReadLimit(maxRelayMessageSize) + _ = socket.SetReadDeadline(time.Now().Add(3 * heartbeatInterval)) + socket.SetPongHandler(func(string) error { return socket.SetReadDeadline(time.Now().Add(3 * heartbeatInterval)) }) + done := make(chan struct{}) + defer close(done) + go r.heartbeat(environment, reg, c, done) + for { + kind, data, err := socket.ReadMessage() + if err != nil { + return + } + r.mu.Lock() + peer := c.peer + current := !r.closed && r.registrations[environment] == reg && (reg.socket == c || reg.socket == peer) + r.mu.Unlock() + if kind != websocket.BinaryMessage || peer == nil { + _ = socket.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseUnsupportedData, "binary paired relay required"), time.Now().Add(time.Second)) + return + } + if !current { + return + } + // Each peer has exactly one data writer. Blocking bounds buffering to one native frame per direction. + if err := peer.socket.SetWriteDeadline(time.Now().Add(relayWriteTimeout)); err != nil { + return + } + if err := peer.socket.WriteMessage(websocket.BinaryMessage, data); err != nil { + return + } + } +} + +func (r *Registry) closeConnectionLocked(reg *registration, c *connection) *connectionObservation { + if c == nil { + return nil + } + _ = c.socket.Close() + if c.peer != nil { + _ = c.peer.socket.Close() + } + // Close both physical peers so the native executor detaches its virtual Session before reconnecting. + if reg.socket != nil && (reg.socket == c || reg.socket == c.peer) { + reg.socket = nil + clear(reg.grants) + if r.registrations[reg.key.EnvironmentID] == reg { + return r.connectionObservationLocked(reg, false) + } + } + return nil +} + +func (r *Registry) heartbeat(environment string, reg *registration, c *connection, done <-chan struct{}) { + ticker := time.NewTicker(heartbeatInterval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + err := r.executorAuthorized(ctx, reg.key) + cancel() + r.mu.Lock() + current := !r.closed && r.registrations[environment] == reg && (reg.socket == c || (reg.socket != nil && reg.socket.peer == c)) + r.mu.Unlock() + if err != nil || !current { + _ = c.socket.Close() + return + } + if err := c.socket.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil { + _ = c.socket.Close() + return + } + } + } +} diff --git a/services/agents-api/internal/identity/principal.go b/services/agents-api/internal/identity/principal.go new file mode 100644 index 000000000..5cb8e3051 --- /dev/null +++ b/services/agents-api/internal/identity/principal.go @@ -0,0 +1,60 @@ +// Package identity defines execution-service principals independently of product users. +package identity + +import ( + "errors" + "sort" + "strings" + + "github.com/google/uuid" +) + +type ProjectScope struct { + TenantID string + OrganizationID string + ProjectID string +} + +type Principal struct { + ProjectScope + SubjectKind string + SubjectID string +} + +func (p Principal) Validate() error { + if _, err := ProjectScopes([]ProjectScope{p.ProjectScope}); err != nil { + return err + } + return p.Subject().Validate() +} + +// ProjectScopes validates the bijection and returns unique scopes in stable lock order. +func ProjectScopes(scopes []ProjectScope) ([]ProjectScope, error) { + byTenant := make(map[string]ProjectScope, len(scopes)) + byProject := make(map[[2]string]string, len(scopes)) + for _, scope := range scopes { + tenant, err := uuid.Parse(scope.TenantID) + if err != nil || tenant == uuid.Nil || tenant.String() != scope.TenantID { + return nil, errors.New("caller tenant_id must be a canonical nonzero UUID") + } + if !validID(scope.OrganizationID) || !validID(scope.ProjectID) { + return nil, errors.New("caller organization_id and project_id are required") + } + project := [2]string{scope.OrganizationID, scope.ProjectID} + if existing, ok := byTenant[scope.TenantID]; ok && existing != scope { + return nil, errors.New("caller tenant_id has conflicting project bindings") + } + if existing, ok := byProject[project]; ok && existing != scope.TenantID { + return nil, errors.New("caller project has conflicting tenant bindings") + } + byTenant[scope.TenantID], byProject[project] = scope, scope.TenantID + } + result := make([]ProjectScope, 0, len(byTenant)) + for _, scope := range byTenant { + result = append(result, scope) + } + sort.Slice(result, func(i, j int) bool { return result[i].TenantID < result[j].TenantID }) + return result, nil +} + +func validID(value string) bool { return value != "" && strings.TrimSpace(value) == value } diff --git a/services/agents-api/internal/identity/subject.go b/services/agents-api/internal/identity/subject.go new file mode 100644 index 000000000..c98da3ea7 --- /dev/null +++ b/services/agents-api/internal/identity/subject.go @@ -0,0 +1,18 @@ +package identity + +import "errors" + +// Subject identifies a user or service account within an execution project. +type Subject struct { + Kind string + ID string +} + +func (s Subject) Validate() error { + if (s.Kind != "user" && s.Kind != "service_account") || !validID(s.ID) { + return errors.New("caller subject_kind must be user or service_account with a nonempty subject_id") + } + return nil +} + +func (p Principal) Subject() Subject { return Subject{Kind: p.SubjectKind, ID: p.SubjectID} } diff --git a/services/agents-api/internal/items/command_output.go b/services/agents-api/internal/items/command_output.go new file mode 100644 index 000000000..2be56835f --- /dev/null +++ b/services/agents-api/internal/items/command_output.go @@ -0,0 +1,37 @@ +package items + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func projectCommandOutput(turn string, raw json.RawMessage) ([]Update, error) { + var p proto.CommandOutputPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + if p.ID == "" || p.Delta == "" { + return nil, errors.New("invalid command output observation") + } + return []Update{{Item: v1.Item{ID: Identity(turn, "tool:"+p.ID), TurnID: turn, Type: "command_execution"}, CommandOutputDelta: &p.Delta}}, nil +} + +func mergeCommandOutput(update Update, previous v1.Item) (v1.Item, error) { + if previous.ID != update.Item.ID || previous.TurnID != update.Item.TurnID || previous.Type != "command_execution" { + return v1.Item{}, errors.New("command output requires its existing command") + } + if previous.Status != "in_progress" { + return previous, nil + } + var output string + if previous.Output != nil { + if err := json.Unmarshal(encoded(previous.Output), &output); err != nil { + return v1.Item{}, err + } + } + previous.Output = output + *update.CommandOutputDelta + return previous, nil +} diff --git a/services/agents-api/internal/items/command_output_test.go b/services/agents-api/internal/items/command_output_test.go new file mode 100644 index 000000000..a737b509d --- /dev/null +++ b/services/agents-api/internal/items/command_output_test.go @@ -0,0 +1,70 @@ +package items + +import ( + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestCommandOutputKeepsIdentityAndReplacesDraftAtCompletion(t *testing.T) { + previous := v1.Item{ID: Identity(testTurn, "tool:cmd"), TurnID: testTurn, Type: "command_execution", Command: "run", Status: "in_progress"} + for _, delta := range []string{"same\n", "same\n", "结束\n"} { + raw, _ := json.Marshal(map[string]string{"id": "cmd", "delta": delta}) + updates, err := Project(testTurn, "command_output", 1, raw) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(previous) + merged := mustMerge(t, updates[0], previous) + after, _ := json.Marshal(previous) + if string(before) != string(after) || *updates[0].CommandOutputDelta != delta || merged.ID != previous.ID || merged.Command != "run" { + t.Fatal("delta merge changed its input or command identity") + } + previous = merged + } + if string(encoded(previous.Output)) != `"same\nsame\n结束\n"` { + t.Fatal(string(encoded(previous.Output))) + } + for _, snapshot := range []struct{ raw, want string }{ + {``, `"same\nsame\n结束\n"`}, {`""`, `""`}, {`"authoritative"`, `"authoritative"`}, + } { + final := previous + final.Status, final.Output = "completed", nil + if snapshot.raw != "" { + final.Output = json.RawMessage(snapshot.raw) + } + merged := mustMerge(t, Update{Item: final}, previous) + if string(encoded(merged.Output)) != snapshot.want { + t.Fatal(string(encoded(merged.Output))) + } + late := "late" + got := mustMerge(t, Update{Item: final, CommandOutputDelta: &late}, merged) + if !reflect.DeepEqual(got, merged) { + t.Fatal("late output changed completed command") + } + } +} + +func TestCommandOutputRequiresMatchingCommand(t *testing.T) { + updates, err := Project(testTurn, "command_output", 1, []byte(`{"id":"cmd","delta":"text"}`)) + if err != nil { + t.Fatal(err) + } + for _, previous := range []v1.Item{ + {}, + {ID: updates[0].Item.ID, TurnID: testTurn, Type: "mcp_call"}, + {ID: updates[0].Item.ID, TurnID: "other", Type: "command_execution"}, + {ID: updates[0].Item.ID, TurnID: testTurn, Type: "command_execution", Status: "in_progress", Output: json.RawMessage(`{}`)}, + } { + if _, err := Merge(updates[0], previous); err == nil { + t.Fatal("invalid prior command accepted") + } + } + for _, raw := range []string{`{}`, `{"id":"cmd","delta":null}`, `{"id":"cmd","delta":""}`, `{"id":"cmd","delta":7}`, `{"delta":"text"}`} { + if _, err := Project(testTurn, "command_output", 1, []byte(raw)); err == nil { + t.Fatal("invalid delta accepted", raw) + } + } +} diff --git a/services/agents-api/internal/items/inputs.go b/services/agents-api/internal/items/inputs.go new file mode 100644 index 000000000..6497708ca --- /dev/null +++ b/services/agents-api/internal/items/inputs.go @@ -0,0 +1,44 @@ +package items + +import ( + "encoding/json" + "strconv" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func inputMessages(turn string, sequence int64, raw json.RawMessage) []Update { + var p struct { + Text *string `json:"text"` + Input []struct { + Role string `json:"role"` + Content []v1.ItemContent `json:"content"` + } `json:"input"` + } + // Internal admission predates the public schema and accepts arbitrary objects. + if err := json.Unmarshal(raw, &p); err != nil { + return nil + } + key := "input:" + strconv.FormatInt(sequence, 10) + if p.Text != nil { + return []Update{{Item: message(turn, key, "user", *p.Text, "completed")}} + } + var updates []Update + for i, input := range p.Input { + if input.Role != "user" || len(input.Content) == 0 { + continue + } + valid := true + for _, c := range input.Content { + if !((c.Type == "input_text" && c.Text != nil) || (c.Type == "input_image" && c.ImageURL != "")) { + valid = false + } + } + if !valid { + continue + } + item := v1.Item{ID: Identity(turn, key+":"+strconv.Itoa(i)), TurnID: turn, Type: "message", Status: "completed", Role: "user", Content: input.Content} + updates = append(updates, Update{Item: item}) + } + return updates +} diff --git a/services/agents-api/internal/items/messages.go b/services/agents-api/internal/items/messages.go new file mode 100644 index 000000000..b64008677 --- /dev/null +++ b/services/agents-api/internal/items/messages.go @@ -0,0 +1,132 @@ +// Package items projects execution observations to the supported public Item variants. +package items + +import ( + "encoding/json" + "errors" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +type Update struct { + Item v1.Item + AppendText bool + CommandOutputDelta *string + // A legacy aggregate is used only when no native message identity was recorded. + LegacyFinal bool +} + +func Identity(turn, key string) string { + return uuid.NewSHA1(uuid.MustParse(turn), []byte(key)).String() +} + +func message(turn, key, role, text, status string) v1.Item { + contentType := "output_text" + if role == "user" { + contentType = "input_text" + } + return v1.Item{ID: Identity(turn, key), TurnID: turn, Type: "message", Role: role, Status: status, + Content: []v1.ItemContent{{Type: contentType, Text: &text}}} +} + +func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, error) { + switch kind { + case "message": + return inputMessages(turn, sequence, raw), nil + case proto.TypeDelta: + var p proto.DeltaPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + key := "message:" + p.ItemID + if p.ItemID == "" { + key = "legacy-message" + } + return []Update{{Item: message(turn, key, "assistant", p.Delta, "in_progress"), AppendText: true}}, nil + case proto.TypeOutputMessage: + var p proto.OutputMessagePayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + if p.ID == "" || (p.Status != "in_progress" && p.Status != "completed") { + return nil, errors.New("invalid message observation") + } + text := "" + if p.Text != nil { + text = *p.Text + } + item := message(turn, "message:"+p.ID, "assistant", text, p.Status) + if p.Phase == "commentary" || p.Phase == "final_answer" { + item.Phase = p.Phase + } + return []Update{{Item: item, AppendText: p.Text == nil}}, nil + case proto.TypeDone, "execution_failed": + // Legacy Done may contain adapter diagnostics. Only a successful Turn + // confirms aggregate answer text; failures retain observed message deltas. + return nil, nil + case "cancel_receipt", "execution_completed", "execution_cancelled": + var p struct { + Applied bool `json:"applied"` + Outcome *proto.DonePayload `json:"outcome"` + Done *proto.DonePayload `json:"done"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + final := p.Done + if kind == "cancel_receipt" { + if !p.Applied { + return nil, nil + } + final = p.Outcome + } + if final == nil || final.Content == "" { + return nil, nil + } + status := "incomplete" + if kind == "execution_completed" { + status = "completed" + } + return []Update{{Item: message(turn, "legacy-message", "assistant", final.Content, status), LegacyFinal: true}}, nil + case proto.TypeToolCall: + return projectTool(turn, raw) + case proto.TypeCommandOutput: + return projectCommandOutput(turn, raw) + default: + return nil, nil + } +} + +func Merge(update Update, previous v1.Item) (v1.Item, error) { + if update.CommandOutputDelta != nil { + return mergeCommandOutput(update, previous) + } + item := update.Item + if previous.ID == "" { + return item, nil + } + if previous.Status != "in_progress" && !(update.LegacyFinal && previous.Status == "incomplete") { + return previous, nil + } + if (item.Type == "function_call") && (item.Arguments == nil || string(encoded(item.Arguments)) == "null") { + item.Arguments = previous.Arguments + } + if item.Type == "command_execution" && item.Output == nil { + item.Output = previous.Output + } + if update.AppendText { + if previous.Status != "in_progress" { + return previous, nil + } + text := *previous.Content[0].Text + *item.Content[0].Text + item.Content = slices.Clone(item.Content) + item.Content[0].Text = &text + if item.Phase == "" { + item.Phase = previous.Phase + } + } + return item, nil +} diff --git a/services/agents-api/internal/items/messages_test.go b/services/agents-api/internal/items/messages_test.go new file mode 100644 index 000000000..a7ee296ba --- /dev/null +++ b/services/agents-api/internal/items/messages_test.go @@ -0,0 +1,94 @@ +package items + +import ( + "encoding/json" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +const testTurn = "bff31a40-9a63-4a49-aebe-89dfe9dd5268" + +func TestMessageSnapshotsReplaceDeltasAndDoNotRegress(t *testing.T) { + var previous v1.Item + for _, event := range []struct{ kind, body string }{ + {"output_message", `{"id":"native","status":"in_progress","phase":"commentary"}`}, + {"delta", `{"item_id":"native","delta":"draft"}`}, + {"output_message", `{"id":"native","status":"completed","phase":"final_answer","text":"Revised answer"}`}, + {"delta", `{"item_id":"native","delta":"late"}`}, + {"output_message", `{"id":"native","status":"in_progress"}`}, + } { + updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) + if err != nil { + t.Fatal(err) + } + previous = mustMerge(t, updates[0], previous) + } + if previous.Status != "completed" || previous.Phase != "final_answer" || *previous.Content[0].Text != "Revised answer" { + t.Fatalf("%+v", previous) + } + raw, _ := json.Marshal(previous) + if strings.Contains(string(raw), "native") { + t.Fatal("native identity leaked") + } +} + +func TestLegacyDoneDoesNotConfirmSuccessfulAnswer(t *testing.T) { + var previous v1.Item + for _, event := range []struct{ kind, body string }{ + {"delta", `{"delta":"partial answer"}`}, + {"error", `{"error":"provider failure"}`}, + {"done", `{"content":"provider failure"}`}, + {"execution_failed", `{"done":{"content":"provider failure"}}`}, + } { + updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) + if err != nil { + t.Fatal(err) + } + for _, update := range updates { + previous = mustMerge(t, update, previous) + } + } + if previous.Status != "in_progress" || *previous.Content[0].Text != "partial answer" { + t.Fatal(previous) + } + updates, err := Project(testTurn, "execution_completed", 1, []byte(`{"done":{"content":"complete answer"}}`)) + if err != nil { + t.Fatal(err) + } + previous = mustMerge(t, updates[0], previous) + if previous.Status != "completed" || *previous.Content[0].Text != "complete answer" { + t.Fatal(previous) + } +} + +func TestMergePreservesIncomingDeltasAndPreviousSnapshots(t *testing.T) { + var previous v1.Item + fragments := []string{"go ", "go ", "结束"} + for i, fragment := range fragments { + update := Update{Item: message(testTurn, "message:native", "assistant", fragment, "in_progress"), AppendText: true} + before, _ := json.Marshal(previous) + merged := mustMerge(t, update, previous) + after, _ := json.Marshal(previous) + if string(before) != string(after) { + t.Fatal("merge mutated the previous snapshot") + } + if *update.Item.Content[0].Text != fragment { + t.Fatalf("incoming delta changed: got %q, want %q", *update.Item.Content[0].Text, fragment) + } + if *merged.Content[0].Text != strings.Join(fragments[:i+1], "") { + t.Fatalf("accumulated text changed: %+v", merged) + } + previous = merged + } +} + +func mustMerge(t *testing.T, update Update, previous v1.Item) v1.Item { + t.Helper() + item, err := Merge(update, previous) + if err != nil { + t.Fatal(err) + } + return item +} diff --git a/services/agents-api/internal/items/tools.go b/services/agents-api/internal/items/tools.go new file mode 100644 index 000000000..95fb37358 --- /dev/null +++ b/services/agents-api/internal/items/tools.go @@ -0,0 +1,100 @@ +package items + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func projectTool(turn string, raw json.RawMessage) ([]Update, error) { + var p struct { + ID string `json:"id"` + Stage string `json:"stage"` + Observation *proto.ToolObservation `json:"observation"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + n := p.Observation + if p.ID == "" || n == nil || (p.Stage != "before" && p.Stage != "after") { + return nil, errors.New("invalid tool observation") + } + switch n.Status { + case "in_progress", "completed", "failed", "incomplete": + default: + return nil, errors.New("invalid tool status") + } + if (p.Stage == "before") != (n.Status == "in_progress") { + return nil, errors.New("inconsistent tool stage and status") + } + item := v1.Item{ID: Identity(turn, "tool:"+p.ID), TurnID: turn, Status: n.Status} + switch n.Kind { + case "command": + if n.Command == "" { + return nil, errors.New("missing command") + } + if len(n.Output) > 0 && string(n.Output) != "null" { + var output string + if err := json.Unmarshal(n.Output, &output); err != nil { + return nil, err + } + item.Output = n.Output + } + item.Type, item.Command, item.Cwd = "command_execution", n.Command, n.Cwd + item.ExitCode, item.DurationMS = n.ExitCode, n.DurationMS + case "mcp": + if n.Server == "" || n.Name == "" { + return nil, errors.New("missing MCP identity") + } + item.Type, item.ServerLabel, item.Name = "mcp_call", n.Server, n.Name + item.Arguments, item.Output, item.Error = nullable(n.Arguments), nullable(n.Output), nullable(n.Error) + case "function": + if n.Name == "" { + return nil, errors.New("missing function identity") + } + item.Type, item.Name, item.CallID = "function_call", n.Name, item.ID + item.Arguments = nullable(n.Arguments) + updates := []Update{{Item: item}} + if p.Stage == "after" && n.Content != nil { + if err := (proto.FunctionResultPayload{Content: *n.Content}).ValidateContent(); err != nil { + return nil, err + } + content := make([]v1.ItemContent, 0, len(*n.Content)) + for _, part := range *n.Content { + value := v1.ItemContent{Type: part.Type, Text: part.Text} + if part.ImageURL != nil { + value.ImageURL = *part.ImageURL + } + content = append(content, value) + } + updates = append(updates, Update{Item: v1.Item{ID: Identity(turn, "result:"+p.ID), TurnID: turn, Type: "function_call_output", CallID: item.ID, Status: item.Status, Output: encoded(content)}}) + } + return updates, nil + case "web_search": + item.Type = "web_search_call" + if item.Status == "failed" { + item.Status = "incomplete" + } + if n.Action != nil { + switch n.Action.Type { + case "search", "open_page", "find_in_page", "other": + default: + return nil, errors.New("unsupported web action") + } + item.Action = &v1.WebSearchAction{Type: n.Action.Type, Query: n.Action.Query, Queries: n.Action.Queries, URL: n.Action.URL, Pattern: n.Action.Pattern} + } + default: + return nil, errors.New("unsupported tool observation") + } + return []Update{{Item: item}}, nil +} + +func nullable(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`null`) + } + return raw +} +func encoded(value any) json.RawMessage { raw, _ := json.Marshal(value); return raw } diff --git a/services/agents-api/internal/items/tools_test.go b/services/agents-api/internal/items/tools_test.go new file mode 100644 index 000000000..66d0ae795 --- /dev/null +++ b/services/agents-api/internal/items/tools_test.go @@ -0,0 +1,92 @@ +package items + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestToolProjectionPreservesResultsAndPublicFields(t *testing.T) { + cases := []struct{ observation, kind, status string }{ + {`{"kind":"command","command":"exit 7","cwd":"/work","status":"failed","output":"error\n","exit_code":7,"duration_ms":9,"privateField":"SECRET"}`, "command_execution", "failed"}, + {`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"id":9007199254740993},"status":"completed","output":{"structuredContent":{"number":9007199254740993}},"error":null}`, "mcp_call", "completed"}, + {`{"kind":"mcp","server":"reference","name":"lookup","arguments":{},"status":"failed","error":{"message":"failed"}}`, "mcp_call", "failed"}, + {`{"kind":"function","name":"apply_patch","status":"completed","arguments":{"changes":[{"diff":"-before\n+after"}]}}`, "function_call", "completed"}, + {`{"kind":"web_search","status":"failed","action":{"type":"search","query":"sample"}}`, "web_search_call", "incomplete"}, + } + for _, c := range cases { + updates, err := Project(testTurn, "tool_call", 1, []byte(`{"id":"x","stage":"after","observation":`+c.observation+`}`)) + if err != nil { + t.Fatal(err) + } + item := updates[0].Item + if item.Type != c.kind || item.Status != c.status { + t.Fatalf("%+v", item) + } + raw, _ := json.Marshal(item) + if strings.Contains(string(raw), "SECRET") { + t.Fatal("private field leaked") + } + if strings.Contains(c.observation, "9007199254740993") && !strings.Contains(string(raw), "9007199254740993") { + t.Fatal("integer precision lost") + } + if c.kind == "mcp_call" && !strings.Contains(string(raw), `"output":`) { + t.Fatal("required nullable output missing") + } + } +} + +func TestFunctionResultsHaveSeparateLinkedIdentity(t *testing.T) { + raw := []byte(`{"id":"x","stage":"after","observation":{"kind":"function","name":"reference::lookup","status":"failed","arguments":[1],"content":[{"type":"input_text","text":""},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}}`) + updates, err := Project(testTurn, "tool_call", 1, raw) + if err != nil { + t.Fatal(err) + } + if len(updates) != 2 || updates[0].Item.Name != "reference::lookup" || updates[0].Item.ID != Identity(testTurn, "tool:x") || updates[1].Item.ID != Identity(testTurn, "result:x") || updates[0].Item.ID != updates[1].Item.CallID || updates[1].Item.Status != "failed" { + t.Fatalf("%+v", updates) + } + result, _ := json.Marshal(updates[1].Item) + if !strings.Contains(string(result), `"text":""`) || !strings.Contains(string(result), `"input_image"`) { + t.Fatal(string(result)) + } + for _, content := range []string{"", `,"content":null`, `,"content":[]`} { + updates, err = Project(testTurn, "tool_call", 1, []byte(`{"id":"x","stage":"after","observation":{"kind":"function","name":"lookup","status":"completed"`+content+`}}`)) + if err != nil { + t.Fatal(err) + } + want := 1 + if strings.Contains(content, "[]") { + want = 2 + } + if len(updates) != want { + t.Fatal(updates) + } + if want == 2 && string(encoded(updates[1].Item.Output)) != "[]" { + t.Fatal(updates) + } + } +} + +func TestToolProjectionRejectsUntranslatedOrInvalidObservations(t *testing.T) { + for _, raw := range []string{ + `{"id":"x","name":"Bash","stage":"after","result":"ok"}`, + `{"id":"x","stage":"after","native_item":{"id":"x","type":"commandExecution","command":"pwd"}}`, + `{"id":"x","stage":"before","observation":{"kind":"command","status":"completed","command":"pwd"}}`, + `{"id":"x","stage":"after","observation":{"kind":"command","status":"completed","command":"pwd","output":{}}}`, + `{"id":"x","stage":"after","observation":{"kind":"function","status":"completed","name":"f","content":[{"type":"unknown"}]}}`, + `{"id":"x","stage":"after","observation":{"kind":"web_search","status":"completed","action":{"type":"openPage"}}}`, + } { + if _, err := Project(testTurn, "tool_call", 1, []byte(raw)); err == nil { + t.Fatalf("accepted %s", raw) + } + } +} + +func TestWebNavigationUsesNeutralDiscriminators(t *testing.T) { + for _, kind := range []string{"open_page", "find_in_page"} { + updates, err := Project(testTurn, "tool_call", 1, []byte(`{"id":"web","stage":"after","observation":{"kind":"web_search","status":"completed","action":{"type":"`+kind+`","url":"https://example.com","pattern":"needle"}}}`)) + if err != nil || len(updates) != 1 || updates[0].Item.Action.Type != kind || updates[0].Item.Action.URL == nil || *updates[0].Item.Action.URL != "https://example.com" { + t.Fatal(updates, err) + } + } +} diff --git a/services/agents-api/internal/runtime/gateway.go b/services/agents-api/internal/runtime/gateway.go new file mode 100644 index 000000000..246de1c66 --- /dev/null +++ b/services/agents-api/internal/runtime/gateway.go @@ -0,0 +1,44 @@ +// Package runtime connects execution devices without product dependencies. +package runtime + +import ( + "errors" + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" +) + +type DeviceStore interface { + gateway.RuntimeStore + gateway.HeartbeatTouch +} + +// NewGateway exposes the existing internal daemon protocol. It does not implement +// the public Agents API self_hosted executor contract or grant Session API access. +func NewGateway(s DeviceStore, publicWSURL string) (http.Handler, *gateway.Registry, error) { + u, err := url.Parse(publicWSURL) + if err != nil || s == nil || (u.Scheme != "ws" && u.Scheme != "wss") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/api/v1/agent-daemon/ws" { + return nil, nil, errors.New("daemon URL must be an absolute ws(s) URL ending in /api/v1/agent-daemon/ws") + } + registry := gateway.NewRegistry() + h := gateway.NewHandler(gateway.HandlerConfig{ + Authenticator: gateway.NewAuthenticator(s), Registry: registry, + Heartbeat: s, PublicWSURL: publicWSURL, + }) + r := chi.NewRouter() + r.Route("/api/v1", func(r chi.Router) { gateway.RegisterRoutes(r, h) }) + return r, registry, nil +} + +// CloseConnections releases upgraded WebSockets, which http.Server.Shutdown +// does not close. Call after stopping new HTTP upgrades. +func CloseConnections(registry *gateway.Registry) { + for _, id := range registry.Devices() { + if session, err := registry.LookupDevice(id); err == nil { + session.Close("execution service shutting down") + } + } +} diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go new file mode 100644 index 000000000..54dba1ae8 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/bootstrap.go @@ -0,0 +1,62 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "io" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/moby/moby/client" +) + +type entry struct { + name string + content []byte + directory bool +} + +func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap) error { + // The existing daemon auth profile is the shared managed/user-managed boundary. + // This file is injected before the untrusted workspace or harness can run. + auth, e := json.Marshal(struct { + ServerURL string `json:"server_url"` + RuntimeID string `json:"runtime_id"` + Credential string `json:"runner_credential"` + }{b.CoreURL, b.DeviceID, b.Credential}) + if e != nil { + return e + } + if e = p.copy(ctx, id, "/home", []entry{ + {name: "runtime", directory: true}, {name: "runtime/.parsar", directory: true}, + {name: "runtime/.parsar/parsar-daemon", directory: true}, + {name: "runtime/.parsar/parsar-daemon/default", directory: true}, + {name: "runtime/.parsar/parsar-daemon/default/auth.json", content: auth}, + }); e != nil { + return e + } + return p.copy(ctx, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) +} +func (p *Provider) copy(ctx context.Context, id, path string, entries []entry) error { + var content bytes.Buffer + writer := tar.NewWriter(&content) + for _, file := range entries { + header := &tar.Header{Name: file.name, Uid: 1000, Gid: 1000, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg} + if file.directory { + header.Mode = 0700 + header.Typeflag = tar.TypeDir + } + if e := writer.WriteHeader(header); e != nil { + return e + } + if _, e := writer.Write(file.content); e != nil { + return e + } + } + if e := writer.Close(); e != nil { + return e + } + _, e := p.client.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) + return e +} diff --git a/services/agents-api/internal/sandbox/docker/command.go b/services/agents-api/internal/sandbox/docker/command.go new file mode 100644 index 000000000..b2c0a744b --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/command.go @@ -0,0 +1,91 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "io" + "path" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/client" +) + +// RunCommand is for trusted initialization only. Closing an exec attachment does +// not kill its process. On any uncertain outcome, the caller must reclaim the +// allocation with Kill instead of admitting native work or replaying the command. +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, command sandbox.Command) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + if len(command.Args) == 0 || len(command.Stdin) > sandbox.MaxCommandInputBytes || (command.Directory != "" && !path.IsAbs(command.Directory)) { + return result, sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil { + return result, e + } + if _, ok := ctx.Deadline(); !ok { + return result, sandbox.ErrInvalid + } + exec, e := p.client.ExecCreate(ctx, c.Container.ID, client.ExecCreateOptions{User: "1000:1000", Cmd: command.Args, WorkingDir: command.Directory, AttachStdin: command.Stdin != nil, AttachStdout: true, AttachStderr: true}) + if e != nil { + return result, e + } + attached, e := p.client.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{}) + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + defer attached.Close() + written := make(chan error, 1) + if command.Stdin != nil { + go func() { + _, err := io.Copy(attached.Conn, bytes.NewReader(command.Stdin)) + if err == nil { + err = attached.CloseWrite() + } + written <- err + }() + } else { + written <- nil + } + stdout, stderr := &boundedBuffer{}, &boundedBuffer{} + done := make(chan error, 1) + go func() { _, e := stdcopy.StdCopy(stdout, stderr, attached.Reader); done <- e }() + select { + case e = <-done: + case <-ctx.Done(): + attached.Close() + <-done + e = ctx.Err() + } + if e != nil { + attached.Close() + <-written + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + select { + case e = <-written: + case <-ctx.Done(): + attached.Close() + <-written + e = ctx.Err() + } + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + status, e := p.client.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{}) + if e != nil || status.Running { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + return sandbox.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: status.ExitCode}, nil +} + +type boundedBuffer struct{ bytes.Buffer } + +func (b *boundedBuffer) Write(data []byte) (int, error) { + const max = 1024 * 1024 + if b.Len()+len(data) > max { + return 0, errors.New("initialization output exceeds 1 MiB") + } + return b.Buffer.Write(data) +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go new file mode 100644 index 000000000..6e96c7911 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -0,0 +1,233 @@ +// Package docker is a thin SDK adapter for the dedicated, colocated Runtime. +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" +) + +const labelPrefix = "io.parsar.agents-api." + +// Config is trusted operator configuration, never public Session input. The +// immutable image contains the qualified native profile and all Runtime binaries. +// Seccomp is JSON content, not a path on the Docker host. Network must provide +// trusted daemon/model connectivity; native tool network policy is in the image. +type Config struct { + InstallationID, Image, Network, Seccomp string + ExtraHosts []string + NestedSandbox bool +} +type Provider struct { + client *client.Client + config Config +} + +var _ sandbox.Provider = (*Provider)(nil) + +func New(c *client.Client, config Config) (*Provider, error) { + if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { + return nil, sandbox.ErrInvalid + } + return &Provider{client: c, config: config}, nil +} +func validID(v string) bool { + u, e := uuid.Parse(v) + return e == nil && u != uuid.Nil && u.String() == v +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func (p *Provider) name(r sandbox.Reference) string { + h := sha256.Sum256([]byte(p.config.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) + return "agents-runtime-" + hex.EncodeToString(h[:16]) +} +func (p *Provider) labels(r sandbox.Reference) map[string]string { + return map[string]string{ + labelPrefix + "installation": p.config.InstallationID, labelPrefix + "tenant": r.TenantID, labelPrefix + "environment": r.EnvironmentID, labelPrefix + "allocation": r.AllocationID, + } +} +func (p *Provider) owns(labels map[string]string, r sandbox.Reference) bool { + for k, v := range p.labels(r) { + if labels[k] != v { + return false + } + } + return true +} +func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (client.ContainerInspectResult, error) { + if !validReference(r) { + return client.ContainerInspectResult{}, sandbox.ErrInvalid + } + v, e := p.client.ContainerInspect(ctx, p.name(r), client.ContainerInspectOptions{}) + if errdefs.IsNotFound(e) { + return v, sandbox.ErrNotFound + } + if e != nil { + return v, e + } + if v.Container.Config == nil || !p.owns(v.Container.Config.Labels, r) { + return v, sandbox.ErrOwnership + } + return v, nil +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + v, e := p.inspect(ctx, r) + if e != nil { + return info, e + } + info.ProviderID = v.Container.ID + info.State = string(v.Container.State.Status) + info.BootstrapComplete = info.State == "running" + return info, nil +} + +// Renew observes Docker state. Docker has no lease; service-owned expiry must be +// managed durably by Core. Never synthesize an expiry or revive a stopped Runtime. +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} + +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info := sandbox.Info{Reference: b.Reference} + u, e := url.Parse(b.CoreURL) + if (b.NetworkAccess != "" && b.NetworkAccess != "enabled" && b.NetworkAccess != "disabled") || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + return info, sandbox.ErrInvalid + } + if existing, e := p.GetInfo(ctx, b.Reference); e == nil { + return existing, sandbox.ErrExists + } else if !errors.Is(e, sandbox.ErrNotFound) { + return info, e + } + name := p.name(b.Reference) + // Retained volumes without a container are partial or lost state, not an + // invitation to overwrite native history with a new bootstrap identity. + for _, suffix := range []string{"-home", "-environment"} { + v, err := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if err == nil { + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + return info, sandbox.ErrExists + } + if !errdefs.IsNotFound(err) { + return info, err + } + } + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: p.labels(b.Reference)}) + if e != nil { + return info, e + } + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + } + limit := int64(128) + // A nested native sandbox must mount its own procfs. Keep sysfs secrets + // masked; only this qualified image profile opts out of Docker's proc masks. + var masked, readonly []string + var init *bool + if p.config.NestedSandbox { + masked = []string{"/sys/firmware", "/sys/devices/virtual/powercap"} + readonly = []string{} + enabled := true + init = &enabled + } + v, e := p.client.ContainerCreate(ctx, client.ContainerCreateOptions{Name: name, Image: p.config.Image, + Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + b.NetworkAccess}}, + HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + p.config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(p.config.Network), ExtraHosts: p.config.ExtraHosts, + MaskedPaths: masked, ReadonlyPaths: readonly, Init: init, + Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, + Mounts: []mount.Mount{ + {Type: mount.TypeVolume, Source: name + "-home", Target: "/home"}, + {Type: mount.TypeVolume, Source: name + "-environment", Target: "/environment"}, + // The native sandbox mounts canonical roots, omitting symlink aliases. + // Expose the same workspace at its public path; trusted atomic staging + // remains entirely on the original /environment mount. + {Type: mount.TypeVolume, Source: name + "-environment", Target: "/workspace", VolumeOptions: &mount.VolumeOptions{Subpath: "workspace", NoCopy: true}}, + }, + }, + }) + if errdefs.IsConflict(e) { + return info, sandbox.ErrExists + } + if e != nil { + return info, e + } + info.ProviderID = v.ID + // Any failure returns the retained allocation reference. The caller must Kill + // it, including on a lost acknowledgement. Never erase uncertain owner state. + if e = p.bootstrap(ctx, v.ID, b); e != nil { + return info, fmt.Errorf("runtime bootstrap: %w", e) + } + if _, e = p.client.ContainerStart(ctx, v.ID, client.ContainerStartOptions{}); e != nil { + return info, e + } + return p.GetInfo(ctx, b.Reference) +} + +// Kill is idempotent only for absence, not for errors or foreign ownership. It +// checks all resources before removing any and confirms removal of named volumes. +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + if !validReference(r) { + return sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil && !errors.Is(e, sandbox.ErrNotFound) { + return e + } + present := e == nil + name := p.name(r) + volumes := []string{} + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if errdefs.IsNotFound(e) { + continue + } + if e != nil { + return e + } + if !p.owns(v.Volume.Labels, r) { + return sandbox.ErrOwnership + } + volumes = append(volumes, name+suffix) + } + if present { + if _, e = p.client.ContainerRemove(ctx, c.Container.ID, client.ContainerRemoveOptions{Force: true}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + for _, v := range volumes { + if _, e = p.client.VolumeRemove(ctx, v, client.VolumeRemoveOptions{}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + if _, e = p.inspect(ctx, r); !errors.Is(e, sandbox.ErrNotFound) { + if e == nil { + return errors.New("container removal unconfirmed") + } + return e + } + for _, v := range volumes { + if _, e = p.client.VolumeInspect(ctx, v, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + if e == nil { + return errors.New("volume removal unconfirmed") + } + return e + } + } + return nil +} diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go new file mode 100644 index 000000000..2db1b6910 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -0,0 +1,198 @@ +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +func TestProviderRejectsUnsafeOperatorConfiguration(t *testing.T) { + c, e := client.New() + if e != nil { + t.Fatal(e) + } + defer c.Close() + base := Config{InstallationID: uuid.NewString(), Image: "test@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`} + for _, change := range []func(*Config){func(c *Config) { c.Image = "mutable:latest" }, func(c *Config) { c.InstallationID = "" }, func(c *Config) { c.Network = "host" }, func(c *Config) { c.Network = "container:other" }, func(c *Config) { c.Seccomp = "" }} { + v := base + change(&v) + if _, e := New(c, v); !errors.Is(e, sandbox.ErrInvalid) { + t.Fatalf("accepted invalid configuration: %v", e) + } + } +} + +// This optional Docker mechanism test uses a pinned fixture image whose entrypoint +// is sleep. It is not native/model acceptance; the real Runtime has separate checks. +func TestDockerProviderLifecycle(t *testing.T) { + image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") + if image == "" { + t.Skip("explicit Docker fixture image required") + } + seccomp, e := os.ReadFile("../../../deploy/codex/seccomp.json") + if e != nil { + t.Fatal(e) + } + c, e := client.New(client.FromEnv) + if e != nil { + t.Fatal(e) + } + defer c.Close() + p, e := New(c, Config{InstallationID: uuid.NewString(), Image: image, Network: "bridge", Seccomp: string(seccomp)}) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + bootstrap := func() sandbox.Bootstrap { + return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential"} + } + b := bootstrap() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if e := p.Kill(ctx, b.Reference); e != nil { + t.Error(e) + } + }) + t.Run("stdin concurrent output and EOF", func(t *testing.T) { + inputOwner := bootstrap() + if _, err := p.Create(ctx, inputOwner); err != nil { + t.Fatal(err) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + if err := p.Kill(cleanup, inputOwner.Reference); err != nil { + t.Error(err) + } + }() + for _, data := range [][]byte{{}, bytes.Repeat([]byte{0, 255, 10, 1, 42}, 900000)} { + result, err := p.RunCommand(ctx, inputOwner.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "head -c 131072 /dev/zero; sha256sum"}, Stdin: data}) + digest := sha256.Sum256(data) + if err != nil || result.ExitCode != 0 || !strings.HasSuffix(result.Stdout, hex.EncodeToString(digest[:])+" -\n") || len(result.Stdout) != 131072+68 { + t.Fatalf("stdin/EOF failure: input=%d stdout=%d exit=%d error=%v", len(data), len(result.Stdout), result.ExitCode, err) + } + } + }) + info, e := p.Create(ctx, b) + if e != nil { + t.Fatal(e) + } + if info.State != "running" || info.ProviderID == "" { + t.Fatalf("bad compute observation: %+v", info) + } + inspected, e := p.inspect(ctx, b.Reference) + if e != nil { + t.Fatal(e) + } + if strings.Contains(string(inspected.Raw), b.Credential) || inspected.Container.Config.User != "1000:1000" || !inspected.Container.HostConfig.ReadonlyRootfs || inspected.Container.HostConfig.Privileged { + t.Fatal("unsafe Docker configuration") + } + changed := b + changed.Credential = "must-not-replace-existing" + if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("duplicate not rejected: %v", e) + } + r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/.parsar/parsar-daemon/default/auth.json"}}) + if e != nil { + t.Fatal(e) + } + var auth map[string]string + if json.Unmarshal([]byte(r.Stdout), &auth) != nil || auth["runner_credential"] != b.Credential || auth["runtime_id"] != b.DeviceID { + t.Fatal("bootstrap changed or malformed") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "printf retained > /environment/workspace/history; printf failed >&2; exit 7"}}) + if e != nil || r.ExitCode != 7 || r.Stderr != "failed" { + t.Fatalf("lost command status: %+v %v", r, e) + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "set -eu; test \"$(cat /workspace/history)\" = retained; printf replaced > /environment/staging/replacement; mv /environment/staging/replacement /environment/workspace/history; cat /workspace/history"}}) + if e != nil || r.ExitCode != 0 || r.Stdout != "replaced" { + t.Fatal("public workspace view or atomic staging failed", e) + } + wrong := b.Reference + wrong.TenantID = uuid.NewString() + if _, e = p.GetInfo(ctx, wrong); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("foreign allocation visible") + } + if e = p.Kill(ctx, wrong); e != nil { + t.Fatal(e) + } + if _, e = p.Renew(ctx, b.Reference); e != nil { + t.Fatal("wrong tenant removed the owner") + } + timeout := 1 + if _, e = c.ContainerRestart(ctx, info.ProviderID, client.ContainerRestartOptions{Timeout: &timeout}); e != nil { + t.Fatal(e) + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/environment/workspace/history"}}) + if e != nil || r.Stdout != "replaced" { + t.Fatal("restart lost workspace") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "touch /cannot-write-root"}}) + if e != nil || r.ExitCode == 0 { + t.Fatal("root filesystem writable") + } + // Container loss must not trigger credential overwrite or state replacement. + if _, e = c.ContainerRemove(ctx, info.ProviderID, client.ContainerRemoveOptions{Force: true}); e != nil { + t.Fatal(e) + } + if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("retained volumes reused: %v", e) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e = c.VolumeInspect(ctx, p.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + t.Fatal("named volume remains") + } + } + // A colliding resource with different ownership cannot be deleted, including + // when its container is absent after a partial creation. + foreign := bootstrap() + volume := p.name(foreign.Reference) + "-home" + if _, e = c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: volume, Labels: map[string]string{"fixture": "foreign"}}); e != nil { + t.Fatal(e) + } + t.Cleanup(func() { + _, e := c.VolumeRemove(context.Background(), volume, client.VolumeRemoveOptions{}) + if e != nil { + t.Error(e) + } + }) + if e = p.Kill(ctx, foreign.Reference); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume cleanup accepted") + } + if _, e = p.Create(ctx, foreign); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume bootstrap accepted") + } + // Closing initialization output is not process termination. Require explicit + // reclamation, without returning partial output as a successful command. + next := bootstrap() + defer p.Kill(context.Background(), next.Reference) + if _, e = p.Create(ctx, next); e != nil { + t.Fatal(e) + } + short, stop := context.WithTimeout(ctx, 100*time.Millisecond) + _, e = p.RunCommand(short, next.Reference, sandbox.Command{Args: []string{"sleep", "30"}}) + stop() + if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { + t.Fatalf("timeout classified as certain: %v", e) + } + if e = p.Kill(ctx, next.Reference); e != nil { + t.Fatal(e) + } +} diff --git a/services/agents-api/internal/sandbox/docker/recovery_test.go b/services/agents-api/internal/sandbox/docker/recovery_test.go new file mode 100644 index 000000000..e433af9f1 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/recovery_test.go @@ -0,0 +1,138 @@ +package docker + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +// These controlled transport faults use real Docker compute and volumes. They +// establish Provider recovery observations, not native or model acceptance. +func TestDockerProviderRecoveryObservations(t *testing.T) { + image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") + if image == "" { + t.Skip("explicit Docker fixture image required") + } + seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") + if err != nil { + t.Fatal(err) + } + for _, fault := range []string{"lost-start-response", "partial-volumes"} { + t.Run(fault, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }} + defer transport.CloseIdleConnections() + var fired atomic.Bool + var creates atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create") { + creates.Add(1) + if fault == "partial-volumes" { + fired.Store(true) + http.Error(w, `{"message":"injected before container create"}`, 500) + return + } + } + request := r.Clone(r.Context()) + request.RequestURI = "" + request.URL.Scheme = "http" + request.URL.Host = "docker" + request.Host = "docker" + response, e := transport.RoundTrip(request) + if e != nil { + http.Error(w, "Docker transport failed", 502) + return + } + defer response.Body.Close() + if fault == "lost-start-response" && r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start") && response.StatusCode == 204 && fired.CompareAndSwap(false, true) { + connection, _, e := w.(http.Hijacker).Hijack() + if e == nil { + _ = connection.Close() + } + return + } + for k, values := range response.Header { + for _, v := range values { + w.Header().Add(k, v) + } + } + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, response.Body) + })) + defer proxy.Close() + c, e := client.New(client.WithHost(proxy.URL)) + if e != nil { + t.Fatal(e) + } + defer c.Close() + config := Config{InstallationID: uuid.NewString(), Image: image, Network: "bridge", Seccomp: string(seccomp)} + p, e := New(c, config) + if e != nil { + t.Fatal(e) + } + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-recovery-token"} + direct, e := client.New(client.WithHost("unix:///var/run/docker.sock")) + if e != nil { + t.Fatal(e) + } + defer direct.Close() + recovered, e := New(direct, config) + if e != nil { + t.Fatal(e) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + if e := recovered.Kill(cleanup, b.Reference); e != nil { + t.Error(e) + } + }() + if _, e := p.Create(ctx, b); e == nil || !fired.Load() { + t.Fatal("Create did not expose the injected uncertainty") + } + info, e := recovered.GetInfo(ctx, b.Reference) + if fault == "lost-start-response" { + if e != nil || info.State != "running" || !info.BootstrapComplete { + t.Fatalf("original allocation not recoverable: %+v %v", info, e) + } + } else { + if !errors.Is(e, sandbox.ErrNotFound) { + t.Fatalf("partial allocation observation: %v", e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); e != nil { + t.Fatal("missing container concealed missing volume fixture", e) + } + } + } + if creates.Load() != 1 { + t.Fatalf("Create was replayed %d times", creates.Load()) + } + if e := recovered.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + t.Fatal("owned volume remains", e) + } + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/bootstrap.go b/services/agents-api/internal/sandbox/e2b/bootstrap.go new file mode 100644 index 000000000..a1dd7b94d --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/bootstrap.go @@ -0,0 +1,117 @@ +package e2b + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +const bootstrapInput = "/root/.parsar/e2b/bootstrap.json" +const bootstrapReceipt = "/root/.parsar/e2b/ready.json" + +func basicUser(user string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":")) +} + +func (p *Provider) file(ctx context.Context, a allocation, name string, data []byte) ([]byte, error) { + if a.AccessToken == "" { + return nil, sandbox.ErrOwnership + } + method := http.MethodGet + var body bytes.Buffer + var contentType string + if data != nil { + method = http.MethodPost + writer := multipart.NewWriter(&body) + part, e := writer.CreateFormFile("file", "bootstrap.json") + if e != nil { + return nil, e + } + if _, e = part.Write(data); e != nil { + return nil, e + } + if e = writer.Close(); e != nil { + return nil, e + } + contentType = writer.FormDataContentType() + } + req, e := http.NewRequestWithContext(ctx, method, envdURL+"/files?"+url.Values{"path": {name}, "username": {"root"}}.Encode(), &body) + if e != nil { + return nil, sandbox.ErrInvalid + } + req.Header.Set("X-Access-Token", a.AccessToken) + req.Header.Set("E2b-Sandbox-Id", a.ID) + req.Header.Set("E2b-Sandbox-Port", "49983") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + response, e := p.client.Do(req) + if e != nil { + return nil, errors.Join(errors.New("E2B initialization file request failed"), ctx.Err()) + } + defer response.Body.Close() + if response.StatusCode == 404 { + return nil, sandbox.ErrNotFound + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("E2B initialization file request returned HTTP %d", response.StatusCode) + } + output, e := io.ReadAll(io.LimitReader(response.Body, 65537)) + if e != nil || len(output) > 65536 { + return nil, errors.New("invalid E2B initialization file response") + } + return output, nil +} +func (p *Provider) bootstrap(ctx context.Context, a allocation, b sandbox.Bootstrap) error { + raw, e := json.Marshal(struct { + sandbox.Reference + SessionID string `json:"session_id"` + DeviceID string `json:"runtime_id"` + CoreURL string `json:"server_url"` + Credential string `json:"runner_credential"` + NetworkAccess string `json:"network_access"` + }{b.Reference, b.SessionID, b.DeviceID, b.CoreURL, b.Credential, b.NetworkAccess}) + if e != nil { + return e + } + // E2B reinitializes /run when booting a template. /root remains root-private + // on persistent disk, including while envd creates the input's parent directory. + if _, e = p.file(ctx, a, bootstrapInput, raw); e != nil { + return e + } + result, e := p.run(ctx, a, "root", sandbox.Command{Args: []string{"/usr/bin/python3", "/opt/parsar-e2b/init.py"}, Directory: "/root"}) + if e != nil { + return e + } + if result.ExitCode != 0 { + return errors.New("E2B Runtime initialization failed") + } + return nil +} +func (p *Provider) completed(ctx context.Context, a allocation, r sandbox.Reference) (bool, error) { + raw, e := p.file(ctx, a, bootstrapReceipt, nil) + if errors.Is(e, sandbox.ErrNotFound) { + return false, nil + } + if e != nil { + return false, e + } + var receipt struct { + sandbox.Reference + SessionID string `json:"session_id"` + DeviceID string `json:"runtime_id"` + } + if json.Unmarshal(raw, &receipt) != nil || receipt.Reference != r || !validID(receipt.SessionID) || !validID(receipt.DeviceID) || receipt.SessionID != a.Metadata[metadataPrefix+"session"] || receipt.DeviceID != a.Metadata[metadataPrefix+"device"] { + return false, sandbox.ErrOwnership + } + return true, nil +} diff --git a/services/agents-api/internal/sandbox/e2b/command.go b/services/agents-api/internal/sandbox/e2b/command.go new file mode 100644 index 000000000..508db8194 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/command.go @@ -0,0 +1,103 @@ +package e2b + +import ( + "bytes" + "context" + "errors" + "path" + + "connectrpc.com/connect" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" + "google.golang.org/protobuf/proto" +) + +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + if len(c.Args) == 0 || c.Args[0] == "" || len(c.Stdin) > sandbox.MaxCommandInputBytes || (c.Directory != "" && !path.IsAbs(c.Directory)) { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + a, e := p.inspect(ctx, r) + if e != nil { + return sandbox.CommandResult{}, e + } + return p.run(ctx, a, "runtime", c) +} + +// Only trusted initialization calls this transport. Native execution and daily +// Files stay on the authenticated Core/Runtime connection. +func (p *Provider) run(ctx context.Context, a allocation, user string, c sandbox.Command) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + if _, ok := ctx.Deadline(); !ok || a.AccessToken == "" { + return result, sandbox.ErrInvalid + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + request := connect.NewRequest(&process.StartRequest{Process: &process.ProcessConfig{Cmd: c.Args[0], Args: c.Args[1:], Cwd: nil}, Stdin: proto.Bool(c.Stdin != nil)}) + if c.Directory != "" { + request.Msg.Process.Cwd = proto.String(c.Directory) + } + request.Header().Set("X-Access-Token", a.AccessToken) + request.Header().Set("E2b-Sandbox-Id", a.ID) + request.Header().Set("E2b-Sandbox-Port", "49983") + request.Header().Set("Authorization", basicUser(user)) + client := connect.NewClient[process.StartRequest, process.StartResponse](p.client, envdURL+"/process.Process/Start", connect.WithReadMaxBytes(1024*1024)) + stream, e := client.CallServerStream(ctx, request) + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, ctx.Err()) + } + defer stream.Close() + var stdout, stderr bytes.Buffer + ended := false + var written chan error + defer func() { + cancel() + if written != nil { + <-written + } + }() + for stream.Receive() { + event := stream.Msg().GetEvent() + if start := event.GetStart(); start != nil && c.Stdin != nil { + if written != nil || start.GetPid() == 0 { + return result, sandbox.ErrCommandUnconfirmed + } + written = make(chan error, 1) + go func() { + err := p.sendInput(ctx, request.Header(), start.GetPid(), c.Stdin) + written <- err + if err != nil { + cancel() + } + }() + } + if data := event.GetData(); data != nil { + if stdout.Len()+stderr.Len()+len(data.GetStdout())+len(data.GetStderr()) > 1024*1024 { + return result, sandbox.ErrCommandUnconfirmed + } + stdout.Write(data.GetStdout()) + stderr.Write(data.GetStderr()) + } + if end := event.GetEnd(); end != nil { + if ended || !end.GetExited() { + return result, sandbox.ErrCommandUnconfirmed + } + result.ExitCode = int(end.GetExitCode()) + ended = true + } + } + if stream.Err() != nil || !ended { + return sandbox.CommandResult{}, errors.Join(sandbox.ErrCommandUnconfirmed, ctx.Err()) + } + if c.Stdin != nil { + if written == nil { + return result, sandbox.ErrCommandUnconfirmed + } + err := <-written + written = nil + if err != nil { + return result, sandbox.ErrCommandUnconfirmed + } + } + result.Stdout, result.Stderr = stdout.String(), stderr.String() + return result, nil +} diff --git a/services/agents-api/internal/sandbox/e2b/command_input.go b/services/agents-api/internal/sandbox/e2b/command_input.go new file mode 100644 index 000000000..7f1602f43 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/command_input.go @@ -0,0 +1,39 @@ +package e2b + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" +) + +func (p *Provider) sendInput(ctx context.Context, headers http.Header, pid uint32, input []byte) error { + selector := &process.ProcessSelector{Selector: &process.ProcessSelector_Pid{Pid: pid}} + sender := connect.NewClient[process.SendInputRequest, process.SendInputResponse](p.client, envdURL+"/process.Process/SendInput", connect.WithReadMaxBytes(65536)) + for len(input) > 0 { + count := min(len(input), 1024*1024) + request := connect.NewRequest(&process.SendInputRequest{Process: selector, Input: &process.ProcessInput{Input: &process.ProcessInput_Stdin{Stdin: input[:count]}}}) + copyCommandHeaders(request.Header(), headers) + if _, err := sender.CallUnary(ctx, request); err != nil { + return err + } + input = input[count:] + } + closer := connect.NewClient[process.CloseStdinRequest, process.CloseStdinResponse](p.client, envdURL+"/process.Process/CloseStdin", connect.WithReadMaxBytes(65536)) + request := connect.NewRequest(&process.CloseStdinRequest{Process: selector}) + copyCommandHeaders(request.Header(), headers) + _, err := closer.CallUnary(ctx, request) + // The process can exit after consuming all bytes, before this EOF request. + // RunCommand still requires a complete successful exit stream. + if connect.CodeOf(err) == connect.CodeNotFound { + return nil + } + return err +} + +func copyCommandHeaders(destination, source http.Header) { + for _, name := range []string{"X-Access-Token", "E2b-Sandbox-Id", "E2b-Sandbox-Port", "Authorization"} { + destination.Set(name, source.Get(name)) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/command_input_test.go b/services/agents-api/internal/sandbox/e2b/command_input_test.go new file mode 100644 index 000000000..fc767c6d8 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/command_input_test.go @@ -0,0 +1,84 @@ +package e2b + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" +) + +type inputFixtureTransport struct{ target *url.URL } + +func (t inputFixtureTransport) RoundTrip(r *http.Request) (*http.Response, error) { + copy := r.Clone(r.Context()) + copy.URL.Scheme = t.target.Scheme + copy.URL.Host = t.target.Host + return http.DefaultTransport.RoundTrip(copy) +} + +func TestStdinExitRequiresCompleteDeliveryAndTerminalStream(t *testing.T) { + for _, tc := range []struct { + name string + sendFailure, closeFailure connect.Code + terminal, want bool + }{ + {name: "EOF", terminal: true, want: true}, + {name: "exit before EOF", closeFailure: connect.CodeNotFound, terminal: true, want: true}, + {name: "missing exit", closeFailure: connect.CodeNotFound}, + {name: "input not delivered", sendFailure: connect.CodeNotFound, terminal: true}, + {name: "unconfirmed EOF", closeFailure: connect.CodeUnavailable, terminal: true}, + } { + t.Run(tc.name, func(t *testing.T) { + finished := make(chan struct{}) + var once sync.Once + finish := func() { once.Do(func() { close(finished) }) } + mux := http.NewServeMux() + mux.Handle("/process.Process/Start", connect.NewServerStreamHandler("/process.Process/Start", func(ctx context.Context, _ *connect.Request[process.StartRequest], s *connect.ServerStream[process.StartResponse]) error { + if err := s.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_Start{Start: &process.ProcessEvent_StartEvent{Pid: 123}}}}); err != nil { + return err + } + select { + case <-finished: + case <-ctx.Done(): + return ctx.Err() + } + if !tc.terminal { + return nil + } + return s.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_End{End: &process.ProcessEvent_EndEvent{Exited: true}}}}) + })) + mux.Handle("/process.Process/SendInput", connect.NewUnaryHandler("/process.Process/SendInput", func(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { + if tc.sendFailure != 0 { + finish() + return nil, connect.NewError(tc.sendFailure, errors.New("fixture input failure")) + } + return connect.NewResponse(&process.SendInputResponse{}), nil + })) + mux.Handle("/process.Process/CloseStdin", connect.NewUnaryHandler("/process.Process/CloseStdin", func(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { + finish() + if tc.closeFailure != 0 { + return nil, connect.NewError(tc.closeFailure, errors.New("fixture EOF failure")) + } + return connect.NewResponse(&process.CloseStdinResponse{}), nil + })) + server := httptest.NewServer(mux) + defer server.Close() + target, _ := url.Parse(server.URL) + provider := Provider{client: &http.Client{Transport: inputFixtureTransport{target: target}}} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + _, err := provider.run(ctx, allocation{ID: "fixture", AccessToken: "fixture"}, "runtime", sandbox.Command{Args: []string{"fixture"}, Stdin: []byte("file")}) + if (err == nil) != tc.want { + t.Fatal("unexpected stdin completion", err) + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/control.go b/services/agents-api/internal/sandbox/e2b/control.go new file mode 100644 index 000000000..e419f0c7e --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/control.go @@ -0,0 +1,130 @@ +package e2b + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +const apiURL = "https://api.e2b.app" +const envdURL = "https://sandbox.e2b.app" +const metadataPrefix = "io.parsar.agents-api." + +type allocation struct { + ID string `json:"sandboxID"` + State string `json:"state"` + AccessToken string `json:"envdAccessToken"` + Metadata map[string]string `json:"metadata"` +} + +func (p *Provider) metadata(r sandbox.Reference) map[string]string { + return map[string]string{metadataPrefix + "installation": p.config.InstallationID, metadataPrefix + "tenant": r.TenantID, metadataPrefix + "environment": r.EnvironmentID, metadataPrefix + "allocation": r.AllocationID} +} +func (p *Provider) owns(a allocation, r sandbox.Reference) bool { + for k, v := range p.metadata(r) { + if a.Metadata[k] != v { + return false + } + } + return a.ID != "" +} + +// The caller owns retries. In particular, never replay a lost Create response. +// Provider responses and transport errors can contain secrets; return safe status only. +func (p *Provider) request(ctx context.Context, method, path string, body, out any) (http.Header, error) { + var input io.Reader + if body != nil { + raw, e := json.Marshal(body) + if e != nil { + return nil, sandbox.ErrInvalid + } + input = bytes.NewReader(raw) + } + req, e := http.NewRequestWithContext(ctx, method, apiURL+path, input) + if e != nil { + return nil, sandbox.ErrInvalid + } + req.Header.Set("X-API-Key", p.config.APIKey) + req.Header.Set("Content-Type", "application/json") + response, e := p.client.Do(req) + if e != nil { + return nil, errors.Join(errors.New("E2B control request failed"), ctx.Err()) + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotFound { + return response.Header, sandbox.ErrNotFound + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response.Header, fmt.Errorf("E2B control request returned HTTP %d", response.StatusCode) + } + if out != nil { + raw, e := io.ReadAll(io.LimitReader(response.Body, 1024*1024+1)) + if e != nil || len(raw) > 1024*1024 || json.Unmarshal(raw, out) != nil { + return nil, errors.New("invalid E2B control response") + } + } + return response.Header, nil +} + +func (p *Provider) allocations(ctx context.Context, r sandbox.Reference) ([]allocation, error) { + if !validReference(r) { + return nil, sandbox.ErrInvalid + } + metadata := url.Values{} + for k, v := range p.metadata(r) { + metadata.Set(k, v) + } + query := url.Values{"metadata": {metadata.Encode()}, "limit": {"100"}, "state": {"running,paused"}} + var result []allocation + seen := map[string]bool{} + for { + var page []allocation + headers, e := p.request(ctx, http.MethodGet, "/v2/sandboxes?"+query.Encode(), nil, &page) + if e != nil { + return nil, e + } + for _, a := range page { + if !p.owns(a, r) { + return nil, sandbox.ErrOwnership + } + result = append(result, a) + } + token := headers.Get("X-Next-Token") + if token == "" { + return result, nil + } + if seen[token] { + return nil, errors.New("invalid E2B pagination") + } + seen[token] = true + query.Set("nextToken", token) + } +} +func (p *Provider) inspectID(ctx context.Context, id string, r sandbox.Reference) (allocation, error) { + var a allocation + _, e := p.request(ctx, http.MethodGet, "/sandboxes/"+url.PathEscape(id), nil, &a) + if e == nil && (a.ID != id || !p.owns(a, r)) { + e = sandbox.ErrOwnership + } + return a, e +} +func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (allocation, error) { + all, e := p.allocations(ctx, r) + if e != nil { + return allocation{}, e + } + if len(all) == 0 { + return allocation{}, sandbox.ErrNotFound + } + if len(all) != 1 { + return allocation{}, sandbox.ErrOwnership + } + return p.inspectID(ctx, all[0].ID, r) +} diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE b/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE new file mode 100644 index 000000000..ec47fef19 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 FoundryLabs, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/README.md b/services/agents-api/internal/sandbox/e2b/envdprocess/README.md new file mode 100644 index 000000000..e9c371346 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/envdprocess/README.md @@ -0,0 +1,18 @@ +# E2B envd process protocol + +`process.proto` is copied without changes from E2B runtime commit +`fad70f393e800cee0278669a63976c3aaa00871b`, +`packages/envd/spec/process/process.proto` (Apache-2.0, accompanying LICENSE). +Its SHA-256 is `8edd9358c7dbfcad96796b3f0ed8d14c262b8b14d6bc7d5e84d468941511b8e0`. +The generated file uses protoc 32.1 and protoc-gen-go v1.36.12, matching the +repository's protobuf runtime. Regenerate from this directory: + +```sh +protoc --go_out=. --go_opt=paths=source_relative \ + --go_opt=Mprocess.proto=github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess \ + process.proto +``` + +Only the maintained Connect client is required at runtime. Do not import the E2B +server monorepo or hand-write Connect framing. The full official message schema is +retained; it does not expose these process operations through public Agents API. diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go b/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go new file mode 100644 index 000000000..db3bd24c8 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go @@ -0,0 +1,1969 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v6.32.1 +// source: process.proto + +package envdprocess + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +type ProcessConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_proto protoreflect.FileDescriptor + +const file_process_proto_rawDesc = "" + + "\n" + + "\rprocess.proto\x12\aprocess\"\\\n" + + "\x03PTY\x12%\n" + + "\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" + + "\x04Size\x12\x12\n" + + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" + + "\rProcessConfig\x12\x10\n" + + "\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x124\n" + + "\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" + + "\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + + "\x04_cwd\"\r\n" + + "\vListRequest\"n\n" + + "\vProcessInfo\x12.\n" + + "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + + "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + + "\x04_tag\"B\n" + + "\fListResponse\x122\n" + + "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + + "\fStartRequest\x120\n" + + "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + + "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + + "\x04_ptyB\x06\n" + + "\x04_tagB\b\n" + + "\x06_stdin\"p\n" + + "\rUpdateRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + + "\x04_pty\"\x10\n" + + "\x0eUpdateResponse\"\x87\x04\n" + + "\fProcessEvent\x128\n" + + "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + + "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + + "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + + "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + + "\n" + + "StartEvent\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + + "\tDataEvent\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + + "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + + "\x06output\x1a|\n" + + "\bEndEvent\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + + "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"<\n" + + "\rStartResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + + "\x0fConnectResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + + "\x10SendInputRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + + "\x11SendInputResponse\"C\n" + + "\fProcessInput\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + + "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + + "\x05input\"\xea\x02\n" + + "\x12StreamInputRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + + "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + + "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + + "\n" + + "StartEvent\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + + "\tDataEvent\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"\x15\n" + + "\x13StreamInputResponse\"p\n" + + "\x11SendSignalRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + + "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + + "\x12SendSignalResponse\"G\n" + + "\x11CloseStdinRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + + "\x12CloseStdinResponse\"D\n" + + "\x0eConnectRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + + "\x0fProcessSelector\x12\x12\n" + + "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + + "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + + "\n" + + "\bselector*H\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + + "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + + "\aProcess\x123\n" + + "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + + "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + + "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + + "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + + "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + + "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + + "\n" + + "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + + "\n" + + "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseb\x06proto3" + +var ( + file_process_proto_rawDescOnce sync.Once + file_process_proto_rawDescData []byte +) + +func file_process_proto_rawDescGZIP() []byte { + file_process_proto_rawDescOnce.Do(func() { + file_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc))) + }) + return file_process_proto_rawDescData +} + +var file_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_proto_init() } +func file_process_proto_init() { + if File_process_proto != nil { + return + } + file_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_proto_goTypes, + DependencyIndexes: file_process_proto_depIdxs, + EnumInfos: file_process_proto_enumTypes, + MessageInfos: file_process_proto_msgTypes, + }.Build() + File_process_proto = out.File + file_process_proto_goTypes = nil + file_process_proto_depIdxs = nil +} diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto b/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto new file mode 100644 index 000000000..99376a0e3 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto @@ -0,0 +1,171 @@ +syntax = "proto3"; + +package process; + +service Process { + rpc List(ListRequest) returns (ListResponse); + + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + + rpc Update(UpdateRequest) returns (UpdateResponse); + + // Client input stream ensures ordering of messages + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go new file mode 100644 index 000000000..8ad05ee18 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -0,0 +1,130 @@ +// Package e2b implements compute lifecycle for the colocated Runtime. It does not +// implement model execution, public Files, or a second Runtime transport. +package e2b + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +type Config struct { + InstallationID, APIKey, Template string + // LeaseSeconds must exceed Core's one-hour disconnect grace. Renewal changes + // the expiry of the original running VM; it never resumes or replaces it. + LeaseSeconds int +} +type Provider struct { + config Config + client *http.Client +} + +var _ sandbox.Provider = (*Provider)(nil) + +func validID(v string) bool { + u, e := uuid.Parse(v) + return e == nil && u != uuid.Nil && u.String() == v +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} + +func New(config Config) (*Provider, error) { + parts := strings.Split(config.Template, ":") + if !validID(config.InstallationID) || strings.TrimSpace(config.APIKey) == "" || len(parts) != 2 || !validID(parts[1]) || parts[0] == "" || strings.Trim(parts[0], "abcdefghijklmnopqrstuvwxyz0123456789") != "" || config.LeaseSeconds < 7200 || config.LeaseSeconds > 86400 { + return nil, sandbox.ErrInvalid + } + return &Provider{config: config, client: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("E2B redirects are not allowed") }}}, nil +} +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info := sandbox.Info{Reference: b.Reference} + u, e := url.Parse(b.CoreURL) + if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" || (b.NetworkAccess != "enabled" && b.NetworkAccess != "disabled" && b.NetworkAccess != "") { + return info, sandbox.ErrInvalid + } + existing, e := p.allocations(ctx, b.Reference) + if e != nil { + return info, e + } + if len(existing) != 0 { + return info, sandbox.ErrExists + } + var a allocation + metadata := p.metadata(b.Reference) + metadata[metadataPrefix+"session"] = b.SessionID + metadata[metadataPrefix+"device"] = b.DeviceID + _, e = p.request(ctx, http.MethodPost, "/sandboxes", map[string]any{"templateID": p.config.Template, "timeout": p.config.LeaseSeconds, "secure": true, "autoPause": false, "allowInternetAccess": true, "metadata": metadata}, &a) + if e != nil { + return info, e + } + info.ProviderID = a.ID + // Re-read authenticated identity and envd authority. Running compute is not + // evidence that credential injection and daemon launch have completed. + a, e = p.inspectID(ctx, a.ID, b.Reference) + if e != nil { + return info, e + } + if e = p.bootstrap(ctx, a, b); e != nil { + return info, e + } + return p.GetInfo(ctx, b.Reference) +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + a, e := p.inspect(ctx, r) + if e != nil { + return info, e + } + info.ProviderID, info.State = a.ID, a.State + if a.State == "running" { + info.BootstrapComplete, e = p.completed(ctx, a, r) + } + return info, e +} +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + a, e := p.inspect(ctx, r) + if e != nil { + return info, e + } + info.ProviderID, info.State = a.ID, a.State + if a.State != "running" { + return info, errors.New("E2B allocation is not running") + } + _, e = p.request(ctx, http.MethodPost, "/sandboxes/"+url.PathEscape(a.ID)+"/timeout", map[string]int{"timeout": p.config.LeaseSeconds}, nil) + if e != nil { + return info, e + } + return p.GetInfo(ctx, r) +} +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + all, e := p.allocations(ctx, r) + if e != nil { + return e + } + // An anomalous duplicate may be reclaimed only after all exact owners are + // checked. No execution operation chooses an arbitrary duplicate. + for _, a := range all { + if _, e = p.inspectID(ctx, a.ID, r); e != nil && !errors.Is(e, sandbox.ErrNotFound) { + return e + } + } + for _, a := range all { + if _, e = p.request(ctx, http.MethodDelete, "/sandboxes/"+url.PathEscape(a.ID), nil, nil); e != nil && !errors.Is(e, sandbox.ErrNotFound) { + return e + } + } + remaining, e := p.allocations(ctx, r) + if e != nil { + return e + } + if len(remaining) != 0 { + return errors.New("E2B removal unconfirmed") + } + return nil +} diff --git a/services/agents-api/internal/sandbox/e2b/provider_real_test.go b/services/agents-api/internal/sandbox/e2b/provider_real_test.go new file mode 100644 index 000000000..9d77cce67 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/provider_real_test.go @@ -0,0 +1,183 @@ +package e2b + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +// This suite uses actual E2B VMs. Public real-model acceptance is separate; a +// completed bootstrap intentionally does not assert that its daemon authenticated. +func TestRealE2BLifecycle(t *testing.T) { + keyFile, template := os.Getenv("PARSAR_E2B_TEST_KEY_FILE"), os.Getenv("PARSAR_E2B_TEST_TEMPLATE") + if keyFile == "" || template == "" { + t.Skip("explicit real E2B account and qualified pinned template required") + } + key, e := os.ReadFile(keyFile) + if e != nil { + t.Fatal("cannot read private E2B key") + } + config := Config{InstallationID: uuid.NewString(), APIKey: strings.TrimSpace(string(key)), Template: template, LeaseSeconds: 7200} + p, e := New(config) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) + defer cancel() + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://example.com/api/v1", Credential: uuid.NewString(), NetworkAccess: "enabled"} + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if e := p.Kill(cleanup, b.Reference); e != nil { + t.Error("real E2B cleanup", e) + } + }) + info, e := p.Create(ctx, b) + if e != nil { + t.Fatal("real create", e) + } + if info.ProviderID == "" || info.State != "running" || !info.BootstrapComplete { + t.Fatal("incomplete real bootstrap") + } + t.Log("real Create and completed bootstrap") + for _, input := range [][]byte{{}, bytes.Repeat([]byte{0, 255, 10, 1, 42}, 10485760)} { + result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "head -c 131072 /dev/zero; sha256sum"}, Stdin: input}) + digest := sha256.Sum256(input) + if err != nil || result.ExitCode != 0 || !strings.HasSuffix(result.Stdout, hex.EncodeToString(digest[:])+" -\n") || len(result.Stdout) != 131072+68 { + t.Fatalf("real stdin/EOF failure: input=%d stdout=%d exit=%d error=%v", len(input), len(result.Stdout), result.ExitCode, err) + } + } + t.Log("real binary stdin, concurrent output and EOF") + protection, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", `import os, subprocess +for path in ['/usr/local/bin/parsar-daemon', '/opt/parsar-e2b/init.py', '/usr/bin/envd']: + assert os.stat(path).st_uid == 0 and os.stat(path).st_mode & 0o022 == 0 + try: + fd = os.open(path, os.O_WRONLY | os.O_APPEND) + except PermissionError: + pass + else: + os.close(fd) + raise AssertionError('runtime can modify trusted code') +for path in ['/usr/local', '/usr/local/bin', '/opt/parsar-e2b']: + try: + fd = os.open(path + '/e2b-unsafe-write', os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except PermissionError: + pass + else: + os.close(fd) + raise AssertionError('runtime can replace trusted code') +r = subprocess.run(['su', 'user', '-c', 'id -u'], input='', text=True, capture_output=True, timeout=5) +assert r.returncode != 0, 'runtime can assume the passwordless privileged account' +print('protected')`}}) + if e != nil || protection.ExitCode != 0 || protection.Stdout != "protected\n" { + t.Fatal("real Runtime executable/account protection failed", e) + } + t.Log("real unprivileged writes and privileged account transition denied") + fresh, e := New(config) + if e != nil { + t.Fatal(e) + } + observed, e := fresh.GetInfo(ctx, b.Reference) + if e != nil || observed != info { + t.Fatal("fresh provider lost allocation", e) + } + if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { + t.Fatal("duplicate allocation admitted", e) + } + foreign := b.Reference + foreign.TenantID = uuid.NewString() + if _, e = p.GetInfo(ctx, foreign); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("foreign inspection", e) + } + if _, e = p.RunCommand(ctx, foreign, sandbox.Command{Args: []string{"/usr/bin/id"}}); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("foreign initialization", e) + } + if e = p.Kill(ctx, foreign); e != nil { + t.Fatal(e) + } + if _, e = p.GetInfo(ctx, b.Reference); e != nil { + t.Fatal("foreign cleanup changed owner", e) + } + t.Log("restart lookup, duplicate prevention and foreign ownership") + var before, after struct { + End time.Time `json:"endAt"` + } + _, e = p.request(ctx, http.MethodGet, "/sandboxes/"+info.ProviderID, nil, &before) + if e != nil { + t.Fatal(e) + } + renewed, e := p.Renew(ctx, b.Reference) + if e != nil || renewed.ProviderID != info.ProviderID { + t.Fatal("renew replaced allocation", e) + } + _, e = p.request(ctx, http.MethodGet, "/sandboxes/"+info.ProviderID, nil, &after) + if e != nil || !after.End.After(before.End) { + t.Fatal("lease did not advance", e) + } + result, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", "import os,sys; print(os.getuid()); print(sys.argv[1]); print('stderr-proof',file=sys.stderr); sys.exit(7)", "literal;$(not-a-shell)"}, Directory: "/workspace"}) + if e != nil || result.ExitCode != 7 || result.Stdout != "1000\nliteral;$(not-a-shell)\n" || result.Stderr != "stderr-proof\n" { + t.Fatal("real argv/user/exit/output differ", e) + } + t.Log("real Renew and unprivileged argv-preserving RunCommand") + a, e := p.inspect(ctx, b.Reference) + if e != nil { + t.Fatal(e) + } + if _, e = p.file(ctx, a, bootstrapReceipt, []byte(`{"TenantID":"foreign"}`)); e != nil { + t.Fatal(e) + } + if _, e = p.GetInfo(ctx, b.Reference); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("bad bootstrap receipt accepted", e) + } + uncertain, stop := context.WithTimeout(ctx, 5*time.Second) + _, e = p.RunCommand(uncertain, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", "import time; open('/workspace/command-started','w').write('started'); time.sleep(30)"}}) + stop() + if !errors.Is(e, sandbox.ErrCommandUnconfirmed) && !errors.Is(e, context.DeadlineExceeded) { + t.Fatal("uncertain command reported success", e) + } + started, readErr := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/bin/cat", "/workspace/command-started"}}) + if readErr != nil || started.Stdout != "started" { + t.Fatal("timeout fixture never started its actual process", readErr) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal("real Kill", e) + } + if _, e = p.GetInfo(ctx, b.Reference); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("removal not confirmed", e) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal("repeated Kill", e) + } + t.Log("invalid receipt, uncertain command and confirmed idempotent cleanup") + + // Model a lost Create acknowledgement with a real cloud allocation that has + // never received bootstrap. Observation must not launch a daemon or recreate it. + b.AllocationID = uuid.NewString() + metadata := p.metadata(b.Reference) + var partial allocation + _, e = p.request(ctx, http.MethodPost, "/sandboxes", map[string]any{"templateID": config.Template, "timeout": 120, "secure": true, "metadata": metadata}, &partial) + if e != nil { + t.Fatal(e) + } + pending, e := fresh.GetInfo(ctx, b.Reference) + if e != nil || pending.ProviderID != partial.ID || pending.BootstrapComplete { + t.Fatal("running VM mistaken for initialized Runtime", e) + } + if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { + t.Fatal("unconfirmed Create replayed", e) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + t.Log("lost response recovery observes incomplete bootstrap without replay") +} diff --git a/services/agents-api/internal/sandbox/provider.go b/services/agents-api/internal/sandbox/provider.go new file mode 100644 index 000000000..485cbbe0b --- /dev/null +++ b/services/agents-api/internal/sandbox/provider.go @@ -0,0 +1,58 @@ +// Package sandbox manages compute for an already authorized Environment. +// It does not schedule Turns or implement routine execution and Files operations. +package sandbox + +import ( + "context" + "errors" +) + +var ( + ErrInvalid = errors.New("invalid sandbox configuration") + ErrOwnership = errors.New("sandbox ownership mismatch") + ErrExists = errors.New("sandbox allocation already exists") + ErrNotFound = errors.New("sandbox allocation not found") + ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") +) + +// Reference must be persisted by the caller before Create. AllocationID is a fresh +// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for +// an allocation; a lost Create response is resolved with GetInfo, never by replay. +type Reference struct{ TenantID, EnvironmentID, AllocationID string } + +type Bootstrap struct { + Reference + SessionID, DeviceID, CoreURL, Credential string + NetworkAccess string +} + +// Info describes compute only. Running does not establish daemon authentication, +// native preparation, Environment readiness or a renewable provider lease. +type Info struct { + Reference + ProviderID, State string + // BootstrapComplete is provider evidence that initialization has reached its + // last mutating step. It does not establish daemon or native readiness. + BootstrapComplete bool +} +type Command struct { + Args []string + Directory string + // Stdin carries confidential initialization bytes without exposing them in argv. + Stdin []byte +} + +const MaxCommandInputBytes = 50*1024*1024 + 32 + +type CommandResult struct { + Stdout, Stderr string + ExitCode int +} + +type Provider interface { + Create(context.Context, Bootstrap) (Info, error) + GetInfo(context.Context, Reference) (Info, error) + Renew(context.Context, Reference) (Info, error) + Kill(context.Context, Reference) error + RunCommand(context.Context, Reference, Command) (CommandResult, error) +} diff --git a/services/agents-api/internal/store/agents.go b/services/agents-api/internal/store/agents.go new file mode 100644 index 000000000..79601d2f6 --- /dev/null +++ b/services/agents-api/internal/store/agents.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// SavedAgent is reusable configuration owned by an execution tenant. It has no +// engine binding or live execution state; Session snapshots are separate objects. +type SavedAgent struct { + ID string + TenantID string + Metadata map[string]string + Configuration json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateAgentInput struct { + Metadata map[string]string + Configuration json.RawMessage +} + +// CreateAgent stores caller-validated, credential-free configuration. Public +// defaults and schema validation belong to the API, not an execution adapter. +// Each call creates a new resource; this primitive supplies no retry semantics. +func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAgentInput) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + metadata, err := encodeMetadata(input.Metadata) + if err != nil { + return SavedAgent{}, err + } + if len(input.Configuration) == 0 || len(input.Configuration) > 512*1024 { + return SavedAgent{}, fmt.Errorf("%w: configuration must be an object of at most 512 KiB", ErrInvalidInput) + } + configuration, err := canonicalJSONObject(input.Configuration) + if err != nil { + return SavedAgent{}, err + } + row, err := s.queries.CreateAgent(ctx, sqlc.CreateAgentParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Metadata: metadata, Configuration: configuration, + }) + if err != nil { + return SavedAgent{}, fmt.Errorf("create agent: %w", err) + } + return agentFromRow(row) +} + +// GetAgent scopes every lookup to the authenticated caller's tenant. +func (s *Store) GetAgent(ctx context.Context, tenantID, agentID string) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + id, err := parseID(agentID) + if err != nil { + return SavedAgent{}, err + } + row, err := s.queries.GetAgent(ctx, sqlc.GetAgentParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return SavedAgent{}, ErrNotFound + } + if err != nil { + return SavedAgent{}, fmt.Errorf("get agent: %w", err) + } + return agentFromRow(row) +} + +func agentFromRow(row sqlc.Agent) (SavedAgent, error) { + agent := SavedAgent{ + ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), + Configuration: row.Configuration, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } + if err := json.Unmarshal(row.Metadata, &agent.Metadata); err != nil { + return SavedAgent{}, fmt.Errorf("decode agent metadata: %w", err) + } + return agent, nil +} diff --git a/services/agents-api/internal/store/agents_delete.go b/services/agents-api/internal/store/agents_delete.go new file mode 100644 index 000000000..0ed066b13 --- /dev/null +++ b/services/agents-api/internal/store/agents_delete.go @@ -0,0 +1,31 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// DeleteAgent removes a saved resource independently of execution snapshots. +func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", err + } + id, err := parseID(agentID) + if err != nil { + return "", err + } + deleted, err := s.queries.DeleteAgent(ctx, sqlc.DeleteAgentParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("delete agent: %w", err) + } + return uuid.UUID(deleted.Bytes).String(), nil +} diff --git a/services/agents-api/internal/store/agents_delete_public_test.go b/services/agents-api/internal/store/agents_delete_public_test.go new file mode 100644 index 000000000..a3c732baf --- /dev/null +++ b/services/agents-api/internal/store/agents_delete_public_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestAgentDeletionOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + h, err = api.NewHandler(store.New(pool), auth, "codex") + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(h) + defer recovered.Close() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, python, "../../tests/official_agent_delete.py", server.URL, token, foreign, recovered.URL) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("official Agent deletion: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/agents-api/internal/store/agents_list.go b/services/agents-api/internal/store/agents_list.go new file mode 100644 index 000000000..6f665b26a --- /dev/null +++ b/services/agents-api/internal/store/agents_list.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type AgentPage struct { + Agents []SavedAgent + NextCursor string +} + +func (s *Store) ListAgents(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (AgentPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return AgentPage{}, err + } + if limit < 1 || limit > 100 { + return AgentPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) + } + params := sqlc.ListAgentsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetAgent(ctx, tenantID, cursor) + if err != nil { + return AgentPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListAgents(ctx, params) + if err != nil { + return AgentPage{}, fmt.Errorf("list agents: %w", err) + } + page := AgentPage{Agents: make([]SavedAgent, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + agent, err := agentFromRow(row) + if err != nil { + return AgentPage{}, err + } + page.Agents = append(page.Agents, agent) + } + return page, nil +} diff --git a/services/agents-api/internal/store/agents_list_test.go b/services/agents-api/internal/store/agents_list_test.go new file mode 100644 index 000000000..f7a62c66f --- /dev/null +++ b/services/agents-api/internal/store/agents_list_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "errors" + "reflect" + "slices" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestAgentListPaginationIsolationAndReconnect(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant, other := uuid.NewString(), uuid.NewString() + empty, err := s.ListAgents(ctx, tenant, "", 2, false) + if err != nil || empty.Agents == nil || len(empty.Agents) != 0 || empty.NextCursor != "" { + t.Fatalf("empty page: %+v, %v", empty, err) + } + input := CreateAgentInput{Configuration: []byte(`{"model":"unchanged","tools":[{"parameters":{"const":9007199254740993}}]}`), Metadata: map[string]string{"scope": "same-tenant"}} + ids := []string{} + for range 5 { + agent, err := s.CreateAgent(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + ids = append(ids, agent.ID) + } + foreign, err := s.CreateAgent(ctx, other, input) + if err != nil { + t.Fatal(err) + } + stamp := time.Unix(1700000000, 0).UTC() + if _, err := pool.Exec(ctx, "UPDATE agents SET created_at=$1, updated_at=$1 WHERE tenant_id=$2", stamp, tenant); err != nil { + t.Fatal(err) + } + slices.Sort(ids) + read := func(s *Store, ascending bool) []string { + t.Helper() + var actual []string + cursor := "" + for { + page, err := s.ListAgents(ctx, tenant, cursor, 2, ascending) + if err != nil { + t.Fatal(err) + } + if len(page.Agents) == 0 || len(page.Agents) > 2 { + t.Fatalf("bad page: %+v", page) + } + for _, agent := range page.Agents { + original, err := s.GetAgent(ctx, tenant, agent.ID) + if err != nil || !reflect.DeepEqual(agent, original) || agent.TenantID != tenant { + t.Fatalf("resource changed: %+v, %v", agent, err) + } + actual = append(actual, agent.ID) + } + if page.NextCursor == "" { + break + } + if page.NextCursor != page.Agents[len(page.Agents)-1].ID || len(actual) > len(ids) { + t.Fatal("invalid/repeating continuation") + } + cursor = page.NextCursor + } + return actual + } + if got := read(s, true); !slices.Equal(got, ids) { + t.Fatalf("ascending equal timestamps: %v", got) + } + reverse := slices.Clone(ids) + slices.Reverse(reverse) + if got := read(s, false); !slices.Equal(got, reverse) { + t.Fatalf("descending equal timestamps: %v", got) + } + for _, after := range []string{foreign.ID, uuid.NewString()} { + if _, err := s.ListAgents(ctx, tenant, after, 2, true); !errors.Is(err, ErrNotFound) { + t.Fatalf("unowned/unknown cursor accepted: %v", err) + } + } + if _, err := s.ListAgents(ctx, tenant, "not-an-id", 2, true); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid cursor accepted: %v", err) + } + tail, err := s.ListAgents(ctx, tenant, ids[len(ids)-1], 2, true) + if err != nil || tail.Agents == nil || len(tail.Agents) != 0 || tail.NextCursor != "" { + t.Fatalf("end page: %+v, %v", tail, err) + } + foreignPage, err := s.ListAgents(ctx, other, "", 100, false) + if err != nil || len(foreignPage.Agents) != 1 || foreignPage.Agents[0].ID != foreign.ID { + t.Fatalf("tenant isolation: %+v, %v", foreignPage, err) + } + pool.Close() + restored, _ := testStore(t) + if got := read(restored, true); !slices.Equal(got, ids) { + t.Fatalf("pagination changed after reconnect: %v", got) + } +} diff --git a/services/agents-api/internal/store/agents_test.go b/services/agents-api/internal/store/agents_test.go new file mode 100644 index 000000000..c1c6edaa1 --- /dev/null +++ b/services/agents-api/internal/store/agents_test.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestSavedAgentsPersistIndependentlyAndStayTenantScoped(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenantA, tenantB := uuid.NewString(), uuid.NewString() + session, err := s.CreateSession(ctx, tenantA, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "session"}) + if err != nil { + t.Fatal(err) + } + // Configuration is preserved without applying one harness's capabilities. + input := CreateAgentInput{ + Metadata: map[string]string{"purpose": "保存 configuration"}, + Configuration: []byte(`{"model":" caller-model ","name":null,"instructions":" keep whitespace ","multi_agent":{"enabled":true,"max_concurrent_subagents":6},"tools":[{"type":"function","name":"lookup","description":"","defer_loading":true,"parameters":{"type":"object","properties":{"number":{"const":9007199254740993}}}}]}`), + } + before := time.Now().Add(-time.Second) + first, err := s.CreateAgent(ctx, tenantA, input) + if err != nil { + t.Fatal(err) + } + expectedConfig, err := canonicalJSONObject(input.Configuration) + if err != nil { + t.Fatal(err) + } + gotConfig, err := canonicalJSONObject(first.Configuration) + if err != nil || string(gotConfig) != string(expectedConfig) { + t.Fatalf("configuration changed: %s, %v", first.Configuration, err) + } + if first.ID == session.ID || first.TenantID != tenantA || !reflect.DeepEqual(first.Metadata, input.Metadata) || + first.CreatedAt.Before(before) || first.CreatedAt.After(time.Now().Add(time.Second)) || !first.CreatedAt.Equal(first.UpdatedAt) { + t.Fatalf("unexpected saved agent: %+v", first) + } + // Identical configurations are distinct resources; storage invents no public + // create-idempotency contract or shared identity across callers. + for _, tenant := range []string{tenantA, tenantB} { + other, err := s.CreateAgent(ctx, tenant, input) + if err != nil || other.ID == first.ID || other.TenantID != tenant { + t.Fatalf("distinct create: %+v, %v", other, err) + } + } + for _, lookup := range []struct{ tenant, id string }{ + {tenantB, first.ID}, {tenantA, uuid.NewString()}, {tenantA, session.ID}, + } { + if _, err := s.GetAgent(ctx, lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { + t.Fatalf("unowned/absent agent read: %v", err) + } + } + if _, err := s.GetSession(ctx, tenantA, first.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("saved agent became an execution session: %v", err) + } + unchanged, err := s.GetSession(ctx, tenantA, session.ID) + if err != nil || !reflect.DeepEqual(unchanged, session) { + t.Fatalf("saved Agent storage changed Session: %+v, %v", unchanged, err) + } + pool.Close() + recovered, _ := testStore(t) + got, err := recovered.GetAgent(ctx, tenantA, first.ID) + if err != nil || !reflect.DeepEqual(got, first) { + t.Fatalf("durable read: %+v, %v; want %+v", got, err, first) + } + emptyMetadata, err := recovered.CreateAgent(ctx, tenantA, CreateAgentInput{Configuration: []byte(`{"model":"another-model"}`)}) + if err != nil || emptyMetadata.Metadata == nil || len(emptyMetadata.Metadata) != 0 { + t.Fatalf("empty metadata: %+v, %v", emptyMetadata, err) + } +} + +func TestSavedAgentsRejectInvalidStoreInput(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + for _, raw := range []string{"", "null", "[]", "true", `{"model":"x"`, `{} {}`, `{"x":"` + strings.Repeat("x", 512*1024) + `"}`} { + if _, err := s.CreateAgent(ctx, tenant, CreateAgentInput{Configuration: []byte(raw)}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid configuration length %d: %v", len(raw), err) + } + } + valid := CreateAgentInput{Configuration: []byte(`{"model":"x"}`)} + for _, invalid := range []string{"", "not-a-uuid", uuid.Nil.String()} { + if _, err := s.CreateAgent(ctx, invalid, valid); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid tenant accepted: %v", err) + } + if _, err := s.GetAgent(ctx, invalid, uuid.NewString()); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid read tenant accepted: %v", err) + } + if _, err := s.GetAgent(ctx, tenant, invalid); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid ID accepted: %v", err) + } + } + valid.Metadata = map[string]string{"large": strings.Repeat("x", 64*1024)} + if _, err := s.CreateAgent(ctx, tenant, valid); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("oversized metadata accepted: %v", err) + } + var count int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM agents WHERE tenant_id = $1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatalf("rejected input wrote %d rows: %v", count, err) + } +} diff --git a/services/agents-api/internal/store/agents_update.go b/services/agents-api/internal/store/agents_update.go new file mode 100644 index 000000000..892d8a685 --- /dev/null +++ b/services/agents-api/internal/store/agents_update.go @@ -0,0 +1,96 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +// UpdateAgentInput contains validated top-level replacements, not a full snapshot. +// A nil metadata pointer preserves the existing map; a supplied map replaces it. +type UpdateAgentInput struct { + Configuration json.RawMessage + Metadata *map[string]string +} + +func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input UpdateAgentInput) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + id, err := parseID(agentID) + if err != nil { + return SavedAgent{}, err + } + raw := input.Configuration + if len(raw) == 0 { + raw = json.RawMessage(`{}`) + } + if len(raw) > 512*1024 { + return SavedAgent{}, ErrInvalidInput + } + raw, err = canonicalJSONObject(raw) + if err != nil { + return SavedAgent{}, err + } + var patch map[string]json.RawMessage + if err := json.Unmarshal(raw, &patch); err != nil { + return SavedAgent{}, err + } + var metadata []byte + if input.Metadata != nil { + metadata, err = encodeMetadata(*input.Metadata) + if err != nil { + return SavedAgent{}, err + } + } + if len(patch) == 0 && input.Metadata == nil { + return s.GetAgent(ctx, tenantID, agentID) + } + var updated SavedAgent + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.LockAgent(ctx, sqlc.LockAgentParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + var configuration map[string]json.RawMessage + if err := json.Unmarshal(row.Configuration, &configuration); err != nil { + return err + } + for field, value := range patch { + configuration[field] = value + } + merged, err := json.Marshal(configuration) + if err != nil { + return err + } + merged, err = canonicalJSONObject(merged) + if err != nil { + return err + } + if len(merged) > 512*1024 { + return ErrInvalidInput + } + if input.Metadata == nil { + metadata = row.Metadata + } + row, err = q.UpdateAgent(ctx, sqlc.UpdateAgentParams{TenantID: tenant, ID: id, Configuration: merged, Metadata: metadata}) + if err != nil { + return err + } + updated, err = agentFromRow(row) + return err + }) + if err != nil { + return SavedAgent{}, fmt.Errorf("update agent: %w", err) + } + return updated, nil +} diff --git a/services/agents-api/internal/store/agents_update_public_test.go b/services/agents-api/internal/store/agents_update_public_test.go new file mode 100644 index 000000000..86d4efc49 --- /dev/null +++ b/services/agents-api/internal/store/agents_update_public_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestAgentUpdateOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + h, err = api.NewHandler(store.New(pool), auth, "codex") + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(h) + defer recovered.Close() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, python, "../../tests/official_agent_update.py", server.URL, token, foreign, recovered.URL) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("official Agent update: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/agents-api/internal/store/agents_update_test.go b/services/agents-api/internal/store/agents_update_test.go new file mode 100644 index 000000000..55ee09628 --- /dev/null +++ b/services/agents-api/internal/store/agents_update_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestAgentUpdateRollbackAndCompleteSizeBound(t *testing.T) { + s, _ := testStore(t) + ctx := t.Context() + tenant := uuid.NewString() + configuration, err := json.Marshal(map[string]any{"model": "original", "instructions": strings.Repeat("x", 400*1024), "number": json.Number("9007199254740993")}) + if err != nil { + t.Fatal(err) + } + original, err := s.CreateAgent(ctx, tenant, CreateAgentInput{Configuration: configuration, Metadata: map[string]string{"keep": "original"}}) + if err != nil { + t.Fatal(err) + } + metadata := map[string]string{"replace": "not-committed"} + oversized, err := json.Marshal(map[string]string{"name": strings.Repeat("y", 150*1024)}) + if err != nil { + t.Fatal(err) + } + for _, patch := range []json.RawMessage{oversized, []byte(`[]`), []byte(`{"name":"bad"} {}`)} { + _, err := s.UpdateAgent(ctx, tenant, original.ID, UpdateAgentInput{Configuration: patch, Metadata: &metadata}) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid update: %v", err) + } + unchanged, err := s.GetAgent(ctx, tenant, original.ID) + if err != nil || !reflect.DeepEqual(unchanged, original) { + t.Fatalf("partial failed update: %v", err) + } + } + _, err = s.UpdateAgent(ctx, uuid.NewString(), original.ID, UpdateAgentInput{Configuration: []byte(`{"model":"foreign"}`), Metadata: &metadata}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign update: %v", err) + } + // A failure releases the lock; a later valid patch preserves unrelated large values and numbers. + updated, err := s.UpdateAgent(ctx, tenant, original.ID, UpdateAgentInput{Configuration: []byte(`{"model":"updated"}`)}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(updated.Configuration, &fields); err != nil { + t.Fatal(err) + } + if string(fields["number"]) != "9007199254740993" || string(fields["model"]) != `"updated"` || !reflect.DeepEqual(updated.Metadata, original.Metadata) { + t.Fatal("unrelated configuration or metadata lost") + } + if !updated.CreatedAt.Equal(original.CreatedAt) || updated.UpdatedAt.Before(original.UpdatedAt) { + t.Fatal("resource timestamps changed incorrectly") + } + unchanged, err := s.UpdateAgent(ctx, tenant, original.ID, UpdateAgentInput{}) + if err != nil || !reflect.DeepEqual(unchanged, updated) { + t.Fatalf("empty update: %v", err) + } +} diff --git a/services/agents-api/internal/store/artifact_capture.go b/services/agents-api/internal/store/artifact_capture.go new file mode 100644 index 000000000..4eeab6bb5 --- /dev/null +++ b/services/agents-api/internal/store/artifact_capture.go @@ -0,0 +1,135 @@ +package store + +import ( + "archive/tar" + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "io/fs" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const MaxArtifactBytes int64 = 200 << 20 +const MaxArtifactBatchBytes int64 = 500 << 20 + +// StageTurnArtifacts stores a complete export privately without locking admission during transfer. +func (s *Store) StageTurnArtifacts(ctx context.Context, tenantID, sessionID, turnID, environmentID string, input io.Reader) error { + lookup, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return err + } + environment, err := parseID(environmentID) + if err != nil || input == nil { + return ErrInvalidInput + } + // Authorize before reading caller-controlled bytes or allocating storage. + owned, err := s.GetSessionEnvironment(ctx, tenantID, sessionID) + if err != nil { + return err + } + if owned.ID != environmentID { + return ErrNotFound + } + var configuration struct { + Type string `json:"type"` + } + if err := json.Unmarshal(owned.Configuration, &configuration); err != nil { + return err + } + if configuration.Type != "openai_hosted" { + return ErrInvalidInput + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + rows, err := captureArtifactArchive(ctx, tx, input) + if err != nil { + return err + } + q := s.queries.WithTx(tx) + locked, err := q.LockSession(ctx, sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if locked.DeletedAt.Valid { + return ErrNotFound + } + turn, err := q.GetTurn(ctx, lookup) + if err != nil { + return err + } + if turn.Status != TurnInProgress || turn.CancelRequestedAt.Valid { + return ErrTurnConflict + } + for _, row := range rows { + row.SessionID, row.TurnID, row.EnvironmentID = lookup.SessionID, lookup.ID, environment + if err := q.StageSessionArtifact(ctx, row); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func captureArtifactArchive(ctx context.Context, tx pgx.Tx, input io.Reader) ([]sqlc.StageSessionArtifactParams, error) { + archive := tar.NewReader(input) + objects := tx.LargeObjects() + rows := make([]sqlc.StageSessionArtifactParams, 0) + seen := make(map[string]bool) + var total int64 + for { + header, err := archive.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if header.Typeflag != tar.TypeReg || !strings.HasPrefix(header.Name, "outputs/") || !fs.ValidPath(header.Name) || strings.ContainsAny(header.Name, "\\\x00\r\n") || len(header.Name) > 4096 || header.Size < 0 || header.Size > MaxArtifactBytes || header.Size > MaxArtifactBatchBytes-total || len(rows) >= 4096 || seen[header.Name] { + return nil, ErrInvalidInput + } + seen[header.Name] = true + total += header.Size + oid, err := objects.Create(ctx, 0) + if err != nil { + return nil, err + } + body, err := objects.Open(ctx, oid, pgx.LargeObjectModeWrite) + if err != nil { + return nil, err + } + writer := newSourceFileWriter(body) + if _, err := io.CopyN(writer, archive, header.Size); err != nil { + return nil, err + } + if err := body.Close(); err != nil { + return nil, err + } + rows = append(rows, sqlc.StageSessionArtifactParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, Path: "/workspace/" + header.Name, SizeBytes: writer.size, BodyOid: pgtype.Uint32{Uint32: oid, Valid: true}, Sha256: hex.EncodeToString(writer.hash.Sum(nil))}) + } + // Require transport EOF after the archive trailer, including confirmed helper exit. + padding, err := io.ReadAll(io.LimitReader(input, 32769)) + if err != nil { + return nil, err + } + if len(padding) > 32768 { + return nil, ErrInvalidInput + } + for _, b := range padding { + if b != 0 { + return nil, ErrInvalidInput + } + } + return rows, nil +} diff --git a/services/agents-api/internal/store/artifact_lifecycle.go b/services/agents-api/internal/store/artifact_lifecycle.go new file mode 100644 index 000000000..be0b004f1 --- /dev/null +++ b/services/agents-api/internal/store/artifact_lifecycle.go @@ -0,0 +1,53 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// BeginTurnArtifactCapture separates native completion from bounded output publication. +// Later messages use the existing reservation path instead of the finished executor. +func (s *Store) BeginTurnArtifactCapture(ctx context.Context, tenantID, sessionID, turnID string, appliedThrough int64) error { + lookup, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return err + } + if appliedThrough < 0 { + return ErrInvalidInput + } + return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + turn, err := q.GetTurn(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if turn.Status != TurnInProgress || turn.CancelRequestedAt.Valid { + return ErrTurnConflict + } + pending, err := q.HasUnappliedMessages(ctx, sqlc.HasUnappliedMessagesParams{SessionID: session, TurnID: lookup.ID, Sequence: appliedThrough}) + if err != nil { + return err + } + if pending { + return ErrUnappliedInputs + } + count, err := q.BeginTurnArtifactCapture(ctx, sqlc.BeginTurnArtifactCaptureParams{SessionID: session, ID: lookup.ID}) + if err == nil && count != 1 { + return ErrTurnConflict + } + return err + }) +} + +func settleTurnArtifacts(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn) error { + if turn.Status == TurnCompleted { + return q.PublishTurnArtifacts(ctx, sqlc.PublishTurnArtifactsParams{SessionID: turn.SessionID, TurnID: turn.ID, CreatedAt: turn.CompletedAt}) + } + return q.DeleteUnpublishedTurnArtifacts(ctx, sqlc.DeleteUnpublishedTurnArtifactsParams{SessionID: turn.SessionID, TurnID: turn.ID}) +} diff --git a/services/agents-api/internal/store/claude_execution_test.go b/services/agents-api/internal/store/claude_execution_test.go new file mode 100644 index 000000000..abda0543f --- /dev/null +++ b/services/agents-api/internal/store/claude_execution_test.go @@ -0,0 +1,173 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func claudeSession(t *testing.T, h *dispatchHarness, configuration string, prebound bool) { + t.Helper() + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "claude_sdk", IdempotencyKey: "claude", Configuration: json.RawMessage(configuration)}) + if err != nil { + t.Fatal(err) + } + if prebound { + if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + } +} + +func claudeHeartbeat(t *testing.T, h *dispatchHarness, ready bool) { + t.Helper() + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: ready, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, FunctionTools: true, ToolObservations: true} + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, found, known := peer.AgentKindStatus("claude_sdk") + if known && found && info.Capabilities.DurableInputReceipts == ready { + return + } + if time.Now().After(deadline) { + t.Fatal("Claude capability heartbeat missing") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) { + for _, prebound := range []bool{false, true} { + t.Run(fmt.Sprint(prebound), func(t *testing.T) { + h := newFunctionHarness(t) + claudeSession(t, h, functionConfiguration, prebound) + input := h.message("start", "Look up ticket") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + queued := func() { + time.Sleep(650 * time.Millisecond) + turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } + if !prebound { + if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("bound an incapable device", err) + } + } + } + queued() // A fully capable Codex descriptor cannot execute a Claude Session. + claudeHeartbeat(t, h, false) + queued() // Durable application receipts are required for this engine too. + claudeHeartbeat(t, h, true) + var prompt proto.PromptRequestPayload + if h.read(proto.TypePromptRequest).DecodePayload(&prompt) != nil { + t.Fatal("invalid prompt") + } + if prompt.AgentKind != "claude_sdk" || len(prompt.FunctionTools) != 1 || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) { + t.Fatal(prompt) + } + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "claude-native"}}) + waitTurn(t, h, input.TurnID, store.TurnCompleted) + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "claude-native" { + t.Fatal(bound, err) + } + }) + } +} + +func TestClaudeDispatcherRejectsUnsupportedConfigurationBeforeClaim(t *testing.T) { + for _, configuration := range []string{ + strings.Replace(functionConfiguration, `"model":`, `"text":{"verbosity":"high"},"model":`, 1), + strings.Replace(functionConfiguration, `"type":"object",`, ``, 1), + } { + t.Run(configuration, func(t *testing.T) { + h := newDispatchHarness(t) + claudeSession(t, h, configuration, true) + claudeHeartbeat(t, h, true) + input := h.message("start", "Run") + if result := <-h.run(t.Context(), input.TurnID); result.err == nil { + t.Fatal("unsupported configuration claimed") + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } + }) + } +} + +func TestClaudeImageResultRejectsWholeBatchBeforePersistence(t *testing.T) { + h := newDispatchHarness(t) + claudeSession(t, h, functionConfiguration, false) + worker, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + defer func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = worker.Run(ctx) }() + input := h.message("start", "Run") + if _, err := h.s.TransitionTurn(t.Context(), h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + call := store.FunctionCall{CallID: "public-call", ExecutorCallID: "native-call", Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)} + if err := h.s.RecordFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, call); err != nil { + t.Fatal(err) + } + result := func(raw string) store.Input { + payload, err := json.Marshal(store.FunctionResultInput{TurnID: input.TurnID, CallID: call.CallID, Result: json.RawMessage(raw)}) + if err != nil { + t.Fatal(err) + } + return store.Input{Kind: "tool_result", Payload: payload} + } + batch := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}, result(`{"success":true,"output":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} + if _, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "batch", batch); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal(err) + } + saved, err := h.s.GetFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, call.CallID) + if err != nil || saved.Result != nil || saved.Applied { + t.Fatal(saved, err) + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnWaiting || !turn.CancelRequestedAt.IsZero() { + t.Fatal(turn, err) + } + history, err := h.s.ListTurnInputs(t.Context(), h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil || len(history) != 1 { + t.Fatal(history, err) + } + batch[1] = result(`{"success":false,"output":[{"type":"input_text","text":"Partial result"}],"error":"Tool failed"}`) + receipts, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "batch", batch) + if err != nil || len(receipts) != 3 { + t.Fatal(receipts, err) + } + retry, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "batch", batch) + if err != nil || len(retry) != 3 || !retry[0].Replayed { + t.Fatal(retry, err) + } +} diff --git a/services/agents-api/internal/store/claude_mcp_test.go b/services/agents-api/internal/store/claude_mcp_test.go new file mode 100644 index 000000000..87a7bbc7d --- /dev/null +++ b/services/agents-api/internal/store/claude_mcp_test.go @@ -0,0 +1,133 @@ +package store_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { + for _, requirement := range []string{"anonymous", "bearer", "required"} { + authenticated, required := requirement == "bearer", requirement == "required" + for _, prebound := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/bound=%t", requirement, prebound), func(t *testing.T) { + h := newFunctionHarness(t) + configuration, token := mcpWorkerConfiguration, "" + if authenticated { + configuration, token = mcpBearerWorkerConfiguration(t, h) + } + if required { + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + } + claudeSession(t, h, configuration, prebound) + // Base MCP support does not imply authentication or required initialization. + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: authenticated || required} + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Claude MCP heartbeat", func() bool { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, found, known := peer.AgentKindStatus("claude_sdk") + return known && found && info.Capabilities.MCPHTTPTools == (authenticated || required) && !info.Capabilities.MCPHTTPBearerAuth && !info.Capabilities.MCPHTTPRequired + }) + input := h.message("start", "Use declared tools only") + if prebound { + if result := <-h.run(t.Context(), input.TurnID); result.err == nil { + t.Fatal("final preclaim accepted a runtime without MCP support") + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + time.Sleep(650 * time.Millisecond) + turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal("incapable runtime claimed work", turn, err) + } + if !prebound { + if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("bound an incapable runtime", err) + } + } + caps.MCPHTTPTools, caps.MCPHTTPBearerAuth = true, authenticated + caps.MCPHTTPRequired = required + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + var prompt proto.PromptRequestPayload + if h.read(proto.TypePromptRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { + t.Fatal("missing typed MCP dispatch") + } + server := (*prompt.MCPHTTPServers)[0] + if server.ServerLabel != "tickets" || server.AllowedTools == nil || len(*server.AllowedTools) != 0 || server.Required != required || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents { + t.Fatal("MCP configuration changed during dispatch") + } + endpoint := "http://127.0.0.1:9191/mcp" + if authenticated { + endpoint = "https://mcp.example/tools" + } + if server.ServerURL != endpoint || (token != "" && (server.BearerToken == nil || *server.BearerToken != token)) || (token == "" && server.BearerToken != nil) { + t.Fatal("dispatch lost scoped authentication or authenticated an anonymous server") + } + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + waitTurn(t, h, input.TurnID, store.TurnCompleted) + }) + } + } +} + +func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { + for _, profile := range []string{"missing required capability", "reserved label"} { + t.Run(profile, func(t *testing.T) { + h := newDispatchHarness(t) + configuration := mcpWorkerConfiguration + switch profile { + case "missing required capability": + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + case "reserved label": + configuration = strings.Replace(configuration, `"tickets"`, `"functions"`, 1) + } + claudeSession(t, h, configuration, true) + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: true, MCPHTTPRequired: true, MCPHTTPBearerAuth: true} + if profile == "missing required capability" { + caps.MCPHTTPRequired = false + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + if info, _, _ := peer.AgentKindStatus("claude_sdk"); info.Capabilities.MCPHTTPBearerAuth { + break + } + if time.Now().After(deadline) { + t.Fatal("heartbeat missing") + } + time.Sleep(10 * time.Millisecond) + } + input := h.message("start", "Run") + if result := <-h.run(t.Context(), input.TurnID); result.err == nil { + t.Fatal("unsupported MCP configuration claimed") + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/command_output_test.go b/services/agents-api/internal/store/command_output_test.go new file mode 100644 index 000000000..ff5d27964 --- /dev/null +++ b/services/agents-api/internal/store/command_output_test.go @@ -0,0 +1,151 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + defer pool.Close() + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "command-output"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"run commands"}`)) + if err != nil { + t.Fatal(err) + } + if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + event := func(kind, raw string) store.ExecutionEvent { + return store.ExecutionEvent{Kind: kind, Payload: json.RawMessage(raw)} + } + batch := []store.ExecutionEvent{ + event("tool_call", `{"id":"cmd","stage":"before","observation":{"kind":"command","command":"run","status":"in_progress"}}`), + event("command_output", `{"id":"cmd","delta":"same\n"}`), + event("command_output", `{"id":"cmd","delta":"same\n"}`), + } + for range 2 { + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + before, _ := s.SessionEventCursor(ctx, tenant, session.ID) + // A bad command reference rolls back preceding valid fragments and their events. + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, []store.ExecutionEvent{ + event("command_output", `{"id":"cmd","delta":"rollback"}`), + event("command_output", `{"id":"unknown","delta":"orphan"}`), + }); err == nil { + t.Fatal("unknown command accepted") + } + after, _ := s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("rollback published output") + } + page, err := store.New(pool).ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 2 { + t.Fatalf("read draft: %+v %v", page, err) + } + if page.Items[1].Output != "same\nsame\n" { + t.Fatal("draft output lost", page.Items[1]) + } + final := []store.ExecutionEvent{ + event("tool_call", `{"id":"cmd","stage":"after","observation":{"kind":"command","command":"run","status":"completed","output":"authoritative","exit_code":0}}`), + event("command_output", `{"id":"cmd","delta":"late"}`), + event("tool_call", `{"id":"partial","stage":"before","observation":{"kind":"command","command":"wait","status":"in_progress"}}`), + event("command_output", `{"id":"partial","delta":"已观察\n"}`), + } + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { + t.Fatal(err) + } + if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + // Reopening the Store recovers committed Items without creating events. + reopened := store.New(pool) + before, _ = s.SessionEventCursor(ctx, tenant, session.ID) + page, err = reopened.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 3 { + t.Fatalf("recovery: %+v %v", page, err) + } + if page.Items[1].Status != "completed" || page.Items[1].Output != "authoritative" || page.Items[2].Status != "incomplete" || page.Items[2].Output != "已观察\n" { + t.Fatal("completion/cancellation lost command output", page.Items) + } + after, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("query replayed events") + } + if _, err := reopened.ListSessionEvents(ctx, uuid.NewString(), session.ID, 0); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign event access", err) + } + var fragments []string + added, done := map[string]bool{}, map[string]bool{} + indexes := map[string]int32{} + cursor := int64(0) + for { + changes, err := reopened.ListSessionEvents(ctx, tenant, session.ID, cursor) + if err != nil { + t.Fatal(err) + } + if len(changes) == 0 { + break + } + for _, change := range changes { + e := change.Event + if e.Item != nil && e.Item.Type == "command_execution" { + if e.Type == "agent.session.turn.item.added" { + added[e.Item.ID] = true + indexes[e.Item.ID] = *e.OutputIndex + } + if e.Type == "agent.session.turn.item.done" { + done[e.Item.ID] = true + } + } + if e.Type == "agent.output.command_execution_output.delta" { + if !added[e.ItemID] || done[e.ItemID] || e.OutputIndex == nil || *e.OutputIndex != indexes[e.ItemID] || e.TurnID != input.TurnID || e.SessionID != session.ID || e.EventID == "" { + t.Fatal("invalid command delta identity/order", e) + } + fragments = append(fragments, *e.Delta) + } + cursor = change.Sequence + } + } + if !reflect.DeepEqual(fragments, []string{"same\n", "same\n", "已观察\n"}) || len(done) != 2 { + t.Fatal("incorrect live fragments", fragments, done) + } +} + +func TestExecutionJournalsCommandOutputBeforeCancellation(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("command", "run a command") + result := h.run(ctx, input.TurnID) + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "cmd", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "wait", Status: "in_progress"}}) + h.write(input.TurnID, proto.TypeCommandOutput, proto.CommandOutputPayload{ID: "cmd", Delta: "partial"}) + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + env := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + if err := env.DecodePayload(&cancel); err != nil { + t.Fatal(err) + } + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + h.finished(result, store.TurnCancelled) + page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || page.Items[1].Output != "partial" { + t.Fatalf("journal/cancellation lost partial output: %+v %v", page, err) + } +} diff --git a/services/agents-api/internal/store/credential_status_migration_test.go b/services/agents-api/internal/store/credential_status_migration_test.go new file mode 100644 index 000000000..e8b644ffe --- /dev/null +++ b/services/agents-api/internal/store/credential_status_migration_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestCredentialStatusMigrationPreservesMetadataAndCiphertext(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "credential_status_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 32); err != nil { + t.Fatal(err) + } + id, vault, tenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, "INSERT INTO vaults(id,tenant_id,metadata) VALUES ($1,$2,'{}')", vault, tenant); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO vault_credentials(id,vault_id,name,auth_type,mcp_server_url,token_ciphertext) VALUES ($1,$2,'Existing Credential','static_bearer','https://example.invalid/mcp',$3)", id, vault, []byte{1, 2, 3}); err != nil { + t.Fatal(err) + } + snapshot := func() string { + t.Helper() + var value string + if err := db.QueryRowContext(ctx, "SELECT (to_jsonb(v)-'status')::text FROM vault_credentials v WHERE id=$1", id).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + before := snapshot() + if _, err := provider.UpTo(ctx, 33); err != nil { + t.Fatal(err) + } + var status string + if err := db.QueryRowContext(ctx, "SELECT status FROM vault_credentials WHERE id=$1", id).Scan(&status); err != nil || status != "active" || snapshot() != before { + t.Fatal("migration changed historical resource", status, err) + } + if err := db.QueryRowContext(ctx, "INSERT INTO vault_credentials(id,vault_id,name,auth_type,mcp_server_url,token_ciphertext) VALUES ($1,$2,'New Credential','static_bearer','https://example.invalid/mcp',$3) RETURNING status", uuid.NewString(), vault, []byte{4, 5, 6}).Scan(&status); err != nil || status != "active" { + t.Fatal("new Credential default", status, err) + } + for _, invalid := range []any{nil, "deleted"} { + if _, err := db.ExecContext(ctx, "UPDATE vault_credentials SET status=$1 WHERE id=$2", invalid, id); err == nil { + t.Fatal("invalid classification accepted") + } + } + if _, err := provider.DownTo(ctx, 32); err != nil || snapshot() != before { + t.Fatal("reversible active-only migration", err) + } + if _, err := provider.UpTo(ctx, 33); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "UPDATE vault_credentials SET status='archived' WHERE id=$1", id); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 32); err == nil || !strings.Contains(err.Error(), "Cannot remove archived Credential classification") { + t.Fatal("downgrade lost archived classification", err) + } + if err := db.QueryRowContext(ctx, "SELECT status FROM vault_credentials WHERE id=$1", id).Scan(&status); err != nil || status != "archived" || snapshot() != before { + t.Fatal("failed downgrade changed data", status, err) + } +} diff --git a/services/agents-api/internal/store/devices.go b/services/agents-api/internal/store/devices.go new file mode 100644 index 000000000..71e8eec1d --- /dev/null +++ b/services/agents-api/internal/store/devices.go @@ -0,0 +1,186 @@ +package store + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +var ErrDeviceBindingConflict = errors.New("session is already bound to a different device") + +// ExecutionDevice contains safe identity only, never a device credential. +type ExecutionDevice struct { + ID string + Name string + EnvironmentID string +} + +// SessionExecutionBinding identifies the Runtime and native history selected for one API Session. +type SessionExecutionBinding struct { + Device ExecutionDevice + NativeSessionID string + HasStartedTurn bool +} + +// CreateDevice is operator provisioning, not a tenant-facing registration API. +func (s *Store) CreateDevice(ctx context.Context, tenantID, name, credentialHash string) (ExecutionDevice, error) { + tenant, err := parseID(tenantID) + if err != nil { + return ExecutionDevice{}, err + } + params, err := newDeviceParams(tenant, name, credentialHash) + if err != nil { + return ExecutionDevice{}, err + } + id, err := s.queries.CreateDevice(ctx, params) + if err != nil { + return ExecutionDevice{}, fmt.Errorf("create execution device: %w", err) + } + return ExecutionDevice{ID: uuid.UUID(id.Bytes).String(), Name: params.Name}, nil +} + +func newDeviceParams(tenant pgtype.UUID, name, credentialHash string) (sqlc.CreateDeviceParams, error) { + name = strings.TrimSpace(name) + digest, err := hex.DecodeString(credentialHash) + if err != nil || len(digest) != 32 || name == "" || len(name) > 256 { + return sqlc.CreateDeviceParams{}, fmt.Errorf("%w: device name and SHA-256 credential digest required", ErrInvalidInput) + } + return sqlc.CreateDeviceParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Name: name, CredentialHash: hex.EncodeToString(digest)}, nil +} + +// GetDeviceCredential is used only by the shared gateway's credential verifier. +// The standalone service does not assign a product WorkspaceID. +func (s *Store) GetDeviceCredential(ctx context.Context, deviceID string) (device.Credential, bool, error) { + id, err := parseID(deviceID) + if err != nil { + return device.Credential{}, false, nil + } + row, err := s.queries.GetDeviceCredential(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return device.Credential{}, false, nil + } + if err != nil { + return device.Credential{}, false, err + } + return device.Credential{ID: uuid.UUID(row.ID.Bytes).String(), Name: row.Name, + Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: row.CredentialHash}, true, nil +} + +func (s *Store) RevokeDevice(ctx context.Context, tenantID, deviceID string) error { + params, err := deviceLookup(tenantID, deviceID) + if err != nil { + return err + } + n, err := s.queries.RevokeDevice(ctx, sqlc.RevokeDeviceParams(params)) + if err == nil && n == 0 { + return ErrNotFound + } + return err +} + +// BindSessionDevice keeps retries stable and refuses silent filesystem moves. +// Dispatchers must obtain the full Session binding before delivery. +func (s *Store) BindSessionDevice(ctx context.Context, tenantID, sessionID, deviceID string) error { + params, err := deviceLookup(tenantID, deviceID) + if err != nil { + return err + } + return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + if _, err := q.GetDevice(ctx, params); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + _, err := q.BindSessionDevice(ctx, sqlc.BindSessionDeviceParams{TenantID: params.TenantID, ID: session, ID_2: params.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrDeviceBindingConflict + } + return err + }) +} + +func (s *Store) GetSessionDevice(ctx context.Context, tenantID, sessionID string) (ExecutionDevice, error) { + params, err := deviceLookup(tenantID, sessionID) + if err != nil { + return ExecutionDevice{}, err + } + if err := s.requireInitializedEnvironment(ctx, params.TenantID, params.ID); err != nil { + return ExecutionDevice{}, err + } + row, err := s.queries.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams(params)) + if errors.Is(err, pgx.ErrNoRows) { + return ExecutionDevice{}, ErrNotFound + } + if err != nil { + return ExecutionDevice{}, err + } + return executionDevice(row.ID, row.Name, row.EnvironmentID), nil +} + +func (s *Store) GetSessionExecutionBinding(ctx context.Context, tenantID, sessionID string) (SessionExecutionBinding, error) { + params, err := deviceLookup(tenantID, sessionID) + if err != nil { + return SessionExecutionBinding{}, err + } + if err := s.requireInitializedEnvironment(ctx, params.TenantID, params.ID); err != nil { + return SessionExecutionBinding{}, err + } + row, err := s.queries.GetSessionExecutionBinding(ctx, sqlc.GetSessionExecutionBindingParams(params)) + if errors.Is(err, pgx.ErrNoRows) { + return SessionExecutionBinding{}, ErrNotFound + } + if err != nil { + return SessionExecutionBinding{}, err + } + return SessionExecutionBinding{ + Device: executionDevice(row.ID, row.Name, row.EnvironmentID), + NativeSessionID: row.NativeSessionID, + HasStartedTurn: row.HasStartedTurn, + }, nil +} + +func executionDevice(id pgtype.UUID, name string, environmentID pgtype.UUID) ExecutionDevice { + value := ExecutionDevice{ID: uuid.UUID(id.Bytes).String(), Name: name} + if environmentID.Valid { + value.EnvironmentID = uuid.UUID(environmentID.Bytes).String() + } + return value +} + +func deviceLookup(tenantID, id string) (sqlc.GetDeviceParams, error) { + var p sqlc.GetDeviceParams + var err error + if p.TenantID, err = parseID(tenantID); err != nil { + return p, err + } + p.ID, err = parseID(id) + return p, err +} + +func (s *Store) TouchRuntimeHeartbeat(ctx context.Context, deviceID string) (device.HeartbeatStatus, error) { + id, err := parseID(deviceID) + if err != nil { + return device.HeartbeatStatus{}, err + } + n, err := s.queries.TouchDevice(ctx, id) + return device.HeartbeatStatus{Liveness: "online", Deleted: n == 0}, err +} + +func (s *Store) TouchAgentDaemonHeartbeat(ctx context.Context, heartbeat device.Heartbeat) (device.HeartbeatStatus, error) { + return s.TouchRuntimeHeartbeat(ctx, heartbeat.RuntimeID) +} + +// Live connectivity belongs to the gateway Registry. Only last-seen time is +// persisted, so a stale socket closing cannot overwrite a newer connection. +func (s *Store) MarkRuntimeOffline(context.Context, string) error { return nil } diff --git a/services/agents-api/internal/store/devices_test.go b/services/agents-api/internal/store/devices_test.go new file mode 100644 index 000000000..399b48efc --- /dev/null +++ b/services/agents-api/internal/store/devices_test.go @@ -0,0 +1,189 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" +) + +func registerTestDevice(t *testing.T, s *Store, tenant string) (ExecutionDevice, string) { + t.Helper() + secret := uuid.NewString() + uuid.NewString() + d, err := s.CreateDevice(context.Background(), tenant, "isolated executor", device.HashCredential(secret)) + if err != nil { + t.Fatal(err) + } + return d, secret +} + +func TestDeviceBindingIsTenantScopedStableAndDurable(t *testing.T) { + s, pool := testStore(t) + other, _ := testStore(t) + ctx := context.Background() + tenant, session := newTurnSession(t, s) + otherTenant, otherSession := newTurnSession(t, s) + a, _ := registerTestDevice(t, s, tenant) + b, _ := registerTestDevice(t, s, tenant) + foreign, _ := registerTestDevice(t, s, otherTenant) + for _, args := range [][3]string{{tenant, session.ID, foreign.ID}, {otherTenant, session.ID, foreign.ID}, {tenant, otherSession.ID, a.ID}} { + if err := s.BindSessionDevice(ctx, args[0], args[1], args[2]); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign binding: %v", err) + } + } + var wg sync.WaitGroup + errs := make(chan error, 8) + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + st, id := s, a.ID + if i%2 == 0 { + st, id = other, b.ID + } + errs <- st.BindSessionDevice(ctx, tenant, session.ID, id) + }() + } + wg.Wait() + close(errs) + success, conflicts := 0, 0 + for err := range errs { + switch { + case err == nil: + success++ + case errors.Is(err, ErrDeviceBindingConflict): + conflicts++ + default: + t.Fatal(err) + } + } + if success != 4 || conflicts != 4 { + t.Fatalf("unstable binding: success=%d conflicts=%d", success, conflicts) + } + winner, err := s.GetSessionDevice(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.GetSessionDevice(ctx, otherTenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign lookup: %v", err) + } + pool.Close() + restarted, _ := testStore(t) + got, err := restarted.GetSessionDevice(ctx, tenant, session.ID) + if err != nil || got != winner { + t.Fatalf("binding after restart: %+v %v", got, err) + } + if err := restarted.RevokeDevice(ctx, otherTenant, winner.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign revocation: %v", err) + } + for range 2 { + if err := restarted.RevokeDevice(ctx, tenant, winner.ID); err != nil { + t.Fatal(err) + } + } + if _, err := restarted.GetSessionDevice(ctx, tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("revoked device remains dispatchable: %v", err) + } +} + +func TestStandaloneGatewayUsesExecutionCredentials(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant, _ := newTurnSession(t, s) + a, secret := registerTestDevice(t, s, tenant) + _, foreignSecret := registerTestDevice(t, s, uuid.NewString()) + server := httptest.NewUnstartedServer(nil) + wsURL := "ws://" + server.Listener.Addr().String() + "/api/v1/agent-daemon/ws" + handler, registry, err := runtime.NewGateway(s, wsURL) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = handler + server.Start() + t.Cleanup(func() { server.Close(); runtime.CloseConnections(registry) }) + for _, token := range []string{"", "session-api-key", foreignSecret, secret} { + body, _ := json.Marshal(map[string]string{"device_id": a.ID}) + req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/agent-daemon/bootstrap", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + response, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + var data map[string]any + err = json.NewDecoder(response.Body).Decode(&data) + response.Body.Close() + if token == secret { + if err != nil || response.StatusCode != http.StatusOK || data["workspace_id"] != "" || data["ws_url"] != wsURL { + t.Fatalf("standalone bootstrap: %d %v %v", response.StatusCode, data, err) + } + } else if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unrelated credential accepted: %d", response.StatusCode) + } + } + u, _ := url.Parse(wsURL) + q := url.Values{"device_id": {a.ID}, "token": {secret}, "version": {proto.Version}} + u.RawQuery = q.Encode() + connect := func() *websocket.Conn { + t.Helper() + conn, response, err := websocket.DefaultDialer.Dial(u.String(), nil) + if response != nil { + response.Body.Close() + } + if err != nil { + t.Fatal("device connection failed") // Do not log the credential-bearing URL. + } + t.Cleanup(func() { conn.Close() }) + return conn + } + first := connect() + previous, err := registry.WaitForDevice(ctx, a.ID, time.Second) + if err != nil { + t.Fatal(err) + } + second := connect() + _ = first.SetReadDeadline(time.Now().Add(2 * time.Second)) + for { + if _, _, err := first.ReadMessage(); err != nil { + if !websocket.IsCloseError(err, websocket.CloseAbnormalClosure) { + t.Fatalf("replaced connection was not closed: %v", err) + } + break + } + } + current, err := registry.LookupDevice(a.ID) + if err != nil || current == previous || current.IsClosed() { + t.Fatalf("replacement connection missing: %v", err) + } + if err := s.RevokeDevice(ctx, tenant, a.ID); err != nil { + t.Fatal(err) + } + if err := second.WriteJSON(map[string]any{"type": proto.TypeHeartbeat, "payload": map[string]any{"version": "test"}}); err != nil { + t.Fatal(err) + } + _ = second.SetReadDeadline(time.Now().Add(2 * time.Second)) + for { + if _, _, err := second.ReadMessage(); err != nil { + if !websocket.IsCloseError(err, gateway.CloseRuntimeDeleted) { + t.Fatalf("revocation did not close connection: %v", err) + } + break + } + } + if _, err := gateway.NewAuthenticator(s).AuthenticateBearer(ctx, a.ID, secret); !errors.Is(err, gateway.ErrAuthUnknownDevice) { + t.Fatalf("revoked credential survived: %v", err) + } +} diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/agents-api/internal/store/dispatch_test.go new file mode 100644 index 000000000..8a91f365e --- /dev/null +++ b/services/agents-api/internal/store/dispatch_test.go @@ -0,0 +1,378 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type dispatchHarness struct { + t *testing.T + s *store.Store + d *execution.Dispatcher + tenant string + session store.Session + device store.ExecutionDevice + conn *websocket.Conn + registry *gateway.Registry + url string + credential string +} + +func newDispatchHarness(t *testing.T) *dispatchHarness { + t.Helper() + return newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model","instructions":"Keep this instruction."},"daemon":{"work_dir":"/tmp"}}`), false) +} + +func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool) *dispatchHarness { + t.Helper() + s, _ := store.NewTestStore(t) + h := &dispatchHarness{t: t, s: s, tenant: uuid.NewString()} + ctx := context.Background() + var err error + h.session, err = s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "session", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + secret := uuid.NewString() + h.credential = secret + if local { + environment, getErr := s.GetSessionEnvironment(ctx, h.tenant, h.session.ID) + if getErr != nil { + t.Fatal(getErr) + } + h.device, err = s.CreateEnvironmentDevice(ctx, h.tenant, environment.ID, "local runtime", device.HashCredential(secret)) + } else { + h.device, err = s.CreateDevice(ctx, h.tenant, "isolated executor", device.HashCredential(secret)) + } + if err != nil { + t.Fatal(err) + } + if err = s.BindSessionDevice(ctx, h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + wsURL := "ws://" + server.Listener.Addr().String() + "/api/v1/agent-daemon/ws" + server.Config.Handler, h.registry, err = runtime.NewGateway(s, wsURL) + if err != nil { + t.Fatal(err) + } + server.Start() + h.url = server.URL + t.Cleanup(func() { server.Close(); runtime.CloseConnections(h.registry) }) + u, _ := url.Parse(wsURL) + u.RawQuery = url.Values{"device_id": {h.device.ID}, "token": {secret}, "version": {proto.Version}}.Encode() + h.conn, _, err = websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatal("device connection failed") + } + t.Cleanup(func() { h.conn.Close() }) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, NativeSessionRecovery: true}}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, e := h.registry.LookupDevice(h.device.ID) + if e == nil { + if _, _, known := peer.AgentKindStatus("codex"); known { + break + } + } + if time.Now().After(deadline) { + t.Fatal("heartbeat not registered") + } + time.Sleep(10 * time.Millisecond) + } + h.d = &execution.Dispatcher{Store: s, Registry: h.registry} + return h +} + +func (h *dispatchHarness) message(key, text string) store.InputReceipt { + h.t.Helper() + body, _ := json.Marshal(map[string]string{"text": text}) + r, err := h.s.SubmitMessage(context.Background(), h.tenant, h.session.ID, key, body) + if err != nil { + h.t.Fatal(err) + } + return r +} + +func (h *dispatchHarness) write(run, kind string, payload any) { + h.t.Helper() + env, err := proto.NewEnvelope(kind, run, payload) + if err != nil { + h.t.Fatal(err) + } + if err = h.conn.WriteJSON(env); err != nil { + h.t.Fatal(err) + } +} + +func (h *dispatchHarness) read(kind string) proto.Envelope { + h.t.Helper() + _ = h.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var env proto.Envelope + if err := h.conn.ReadJSON(&env); err != nil { + h.t.Fatal(err) + } + if env.Type == kind { + return env + } + } +} + +type runResult struct { + turn store.Turn + err error +} + +func (h *dispatchHarness) run(ctx context.Context, turn string) <-chan runResult { + out := make(chan runResult, 1) + go func() { result, err := h.d.Run(ctx, h.tenant, h.session.ID, turn); out <- runResult{result, err} }() + return out +} + +func (h *dispatchHarness) finished(result <-chan runResult, status string) store.Turn { + h.t.Helper() + select { + case got := <-result: + if got.err != nil || got.turn.Status != status { + h.t.Fatalf("execution result: status=%s err=%v outcome=%s", got.turn.Status, got.err, got.turn.Outcome) + } + return got.turn + case <-time.After(10 * time.Second): + h.t.Fatal("execution did not finish") + } + return store.Turn{} +} + +func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + first := h.message("first", "Initial input") + result := h.run(ctx, first.TurnID) + request := h.read(proto.TypePromptRequest) + var prompt proto.PromptRequestPayload + _ = request.DecodePayload(&prompt) + if prompt.Prompt != "Initial input" || prompt.ConversationID != h.session.ID || prompt.AgentOptions["model"] != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { + t.Fatalf("wrong resolved request: %+v", prompt) + } + if _, err := h.d.Run(ctx, uuid.NewString(), h.session.ID, first.TurnID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("foreign execution: %v", err) + } + if _, err := h.d.Run(ctx, h.tenant, h.session.ID, first.TurnID); !errors.Is(err, store.ErrTurnConflict) { + t.Fatalf("duplicate execution: %v", err) + } + second := h.message("second", "Follow-up input") + steer := h.read(proto.TypePromptSteer) + var input proto.PromptSteerPayload + _ = steer.DecodePayload(&input) + if input.Text != "Follow-up input" { + t.Fatal(input.Text) + } + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, ErrorCode: "not_ready"}) + retry := h.read(proto.TypePromptSteer) + if string(retry.Payload) != string(steer.Payload) { + t.Fatal("steering retry changed identity") + } + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "Finished", Usage: proto.Usage{InputTokens: 7, OutputTokens: 3}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread-1"}}) + done := h.finished(result, store.TurnCompleted) + var outcome execution.Result + _ = json.Unmarshal(done.Outcome, &outcome) + if outcome.AppliedThrough != second.Sequence || outcome.Done.Usage.InputTokens != 7 { + t.Fatalf("missing result: %+v", outcome) + } + newStore, pool := store.NewTestStore(t) + defer pool.Close() + h.s = newStore + h.d.Store = newStore + bound, err := newStore.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "native-thread-1" { + t.Fatalf("native binding lost: %+v %v", bound, err) + } + next := h.message("third", "Continue the session") + result = h.run(ctx, next.TurnID) + request = h.read(proto.TypePromptRequest) + _ = request.DecodePayload(&prompt) + if prompt.AgentSessionID != "native-thread-1" || prompt.AgentStateKey != "agents-api-"+h.session.ID { + t.Fatal("native continuity lost") + } + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "Continued"}) + h.finished(result, store.TurnCompleted) +} + +func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) { + for _, withDone := range []bool{false, true} { + t.Run(map[bool]string{false: "receipt", true: "done-before-receipt"}[withDone], func(t *testing.T) { + h := newDispatchHarness(t) + first := h.message("first", "Run") + result := h.run(context.Background(), first.TurnID) + h.read(proto.TypePromptRequest) + _, err := h.s.RequestCancel(context.Background(), h.tenant, h.session.ID, "cancel") + if err != nil { + t.Fatal(err) + } + env := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + _ = env.DecodePayload(&cancel) + if cancel.DeliveryID == "" { + t.Fatal("cancellation has no receipt identity") + } + current, err := h.s.GetTurn(context.Background(), h.tenant, h.session.ID, first.TurnID) + if err != nil || current.Status != store.TurnInProgress { + t.Fatal("cancel finished before receipt") + } + if withDone { + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) + } + outcome := &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-native"}, Usage: proto.Usage{InputTokens: 10}} + h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: outcome}) + h.finished(result, store.TurnCancelled) + next := h.message("next", "Continue after cancellation") + result = h.run(context.Background(), next.TurnID) + env = h.read(proto.TypePromptRequest) + var prompt proto.PromptRequestPayload + _ = env.DecodePayload(&prompt) + if prompt.AgentSessionID != "cancelled-native" { + t.Fatal("cancellation lost native continuity") + } + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) + h.finished(result, store.TurnCompleted) + }) + } + h := newDispatchHarness(t) + first := h.message("first", "Run") + ctx, cancel := context.WithCancel(context.Background()) + result := h.run(ctx, first.TurnID) + h.read(proto.TypePromptRequest) + cancel() + done := h.finished(result, store.TurnFailed) + var outcome execution.Result + _ = json.Unmarshal(done.Outcome, &outcome) + if outcome.ErrorCode != "execution_interrupted" { + t.Fatal(outcome.ErrorCode) + } +} + +func TestExecutionFailureDoesNotBecomeSuccessOrReplay(t *testing.T) { + for _, kind := range []string{"disconnect", "engine", "late-input", "unconfirmed-input"} { + t.Run(kind, func(t *testing.T) { + h := newDispatchHarness(t) + first := h.message("first", "Run") + result := h.run(context.Background(), first.TurnID) + h.read(proto.TypePromptRequest) + switch kind { + case "disconnect": + h.conn.Close() + case "engine": + h.write(first.TurnID, proto.TypeUsage, proto.UsagePayload{Usage: proto.Usage{InputTokens: 13, OutputTokens: 7}}) + h.write(first.TurnID, proto.TypeError, proto.ErrorPayload{Error: "Engine failed"}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) + case "late-input": + h.message("late", "Still unprocessed") + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "Only first input finished"}) + case "unconfirmed-input": + h.message("second", "Steer") + h.read(proto.TypePromptSteer) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) + } + done := h.finished(result, store.TurnFailed) + if kind == "engine" { + var outcome execution.Result + _ = json.Unmarshal(done.Outcome, &outcome) + if outcome.Done.Usage.InputTokens != 13 || outcome.Done.Usage.OutputTokens != 7 { + t.Fatal("failed execution lost reported usage") + } + } + if kind != "disconnect" { + if _, err := h.d.Run(context.Background(), h.tenant, h.session.ID, first.TurnID); !errors.Is(err, store.ErrTurnConflict) { + t.Fatalf("terminal replay: %v", err) + } + } + }) + } +} + +func TestExecutionOutcomeAndNativeBindingCommitTogether(t *testing.T) { + h := newDispatchHarness(t) + first := h.message("first", "Run") + ctx := context.Background() + _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + late := h.message("second", "Late") + if _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnCompleted, []byte(`{}`), "native-one", first.Sequence); !errors.Is(err, store.ErrUnappliedInputs) { + t.Fatalf("unapplied completion: %v", err) + } + bound, _ := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if bound.NativeSessionID != "" { + t.Fatal("native ID committed without outcome") + } + var wg sync.WaitGroup + errs := make(chan error, 2) + for _, native := range []string{"native-one", "native-two"} { + wg.Add(1) + go func() { + defer wg.Done() + _, err := h.s.CompleteExecution(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnCompleted, []byte(`{}`), native, late.Sequence) + errs <- err + }() + } + wg.Wait() + close(errs) + success := 0 + for err := range errs { + if err == nil { + success++ + } else if !errors.Is(err, store.ErrTurnConflict) { + t.Fatal(err) + } + } + if success != 1 { + t.Fatal("multiple terminal owners") + } +} + +func TestExecutionRejectsLegacyDaemonBeforeClaim(t *testing.T) { + for _, missing := range []string{"execution_controls", "durable_input_receipts", "durable_turns", "web_search_control", "text_verbosity", "subagent_control", "tool_observations"} { + t.Run(missing, func(t *testing.T) { + h := newDispatchHarness(t) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: missing != "durable_turns", DurableInputReceipts: missing != "durable_input_receipts", WebSearchControl: missing != "web_search_control", TextVerbosity: missing != "text_verbosity", ExecutionControls: missing != "execution_controls", SubagentControl: missing != "subagent_control", ToolObservations: missing != "tool_observations"}}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("codex") + if info.Capabilities.DurableInputReceipts == (missing != "durable_input_receipts") && info.Capabilities.ExecutionControls == (missing != "execution_controls") && info.Capabilities.DurableTurns == (missing != "durable_turns") && info.Capabilities.WebSearchControl == (missing != "web_search_control") && info.Capabilities.TextVerbosity == (missing != "text_verbosity") && info.Capabilities.SubagentControl == (missing != "subagent_control") && info.Capabilities.ToolObservations == (missing != "tool_observations") { + break + } + if time.Now().After(deadline) { + t.Fatal("legacy heartbeat not registered") + } + time.Sleep(10 * time.Millisecond) + } + first := h.message("legacy", "Run") + if _, err := h.d.Run(context.Background(), h.tenant, h.session.ID, first.TurnID); err == nil { + t.Fatal("legacy daemon accepted") + } + turn, err := h.s.GetTurn(context.Background(), h.tenant, h.session.ID, first.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal("unsupported daemon claimed work") + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_admission_test.go b/services/agents-api/internal/store/environment_admission_test.go new file mode 100644 index 000000000..24582f227 --- /dev/null +++ b/services/agents-api/internal/store/environment_admission_test.go @@ -0,0 +1,234 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type environmentAdmissionResult struct { + receipts []store.InputReceipt + err error +} + +func newEnvironmentAdmission(t *testing.T) (*dispatchHarness, *execution.Worker) { + t.Helper() + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + worker, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = worker.Run(ctx) + }) + h.session, err = worker.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{ + Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/remote"}}`), + }) + if err != nil { + t.Fatal(err) + } + return h, worker +} + +func submitEnvironmentAdmission(ctx context.Context, h *dispatchHarness, worker *execution.Worker, key string) <-chan environmentAdmissionResult { + result := make(chan environmentAdmissionResult, 1) + go func() { + receipts, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, key, environmentAdmissionInputs()) + result <- environmentAdmissionResult{receipts, err} + }() + return result +} + +func environmentAdmissionInputs() []store.Input { + return []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}} +} + +func awaitEnvironmentAdmission(t *testing.T, result <-chan environmentAdmissionResult) environmentAdmissionResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(10 * time.Second): + t.Fatal("input waiter did not settle") + return environmentAdmissionResult{} + } +} + +func environmentAdmissionPending(t *testing.T, h *dispatchHarness, key string) store.EnvironmentInputReservation { + t.Helper() + _, pool := store.NewTestStore(t) + var id string + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "input reservation", func() bool { + return pool.QueryRow(t.Context(), "SELECT id::text FROM environment_input_reservations WHERE session_id=$1 AND idempotency_key=$2", h.session.ID, key).Scan(&id) == nil + }) + pending, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, id) + if err != nil { + t.Fatal(err) + } + return pending +} + +func TestEnvironmentAdmissionWaitsForPreparedClaimAndRetainsRetry(t *testing.T) { + h, worker := newEnvironmentAdmission(t) + first := submitEnvironmentAdmission(t.Context(), h, worker, "wait") + retry := submitEnvironmentAdmission(t.Context(), h, worker, "wait") + pending := environmentAdmissionPending(t, h, "wait") + for _, result := range []<-chan environmentAdmissionResult{first, retry} { + select { + case got := <-result: + t.Fatal("reservation returned before admission", got) + default: + } + } + session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.LastTurn != nil || session.EnvironmentInputActivity == nil || session.EnvironmentInputActivity.Status != "requires_action" { + t.Fatal("waiting activity", session, err) + } + inputs := environmentAdmissionInputs() + if _, err = worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "other", inputs); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal("competing batch", err) + } + if _, err = worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "wait", inputs[:1]); !errors.Is(err, store.ErrIdempotencyConflict) { + t.Fatal("changed retry", err) + } + if _, err = worker.SubmitInputs(t.Context(), uuid.NewString(), h.session.ID, "wait", inputs); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign tenant", err) + } + mixed := append(inputs, store.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)}) + if _, err = worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "mixed", mixed); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal("mixed batch accepted", err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }) + frame := h.read(proto.TypeExecutionPrepare) + handle := acknowledgePreparation(h, frame.ID) + select { + case got := <-first: + t.Fatal("preparing is not admission", got) + default: + } + start := readyPreparedDispatch(t, h, frame.ID, handle) + for _, result := range []<-chan environmentAdmissionResult{first, retry} { + got := awaitEnvironmentAdmission(t, result) + if got.err != nil || len(got.receipts) != 2 || got.receipts[0].TurnID != start.RunID { + t.Fatal("admission result", got) + } + } + steered, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "active", inputs) + if err != nil || len(steered) != 2 || steered[0].TurnID != start.RunID || steered[0].Replayed { + t.Fatal("active steering did not retain the prepared Turn", steered, err) + } + if replay, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "wait", inputs); err != nil || len(replay) != 2 || !replay[0].Replayed || replay[0].TurnID != start.RunID { + t.Fatal("active reservation retry lost its original receipt", replay, err) + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + for index, receipt := range steered { + frame := h.read(proto.TypePromptSteer) + var steer proto.PromptSteerPayload + if err := frame.DecodePayload(&steer); err != nil || steer.InputID != strconv.FormatInt(receipt.Sequence, 10) || steer.Text != []string{"first", "second"}[index] { + t.Fatal("active delivery changed order or identity", steer, err) + } + h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) + } + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "admitted-native"}}) + run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) + if run.Turn.Status != store.TurnCompleted { + t.Fatal("completion", run.Turn) + } + replay, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "wait", inputs) + if err != nil || len(replay) != 2 || !replay[0].Replayed || replay[0].TurnID != start.RunID { + t.Fatal("terminal retry", replay, err) + } +} + +func TestEnvironmentAdmissionSettlementDoesNotCreateTurn(t *testing.T) { + for _, name := range []string{"expired", "cancelled", "deleted", "disconnected", "ownership_lost"} { + t.Run(name, func(t *testing.T) { + h, worker := newEnvironmentAdmission(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + response := submitEnvironmentAdmission(ctx, h, worker, "waiting") + pending := environmentAdmissionPending(t, h, "waiting") + _, pool := store.NewTestStore(t) + expected := execution.ErrEnvironmentInputExpired + switch name { + case "expired": + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + case "cancelled": + expected = execution.ErrEnvironmentInputCancelled + if _, err := h.s.CancelEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID); err != nil { + t.Fatal(err) + } + case "deleted": + expected = store.ErrNotFound + if err := h.s.DeleteSession(t.Context(), h.tenant, h.session.ID); err != nil { + t.Fatal(err) + } + case "disconnected": + expected = context.Canceled + cancel() + case "ownership_lost": + expected = execution.ErrExecutionUnavailable + var killed bool + err := pool.QueryRow(t.Context(), `SELECT pg_terminate_backend(pid,1000) FROM pg_locks WHERE locktype='advisory' + AND database=(SELECT oid FROM pg_database WHERE datname=current_database()) + AND classid=(706172736172::bigint >> 32)::oid + AND objid=(706172736172::bigint & 4294967295)::oid AND objsubid=1 AND granted`).Scan(&killed) + if err != nil || !killed { + t.Fatal("owned lease termination", err) + } + } + got := awaitEnvironmentAdmission(t, response) + if !errors.Is(got.err, expected) || len(got.receipts) != 0 { + t.Fatal("settlement", got, expected) + } + var turns, inputs int + if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM turns WHERE session_id=$1),(SELECT count(*) FROM turn_inputs WHERE session_id=$1)", h.session.ID).Scan(&turns, &inputs); err != nil { + t.Fatal(err) + } + if turns != 0 || inputs != 0 { + t.Fatal("settlement created work", turns, inputs) + } + if name == "deleted" { + return + } + retained, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, pending.ID) + if err != nil { + t.Fatal(err) + } + if name == "disconnected" || name == "ownership_lost" { + if retained.State != store.EnvironmentInputPending || !retained.Deadline.Equal(pending.Deadline) { + t.Fatal("observer changed durable outcome", retained) + } + } else { + replay, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "waiting", environmentAdmissionInputs()) + if !errors.Is(err, expected) || len(replay) != 0 { + t.Fatal("settled retry", replay, err) + } + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_claim_worker_test.go b/services/agents-api/internal/store/environment_claim_worker_test.go new file mode 100644 index 000000000..78772b10f --- /dev/null +++ b/services/agents-api/internal/store/environment_claim_worker_test.go @@ -0,0 +1,81 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestWorkerReconcilesEnvironmentPromotionBeforeStart(t *testing.T) { + for _, deleted := range []bool{false, true} { + t.Run(map[bool]string{false: "unbound", true: "deleted"}[deleted], func(t *testing.T) { + s, pool := store.NewTestStore(t) + tenant, pending := newEnvironmentExpiryReservation(t, s) + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + got, err := lease.Store().PromoteEnvironmentInput(t.Context(), tenant, pending.SessionID, pending.ID) + if err != nil || len(got.Receipts) != 1 || got.Receipts[0].Replayed { + t.Fatal(got, err) + } + turnID := got.Receipts[0].TurnID + if deleted { + if err := s.DeleteSession(t.Context(), tenant, pending.SessionID); err != nil { + t.Fatal(err) + } + } + turn, err := s.GetTurn(t.Context(), tenant, pending.SessionID, turnID) + if err != nil || turn.Status != store.TurnInProgress || (deleted && turn.CancelRequestedAt.IsZero()) { + t.Fatal("promotion did not retain the active claim", turn, err) + } + // Simulate owner loss after commit, without sending any daemon Start. + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + restarted, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry()}) + if err != nil { + t.Fatal(err) + } + stopped, cancel := context.WithCancel(t.Context()) + cancel() + if err := restarted.Run(stopped); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + turn, err = s.GetTurn(t.Context(), tenant, pending.SessionID, turnID) + var outcome struct { + ErrorCode string `json:"error_code"` + } + if err != nil || turn.Status != store.TurnFailed || json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.ErrorCode != "execution_interrupted" { + t.Fatal("restart failed to settle the original claim", turn, err) + } + var turns, inputs, queued int + err = pool.QueryRow(t.Context(), `SELECT + (SELECT count(*) FROM turns WHERE session_id=$1), + (SELECT count(*) FROM turn_inputs WHERE session_id=$1), + (SELECT count(*) FROM turns WHERE session_id=$1 AND status='queued')`, pending.SessionID).Scan(&turns, &inputs, &queued) + if err != nil || turns != 1 || inputs != 1 || queued != 0 { + t.Fatal("restart duplicated or requeued prepared work", turns, inputs, queued, err) + } + successor, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = successor.Close(context.Background()) }) + retry, err := successor.Store().PromoteEnvironmentInput(t.Context(), tenant, pending.SessionID, pending.ID) + if deleted { + if !errors.Is(err, store.ErrNotFound) { + t.Fatal("deleted reservation was exposed", err) + } + } else if err != nil || len(retry.Receipts) != 1 || !retry.Receipts[0].Replayed || retry.Receipts[0].Sequence != got.Receipts[0].Sequence || retry.Receipts[0].TurnID != turnID { + t.Fatal("recovery retry authorized another start", retry, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_connection_events_test.go b/services/agents-api/internal/store/environment_connection_events_test.go new file mode 100644 index 000000000..471b244d4 --- /dev/null +++ b/services/agents-api/internal/store/environment_connection_events_test.go @@ -0,0 +1,88 @@ +package store_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func awaitEnvironmentConnectionState(t *testing.T, ctx context.Context, s *store.Store, tenant, environment, status string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + value, err := s.GetEnvironment(ctx, tenant, environment) + if err != nil { + t.Fatal(err) + } + if value.Status == status { + return + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(10 * time.Millisecond): + } + } + t.Fatal("persisted connection state did not converge", status) +} + +func retainedEnvironmentEvents(t *testing.T, ctx context.Context, s *store.Store, tenant, session, environment string) []v1.SessionEvent { + t.Helper() + var after int64 + events := []v1.SessionEvent{} + for { + changes, err := s.ListSessionEvents(ctx, tenant, session, after) + if err != nil { + t.Fatal(err) + } + if len(changes) == 0 { + break + } + for _, change := range changes { + after = change.Sequence + if change.Event.Environment == nil { + continue + } + event := change.Event + if event.SessionID != session || event.Environment.ID != environment || event.Environment.Type != "self_hosted" || event.Environment.Error != nil || event.TurnID != "" { + t.Fatal("invalid transport event identity") + } + status := event.Environment.Status + if (status != "connected" && status != "disconnected") || event.Type != "agent.session.environment."+status { + t.Fatal("transport observation claimed native readiness") + } + if len(events) > 0 && events[len(events)-1].Type == event.Type { + t.Fatal("duplicate lifecycle transition") + } + events = append(events, event) + } + } + return events +} + +func verifyEnvironmentEventsWithSDK(t *testing.T, root string, events []v1.SessionEvent) { + t.Helper() + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Fatal("pinned official SDK is required for native Environment event acceptance") + } + raw, err := json.Marshal(events) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "environment-events.json") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + command := exec.Command(python, "../../tests/official_environment_events.py", path) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("official Environment event validation: %v\n%s", err, output) + } +} diff --git a/services/agents-api/internal/store/environment_connection_migration_test.go b/services/agents-api/internal/store/environment_connection_migration_test.go new file mode 100644 index 000000000..58fd2e1ef --- /dev/null +++ b/services/agents-api/internal/store/environment_connection_migration_test.go @@ -0,0 +1,89 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestEnvironmentConnectionMigrationPreservesStateAndGuardsFencing(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "connection_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 26); err != nil { + t.Fatal(err) + } + session, environment, tenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) VALUES ($1,$2,'codex','historical','historical','{"environment":{"type":"self_hosted"}}')`, session, tenant); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO environments(id,session_id) VALUES ($1,$2)", environment, session); err != nil { + t.Fatal(err) + } + snapshot := func() string { + t.Helper() + var value string + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(e)::text FROM environments e WHERE id=$1", environment).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + before := snapshot() + if _, err := provider.UpTo(ctx, 27); err != nil { + t.Fatal(err) + } + if snapshot() != before { + t.Fatal("migration invented connection state") + } + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM environment_connections").Scan(&count); err != nil || count != 0 { + t.Fatal(count, err) + } + if _, err := provider.DownTo(ctx, 26); err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 27); err != nil { + t.Fatal(err) + } + generation := uuid.NewString() + if _, err := db.ExecContext(ctx, "INSERT INTO environment_connections(environment_id,generation) VALUES ($1,$2)", environment, generation); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "UPDATE environment_connections SET revision=-1 WHERE environment_id=$1", environment); err == nil { + t.Fatal("negative observation revision accepted") + } + if _, err := provider.DownTo(ctx, 26); err == nil || !strings.Contains(err.Error(), "Cannot remove active Environment observation fencing") { + t.Fatal("downgrade lost generation", err) + } + var retained string + if err := db.QueryRowContext(ctx, "SELECT generation::text FROM environment_connections WHERE environment_id=$1", environment).Scan(&retained); err != nil || retained != generation { + t.Fatal(retained, err) + } + if snapshot() != before { + t.Fatal("migration changed Environment ownership") + } +} diff --git a/services/agents-api/internal/store/environment_connection_recovery.go b/services/agents-api/internal/store/environment_connection_recovery.go new file mode 100644 index 000000000..f63123f80 --- /dev/null +++ b/services/agents-api/internal/store/environment_connection_recovery.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ReconcileEnvironmentConnections runs before the new owner's connection producers start. +// It discards previous process generations and records loss of their connected transport. +func (s *Store) ReconcileEnvironmentConnections(ctx context.Context) error { + if s.executionLease == nil { + return errors.New("Environment reconciliation requires an execution lease") + } + after := pgtype.UUID{Valid: true} + for { + var rows []sqlc.ListEnvironmentConnectionsRow + queryCtx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + err := s.executionLease.withConn(queryCtx, func(conn *pgxpool.Conn) error { + var err error + rows, err = sqlc.New(conn).ListEnvironmentConnections(queryCtx, after) + return err + }) + cancel() + if err != nil { + return err + } + if len(rows) == 0 { + return nil + } + for _, row := range rows { + err := s.withEnvironmentConnection(ctx, uuid.UUID(row.TenantID.Bytes).String(), uuid.UUID(row.ID.Bytes).String(), func(ctx context.Context, q *sqlc.Queries, current sqlc.GetSessionEnvironmentRow) error { + if err := q.DeleteEnvironmentConnection(ctx, current.Environment.ID); err != nil { + return err + } + if current.Environment.Status == "connected" { + return recordEnvironmentConnection(ctx, q, current, "disconnected") + } + return nil + }) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + after = row.ID + } + } +} diff --git a/services/agents-api/internal/store/environment_connection_recovery_test.go b/services/agents-api/internal/store/environment_connection_recovery_test.go new file mode 100644 index 000000000..cad363ed3 --- /dev/null +++ b/services/agents-api/internal/store/environment_connection_recovery_test.go @@ -0,0 +1,79 @@ +package store + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestEnvironmentConnectionRecoveryFencesLostOwnerAcrossPages(t *testing.T) { + s, pool := testStore(t) + old := executionLease(t, s) + type target struct { + tenant string + session Session + environment Environment + generation string + } + var targets []target + for range 33 { + tenant, session, environment := connectionFixture(t, s) + generation := uuid.NewString() + if err := old.Store().ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := old.Store().ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + targets = append(targets, target{tenant, session, environment, generation}) + } + var killed bool + if err := pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1,1000)", old.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + next := executionLease(t, s) + first := targets[0] + if err := old.Store().ObserveEnvironmentConnection(t.Context(), first.tenant, first.environment.ID, first.generation, 2, false); err == nil { + t.Fatal("lost owner wrote state") + } + if err := s.ReconcileEnvironmentConnections(t.Context()); err == nil { + t.Fatal("unleased reconciliation accepted") + } + if err := next.Store().ReconcileEnvironmentConnections(t.Context()); err != nil { + t.Fatal(err) + } + for _, target := range targets { + got, err := s.GetEnvironment(t.Context(), target.tenant, target.environment.ID) + if err != nil || got.Status != "disconnected" { + t.Fatal("old process remained connected", got, err) + } + changes := connectionChanges(t, s, target.tenant, target.session.ID) + if len(changes) != 2 || changes[0].Event.Environment.Status != "connected" || changes[1].Event.Environment.Status != "disconnected" { + t.Fatal("recovery lost event snapshots", changes) + } + before := connectionSnapshot(t, pool, target.environment.ID) + if err := next.Store().ObserveEnvironmentConnection(t.Context(), target.tenant, target.environment.ID, target.generation, 100, true); err != nil { + t.Fatal(err) + } + if after := connectionSnapshot(t, pool, target.environment.ID); after != before { + t.Fatal("old generation survived recovery") + } + } + if err := next.Store().ReconcileEnvironmentConnections(t.Context()); err != nil { + t.Fatal(err) + } + if len(connectionChanges(t, s, first.tenant, first.session.ID)) != 2 { + t.Fatal("repeated recovery duplicated a disconnect") + } + generation := uuid.NewString() + if err := next.Store().ReplaceEnvironmentConnection(t.Context(), first.tenant, first.environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := next.Store().ObserveEnvironmentConnection(t.Context(), first.tenant, first.environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + got, err := s.GetEnvironment(t.Context(), first.tenant, first.environment.ID) + if err != nil || got.Status != "connected" { + t.Fatal("new generation could not connect", got, err) + } +} diff --git a/services/agents-api/internal/store/environment_connection_worker_test.go b/services/agents-api/internal/store/environment_connection_worker_test.go new file mode 100644 index 000000000..dec65a981 --- /dev/null +++ b/services/agents-api/internal/store/environment_connection_worker_test.go @@ -0,0 +1,94 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestEnvironmentConnectionWorkerReconcilesAndClosesBeforeLease(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "connection-worker", Configuration: []byte(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + generation := uuid.NewString() + if err := lease.Store().ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := lease.Store().ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + var worker *execution.Worker + closed := false + next := uuid.NewString() + dispatcher := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), CloseEnvironmentConnections: func() { + closed = true + if err := worker.CheckOwnership(context.Background()); err != nil { + t.Error("shutdown lost ownership before connections", err) + } + if err := worker.ObserveEnvironmentConnection(context.Background(), tenant, environment.ID, next, 2, false); err != nil { + t.Error(err) + } + }} + worker, err = execution.StartWorker(t.Context(), dispatcher) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + exited := make(chan struct{}) + go func() { defer close(exited); done <- worker.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-exited: + case <-time.After(10 * time.Second): + t.Error("worker cleanup did not exit") + } + }) + awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") + if err := worker.ReplaceEnvironmentConnection(ctx, tenant, environment.ID, next); err != nil { + t.Fatal(err) + } + if err := worker.ObserveEnvironmentConnection(ctx, tenant, environment.ID, next, 1, true); err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("worker did not close") + } + if !closed { + t.Fatal("worker skipped connection shutdown") + } + awaitEnvironmentConnectionState(t, t.Context(), s, tenant, environment.ID, "disconnected") + if err := worker.CheckOwnership(t.Context()); err == nil { + t.Fatal("worker retained lease") + } + if len(retainedEnvironmentEvents(t, t.Context(), s, tenant, session.ID, environment.ID)) != 4 { + t.Fatal("worker lifecycle did not retain all snapshots") + } +} diff --git a/services/agents-api/internal/store/environment_connections.go b/services/agents-api/internal/store/environment_connections.go new file mode 100644 index 000000000..59b14d744 --- /dev/null +++ b/services/agents-api/internal/store/environment_connections.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// ReplaceEnvironmentConnection starts an ordered generation, without claiming a connection. +// The producer serializes replacements and never retries an older replacement after its successor. +func (s *Store) ReplaceEnvironmentConnection(ctx context.Context, tenant, environment, generation string) error { + gen, err := parseConnectionGeneration(generation) + if err != nil { + return err + } + return s.withEnvironmentConnection(ctx, tenant, environment, func(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow) error { + if row.Environment.Status == "failed" || row.Environment.Status == "expired" { + return ErrInvalidInput + } + old, err := q.GetEnvironmentConnection(ctx, row.Environment.ID) + if err == nil && old.Generation == gen { + return nil + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err := q.ReplaceEnvironmentConnection(ctx, sqlc.ReplaceEnvironmentConnectionParams{EnvironmentID: row.Environment.ID, Generation: gen}); err != nil { + return err + } + if row.Environment.Status == "connected" { + return recordEnvironmentConnection(ctx, q, row, "disconnected") + } + return nil + }) +} + +// ObserveEnvironmentConnection commits only a newer observation from the current generation. +// Connectivity is transport evidence, not native preparation readiness or process quiescence. +func (s *Store) ObserveEnvironmentConnection(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { + gen, err := parseConnectionGeneration(generation) + if err != nil || revision <= 0 { + return ErrInvalidInput + } + return s.withEnvironmentConnection(ctx, tenant, environment, func(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow) error { + current, err := q.GetEnvironmentConnection(ctx, row.Environment.ID) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if current.Generation != gen || current.Revision >= revision { + return nil + } + if row.Environment.Status == "failed" || row.Environment.Status == "expired" { + return ErrInvalidInput + } + if err := q.AdvanceEnvironmentConnection(ctx, sqlc.AdvanceEnvironmentConnectionParams{EnvironmentID: row.Environment.ID, Revision: revision}); err != nil { + return err + } + status := "disconnected" + if connected { + status = "connected" + } + if row.Environment.Status == status { + return nil + } + return recordEnvironmentConnection(ctx, q, row, status) + }) +} + +func (s *Store) withEnvironmentConnection(ctx context.Context, tenant, environment string, apply func(context.Context, *sqlc.Queries, sqlc.GetSessionEnvironmentRow) error) error { + if s.executionLease == nil { + return errors.New("Environment observations require an execution lease") + } + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + owned, err := s.GetEnvironment(ctx, tenant, environment) + if err != nil { + return err + } + tenantID, err := parseID(tenant) + if err != nil { + return err + } + return s.withPublicSession(ctx, tenant, owned.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenantID, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + return withEnvironmentInputActivity(ctx, q, session, func() error { return apply(ctx, q, row) }) + }) +} + +func recordEnvironmentConnection(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow, status string) error { + var config struct { + Type string `json:"type"` + } + if err := json.Unmarshal(row.Configuration, &config); err != nil || (config.Type != "self_hosted" && config.Type != "openai_hosted") { + return errors.New("invalid stored Environment type") + } + if status != "connected" && status != "disconnected" && status != "failed" { + return ErrInvalidInput + } + if err := q.SetEnvironmentConnectionStatus(ctx, sqlc.SetEnvironmentConnectionStatusParams{ID: row.Environment.ID, Status: status}); err != nil { + return err + } + state := &v1.SessionEnvironmentState{ID: uuid.UUID(row.Environment.ID.Bytes).String(), Type: config.Type, Status: status} + if status == "failed" { + state.Error = &v1.StreamError{Code: "environment_unavailable", Type: "server_error", Message: "The environment could not be prepared for execution."} + } + return recordSessionChange(ctx, q, row.Environment.SessionID, SessionChange{Event: v1.SessionEvent{ + Type: "agent.session.environment." + status, + Environment: state, + }}) +} + +func parseConnectionGeneration(value string) (pgtype.UUID, error) { + id, err := parseID(value) + if err != nil || id.Bytes == [16]byte{} { + return pgtype.UUID{}, ErrInvalidInput + } + return id, nil +} diff --git a/services/agents-api/internal/store/environment_connections_test.go b/services/agents-api/internal/store/environment_connections_test.go new file mode 100644 index 000000000..c6a8397be --- /dev/null +++ b/services/agents-api/internal/store/environment_connections_test.go @@ -0,0 +1,221 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func connectionFixture(t *testing.T, s *Store) (string, Session, Environment) { + t.Helper() + tenant, session := environmentInputSession(t, s) + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + return tenant, session, environment +} + +func connectionSnapshot(t *testing.T, pool *pgxpool.Pool, id string) string { + t.Helper() + var value string + err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object('environment',to_jsonb(e),'observation',to_jsonb(c),'sequence',s.event_sequence)::text + FROM environments e JOIN sessions s ON s.id=e.session_id LEFT JOIN environment_connections c ON c.environment_id=e.id WHERE e.id=$1`, id).Scan(&value) + if err != nil { + t.Fatal(err) + } + return value +} + +func connectionChanges(t *testing.T, s *Store, tenant, session string) []SessionChange { + t.Helper() + all, err := s.ListSessionEvents(t.Context(), tenant, session, 0) + if err != nil { + t.Fatal(err) + } + var changes []SessionChange + for _, change := range all { + if change.Event.Environment != nil { + changes = append(changes, change) + } + } + return changes +} + +func TestEnvironmentConnectionOrdersGenerationsAndImmutableEvents(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment := connectionFixture(t, s) + writer := executionLease(t, s).Store() + first, second := uuid.NewString(), uuid.NewString() + replace := func(gen string) { + t.Helper() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, gen); err != nil { + t.Fatal(err) + } + } + observe := func(gen string, rev int64, connected bool) { + t.Helper() + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, gen, rev, connected); err != nil { + t.Fatal(err) + } + } + replace(first) + if len(connectionChanges(t, s, tenant, session.ID)) != 0 { + t.Fatal("registration claimed a connection") + } + observe(first, 1, true) + before := connectionSnapshot(t, pool, environment.ID) + replace(first) + observe(first, 1, true) + if after := connectionSnapshot(t, pool, environment.ID); after != before { + t.Fatal("retry changed a current observation") + } + observe(first, 3, false) + observe(first, 2, true) + observe(first, 4, true) + replace(second) + observe(second, 2, true) + before = connectionSnapshot(t, pool, environment.ID) + observe(first, 100, false) + observe(second, 1, false) + if after := connectionSnapshot(t, pool, environment.ID); after != before { + t.Fatal("late observation overwrote a successor") + } + changes := connectionChanges(t, s, tenant, session.ID) + want := []string{"connected", "disconnected", "connected", "disconnected", "connected"} + var got []string + for _, change := range changes { + event := change.Event + got = append(got, event.Environment.Status) + if event.SessionID != session.ID || event.Environment.ID != environment.ID || event.Environment.Type != "self_hosted" || event.Type != "agent.session.environment."+event.Environment.Status || event.EventID == "" || event.TurnID != "" { + t.Fatal("incorrect event identity", event) + } + raw, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + if len(wire) != 4 { + t.Fatal("private observation fields escaped", string(raw)) + } + var state map[string]json.RawMessage + if err := json.Unmarshal(wire["environment"], &state); err != nil { + t.Fatal(err) + } + if len(state) != 4 || string(state["error"]) != "null" || strings.Contains(string(raw), first) || strings.Contains(string(raw), second) { + t.Fatal("unsafe or incomplete state snapshot", string(raw)) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatal(got, want) + } + retained, err := s.GetEnvironment(t.Context(), tenant, environment.ID) + if err != nil || retained.Status != "connected" { + t.Fatal(retained, err) + } +} + +func TestEnvironmentConnectionRequiresOwnerAndRollsBackWithEvent(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment := connectionFixture(t, s) + generation := uuid.NewString() + if err := s.ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, generation); err == nil { + t.Fatal("unleased replacement accepted") + } + if err := s.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true); err == nil { + t.Fatal("unleased observation accepted") + } + lease := executionLease(t, s) + writer := lease.Store() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := writer.ReplaceEnvironmentConnection(t.Context(), uuid.NewString(), environment.ID, generation); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign generation accepted", err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, session.ID, generation, 1, true); !errors.Is(err, ErrNotFound) { + t.Fatal("Session ID used as Environment", err) + } + before := connectionSnapshot(t, pool, environment.ID) + constraint := pgx.Identifier{"connection_event_" + uuid.NewString()[:8]}.Sanitize() + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (payload->'event'->'environment'->>'id' IS DISTINCT FROM '"+environment.ID+"') NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, err := pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) + if err != nil { + t.Error(err) + } + }) + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true); err == nil { + t.Fatal("event failure did not abort transaction") + } + if after := connectionSnapshot(t, pool, environment.ID); after != before { + t.Fatal("event failure retained partial state/revision") + } + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + before = connectionSnapshot(t, pool, environment.ID) + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 2, false); err == nil { + t.Fatal("closed owner used pooled writer") + } + if after := connectionSnapshot(t, pool, environment.ID); after != before { + t.Fatal("closed owner changed observation") + } +} + +func TestEnvironmentConnectionDoesNotReviveDeletedOrTerminalResources(t *testing.T) { + for _, status := range []string{"deleted", "expired", "failed"} { + t.Run(status, func(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment := connectionFixture(t, s) + writer := executionLease(t, s).Store() + generation := uuid.NewString() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, generation); err != nil { + t.Fatal(err) + } + if status == "deleted" { + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + } else { + if _, err := pool.Exec(t.Context(), "UPDATE environments SET status=$2 WHERE id=$1", environment.ID, status); err != nil { + t.Fatal(err) + } + } + before := connectionSnapshot(t, pool, environment.ID) + for _, operation := range []func() error{ + func() error { + return writer.ReplaceEnvironmentConnection(t.Context(), tenant, environment.ID, uuid.NewString()) + }, + func() error { + return writer.ObserveEnvironmentConnection(t.Context(), tenant, environment.ID, generation, 1, true) + }, + } { + if err := operation(); err == nil { + t.Fatal("revived a terminal or deleted target") + } + } + if after := connectionSnapshot(t, pool, environment.ID); after != before { + t.Fatal("terminal observation changed") + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_device_test.go b/services/agents-api/internal/store/environment_device_test.go new file mode 100644 index 000000000..06b8cd887 --- /dev/null +++ b/services/agents-api/internal/store/environment_device_test.go @@ -0,0 +1,106 @@ +package store_test + +import ( + "errors" + "net/url" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func TestWorkerEnvironmentSelectsCapableDeviceWithoutMovingBinding(t *testing.T) { + for _, missing := range []string{"preparation", "remote_environment", "durable_input_receipts"} { + t.Run(missing, func(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + released := enableWorkerEnvironment(t, h) + caps := workerEnvironmentCapabilities() + caps.Preparation = missing != "preparation" + caps.RemoteEnvironment = missing != "remote_environment" + caps.DurableInputReceipts = missing != "durable_input_receipts" + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "reduced device capabilities", func() bool { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + return false + } + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.Preparation == caps.Preparation && info.Capabilities.RemoteEnvironment == caps.RemoteEnvironment && info.Capabilities.DurableInputReceipts == caps.DurableInputReceipts + }) + pending := unboundWorkerEnvironmentReservation(t, h) + bound := workerEnvironmentReservation(t, h) + frames := workerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + select { + case frame := <-frames: + t.Fatal("incapable device received work", frame.Type) + case <-time.After(time.Second): + } + if _, err := h.s.GetSessionDevice(t.Context(), h.tenant, pending.SessionID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("incapable device was bound", err) + } + other := connectWorkerEnvironmentDevice(t, h) + otherFrames := workerFrames(t, other) + request := nextWorkerFrame(t, otherFrames, proto.TypeExecutionPrepare) + selected, err := h.s.GetSessionDevice(t.Context(), h.tenant, pending.SessionID) + if err != nil || selected.ID != other.device.ID { + t.Fatal("eligible device was not bound before preparation", selected, err) + } + original, err := h.s.GetSessionDevice(t.Context(), h.tenant, bound.SessionID) + if err != nil || original.ID != h.device.ID { + t.Fatal("existing binding was moved", original, err) + } + handle := acknowledgePreparation(other, request.ID) + other.write(request.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) + nextWorkerFrame(t, otherFrames, proto.TypeExecutionRelease) + stop() + if released.Load() != 1 { + t.Fatal("preparation owner not released") + } + for _, value := range []store.EnvironmentInputReservation{pending, bound} { + stored, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, value.SessionID, value.ID) + if err != nil || stored.State != store.EnvironmentInputPending || !stored.Deadline.Equal(value.Deadline) { + t.Fatal("device selection changed pending input", stored, err) + } + assertEnvironmentExpiryHasNoHistory(t, pool, value.SessionID) + } + }) + } +} + +func connectWorkerEnvironmentDevice(t *testing.T, h *dispatchHarness) *dispatchHarness { + t.Helper() + other := *h + other.credential = uuid.NewString() + var err error + other.device, err = h.s.CreateDevice(t.Context(), h.tenant, "capable alternative", device.HashCredential(other.credential)) + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(h.url) + if err != nil { + t.Fatal(err) + } + u.Scheme, u.Path = "ws", "/api/v1/agent-daemon/ws" + u.RawQuery = url.Values{"device_id": {other.device.ID}, "token": {other.credential}, "version": {proto.Version}}.Encode() + other.conn, _, err = websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatal("alternative device connection failed") + } + t.Cleanup(func() { _ = other.conn.Close() }) + other.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: workerEnvironmentCapabilities()}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "alternative device capabilities", func() bool { + peer, err := h.registry.LookupDevice(other.device.ID) + if err != nil { + return false + } + info, found, known := peer.AgentKindStatus("codex") + return known && found && info.Capabilities.Preparation + }) + return &other +} diff --git a/services/agents-api/internal/store/environment_directory_active_test.go b/services/agents-api/internal/store/environment_directory_active_test.go new file mode 100644 index 000000000..4aac5abe3 --- /dev/null +++ b/services/agents-api/internal/store/environment_directory_active_test.go @@ -0,0 +1,48 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { + h, w, environment, released := directoryWorker(t, true) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true, WorkspaceReadPreparation: true}}}}) + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "execute", []store.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}) + if err != nil { + t.Fatal(err) + } + prepare := h.read(proto.TypeExecutionPrepare) + handle := acknowledgePreparation(h, prepare.ID) + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + frame := h.read(proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if frame.DecodePayload(&start) != nil || start.RunID == "" { + t.Fatal("execution did not start") + } + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + result := startDirectoryRead(t.Context(), w, environment) + read := h.read(proto.TypeWorkspaceRead) + var request proto.WorkspaceReadPayload + if read.DecodePayload(&request) != nil || request.RunID != start.RunID || request.Handle != "" || request.EnvironmentID != environment.ID { + t.Fatal("active read selected another execution owner") + } + size := int64(3) + h.write(read.ID, proto.TypeWorkspaceReadResult, proto.WorkspaceReadResultPayload{Outcome: "completed", CloseAcknowledged: true, Directory: &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{{Name: "active.txt", Kind: "file", SizeBytes: &size}}}}) + if got := awaitDirectoryResult(t, result); got.err != nil || len(got.value.Entries) != 1 { + t.Fatal("active read", got.err) + } + if released.Load() != 0 { + t.Fatal("active read released model execution") + } + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) + if run.Turn.Status != store.TurnCompleted { + t.Fatal("active read changed Turn outcome") + } + awaitDaemonRemoteCondition(t, context.Background(), 3*time.Second, "execution credential release", func() bool { return released.Load() == 1 }) +} diff --git a/services/agents-api/internal/store/environment_directory_native_test.go b/services/agents-api/internal/store/environment_directory_native_test.go new file mode 100644 index 000000000..2024ba5bf --- /dev/null +++ b/services/agents-api/internal/store/environment_directory_native_test.go @@ -0,0 +1,73 @@ +package store_test + +import ( + "context" + "errors" + "maps" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePreparedWorkerDirectory(t *testing.T) { + testNativePreparedWorkerRemoteEnvironment(t, true) +} + +func prepareWorkerDirectoryArtifact(t *testing.T, native string) *nativeHarnessArtifact { + t.Helper() + artifact, helper := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT"), os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") + if !filepath.IsAbs(artifact) || !filepath.IsAbs(helper) { + t.Skip("qualified private harness and directory helper required") + } + t.Setenv("PARSAR_CODEX_HARNESS_BIN", artifact) + t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/usr/local/bin/agents-api-codex-directory") + return newNativeHarnessArtifact(t, native, artifact) +} + +func verifyWorkerDirectoryReads(t *testing.T, ctx context.Context, h *dispatchHarness, w *execution.Worker, registry *codex.Registry, environment store.Environment, root string) map[string]any { + t.Helper() + wait := func() { + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor after directory release", func() bool { + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + return err == nil && connected + }) + } + wait() + stable := filepath.Join(root, "parsar-daemon", "agent-sessions", "agents-api-"+h.session.ID) + before := nativeReadStateHashes(t, stable) + session, err := h.s.GetSession(ctx, h.tenant, h.session.ID) + if err != nil || session.LastTurn == nil { + t.Fatal("missing completed native Turn") + } + result, err := w.ReadEnvironmentDirectory(ctx, environment, "") + if err != nil || result.Truncated { + t.Fatal("Core native directory read failed", err) + } + found := false + for _, entry := range result.Entries { + if entry.Name == "retained.txt" && entry.Kind == "file" && entry.SizeBytes != nil && *entry.SizeBytes == int64(len("remote-file-content\n")) { + found = true + } + } + if !found { + t.Fatal("Core directory omitted the real-model generated file") + } + if _, err := w.ReadEnvironmentDirectory(ctx, environment, "missing-directory"); !errors.Is(err, store.ErrNotFound) { + t.Fatal("Core missing directory result", err) + } + wait() + after, err := h.s.GetSession(ctx, h.tenant, h.session.ID) + if err != nil || after.LastTurn == nil || after.LastTurn.ID != session.LastTurn.ID || after.LastTurn.Status != store.TurnCompleted || !maps.Equal(before, nativeReadStateHashes(t, stable)) { + t.Fatal("directory read changed execution history or configuration") + } + remaining, err := filepath.Glob(filepath.Join(root, "parsar-daemon", "workspace-read", "read-*")) + if err != nil || len(remaining) != 0 { + t.Fatal("Core read returned before temporary state removal") + } + return map[string]any{"real_model_file_observed": true, "missing_directory_verified": true, "stable_state_unchanged": true, "turn_unchanged": true, "temporary_state_removed": true} +} diff --git a/services/agents-api/internal/store/environment_directory_test.go b/services/agents-api/internal/store/environment_directory_test.go new file mode 100644 index 000000000..03b299b57 --- /dev/null +++ b/services/agents-api/internal/store/environment_directory_test.go @@ -0,0 +1,228 @@ +package store_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type directoryResult struct { + value proto.WorkspaceDirectoryResult + err error +} + +func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *execution.Worker, store.Environment, *atomic.Int32) { + t.Helper() + h := newDispatchHarness(t) + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "read-only", Configuration: []byte(`{"agent":{"model":"unavailable-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := h.s.GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) + if err != nil { + t.Fatal(err) + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, Preparation: true, WorkspaceReadPreparation: true}}}}) + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + t.Fatal(err) + } + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "read preparation capability", func() bool { + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.WorkspaceReadPreparation + }) + released := &atomic.Int32{} + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + if len(execute) > 0 && execute[0] { + return nil, nil + } + t.Error("read resolved model credentials") + return nil, errors.New("no credentials") + } + h.d.EnvironmentConnection = func(ctx context.Context, s store.Session, e store.Environment) (execution.EnvironmentConnection, error) { + if ctx.Err() != nil || s.ID != h.session.ID || e.ID != environment.ID || e.TenantID != h.tenant { + t.Error("wrong reader binding") + } + return execution.EnvironmentConnection{URL: "http://read-transport.test", Token: "synthetic-read-token", Release: func() { released.Add(1) }}, nil + } + w, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- w.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("reader worker did not stop") + } + }) + return h, w, environment, released +} + +func startDirectoryRead(ctx context.Context, w *execution.Worker, environment store.Environment) <-chan directoryResult { + ch := make(chan directoryResult, 1) + go func() { + value, err := w.ReadEnvironmentDirectory(ctx, environment, "reports") + ch <- directoryResult{value, err} + }() + return ch +} + +func awaitDirectoryResult(t *testing.T, ch <-chan directoryResult) directoryResult { + t.Helper() + select { + case r := <-ch: + return r + case <-time.After(5 * time.Second): + t.Fatal("directory read did not return") + return directoryResult{} + } +} + +func prepareDirectoryRead(t *testing.T, h *dispatchHarness, environment store.Environment) (string, string) { + t.Helper() + frame := h.read(proto.TypeExecutionPrepare) + var request proto.ExecutionPreparePayload + if frame.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.RemoteEnvironment.ID != environment.ID || request.Configuration.AgentStateKey != "agents-api-"+h.session.ID { + t.Fatal("read did not use the closed preparation profile") + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + read := h.read(proto.TypeWorkspaceRead) + var input proto.WorkspaceReadPayload + if read.DecodePayload(&input) != nil || input.Handle != handle || input.RunID != "" || input.EnvironmentID != environment.ID || input.Path != "reports" || input.Operation != "directory" { + t.Fatal("directory request changed binding or path") + } + return frame.ID, read.ID +} + +func completeDirectoryRead(t *testing.T, h *dispatchHarness, request, read string, truncated, cleanupFailed bool) { + t.Helper() + size := int64(9) + h.write(read, proto.TypeWorkspaceReadResult, proto.WorkspaceReadResultPayload{Outcome: "completed", CloseAcknowledged: true, Directory: &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{{Name: "report.txt", Kind: "file", SizeBytes: &size}}, Truncated: truncated}}) + release := h.read(proto.TypeExecutionRelease) + var input proto.ExecutionReleasePayload + if release.ID != request || release.DecodePayload(&input) != nil || input.Handle == "" { + t.Fatal("reader did not release its preparation") + } + status := proto.PreparationStatusPayload{Handle: input.Handle, Revision: 3, State: "released"} + if cleanupFailed { + status.State, status.ErrorCode = "failed", "cleanup_unconfirmed" + } + h.write(request, proto.TypePreparationStatus, status) +} + +func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing.T) { + h, w, environment, released := directoryWorker(t) + foreign := environment + foreign.TenantID = uuid.NewString() + if _, err := w.ReadEnvironmentDirectory(t.Context(), foreign, "reports"); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign reader admitted", err) + } + wrong := environment + wrong.SessionID = uuid.NewString() + if _, err := w.ReadEnvironmentDirectory(t.Context(), wrong, "reports"); !errors.Is(err, store.ErrNotFound) { + t.Fatal("wrong Session admitted", err) + } + result := startDirectoryRead(t.Context(), w, environment) + request, read := prepareDirectoryRead(t, h, environment) + if _, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("second idle reader bypassed Session owner", err) + } + select { + case <-result: + t.Fatal("read returned before settlement") + default: + } + completeDirectoryRead(t, h, request, read, false, false) + got := awaitDirectoryResult(t, result) + if got.err != nil || len(got.value.Entries) != 1 || released.Load() != 1 { + t.Fatal("directory result", got.err, released.Load()) + } + session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.LastTurn != nil || session.EnvironmentInputActivity != nil { + t.Fatal("directory read manufactured execution") + } + bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) + if err != nil || bound.Device.ID != h.device.ID || bound.NativeSessionID != "" { + t.Fatal("directory read changed native history identity") + } +} + +func TestEnvironmentDirectoryWorkerRejectsIncompleteOrUnreleasedResults(t *testing.T) { + for _, mode := range []string{"truncated", "cleanup_failed"} { + t.Run(mode, func(t *testing.T) { + h, w, environment, released := directoryWorker(t) + result := startDirectoryRead(t.Context(), w, environment) + request, read := prepareDirectoryRead(t, h, environment) + completeDirectoryRead(t, h, request, read, mode == "truncated", mode == "cleanup_failed") + got := awaitDirectoryResult(t, result) + if !errors.Is(got.err, execution.ErrExecutionUnavailable) || len(got.value.Entries) != 0 || released.Load() != 1 { + t.Fatal("incomplete reader exposed data or retained authority", got.err) + } + }) + } +} + +func TestEnvironmentDirectorySequentialReadsReleaseSchedulingOwnership(t *testing.T) { + h, w, environment, released := directoryWorker(t) + const pages = 32 + done := make(chan error, 1) + go func() { + for range pages { + value, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports") + if err != nil { + done <- err + return + } + if len(value.Entries) != 1 { + done <- errors.New("missing directory page") + return + } + } + done <- nil + }() + for range pages { + request, read := prepareDirectoryRead(t, h, environment) + completeDirectoryRead(t, h, request, read, false, false) + } + select { + case err := <-done: + if err != nil || released.Load() != pages { + t.Fatal("sequential reads retained ownership", err, released.Load()) + } + case <-time.After(5 * time.Second): + t.Fatal("sequential reads did not finish") + } +} + +func TestEnvironmentDirectoryObserverCancellationRetainsReadOwner(t *testing.T) { + h, w, environment, released := directoryWorker(t) + ctx, cancel := context.WithCancel(t.Context()) + result := startDirectoryRead(ctx, w, environment) + request, read := prepareDirectoryRead(t, h, environment) + cancel() + if got := awaitDirectoryResult(t, result); !errors.Is(got.err, execution.ErrExecutionUnavailable) { + t.Fatal("cancelled observer result", got.err) + } + if released.Load() != 0 { + t.Fatal("observer cancelled native ownership") + } + if _, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("cancelled observer freed Session owner") + } + completeDirectoryRead(t, h, request, read, false, false) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "reader release", func() bool { return released.Load() == 1 }) +} diff --git a/services/agents-api/internal/store/environment_executor_command_test.go b/services/agents-api/internal/store/environment_executor_command_test.go new file mode 100644 index 000000000..282c5e922 --- /dev/null +++ b/services/agents-api/internal/store/environment_executor_command_test.go @@ -0,0 +1,78 @@ +package store_test + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestEnvironmentExecutorOperatorCommand(t *testing.T) { + binary := os.Getenv("PARSAR_ENVIRONMENT_KEY_BINARY") + if binary == "" { + t.Skip("built environment-key operator executable required") + } + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + keyID := uuid.NewString() + command := func(owner string, success bool, flags ...string) string { + t.Helper() + args := append([]string{"--tenant", owner, "--organization", principal.OrganizationID, "--project", principal.ProjectID, "--subject-kind", principal.SubjectKind, "--subject-id", principal.SubjectID, "--key-id", keyID}, flags...) + cmd := exec.CommandContext(t.Context(), binary, args...) + cmd.Env = append(os.Environ(), "AGENTS_API_DATABASE_URL="+pool.Config().ConnConfig.ConnString()) + data, err := cmd.Output() + if (err == nil) != success { + t.Fatal("unexpected operator outcome", flags) + } + if !success || (len(flags) == 1 && flags[0] == "--revoke") { + if len(data) != 0 { + t.Fatal("failed/revoke command emitted secret output") + } + return "" + } + var output struct { + KeyID string `json:"key_id"` + EnvironmentID string `json:"environment_id"` + Token string `json:"executor_token"` + } + if json.Unmarshal(data, &output) != nil || output.KeyID != keyID || output.EnvironmentID != "" || output.Token == "" { + t.Fatal("invalid operator output") + } + return output.Token + } + command(uuid.NewString(), false) + first := command(tenant, true) + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "operator-key", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + + command(tenant, false) + command(tenant, false, "--rotate", "--revoke") + command(tenant, false, "--rotate", "--environment", environment.ID) + next := command(tenant, true, "--rotate") + if next == first { + t.Fatal("rotation returned the same key") + } + if _, err := s.AuthenticateEnvironmentExecutor(t.Context(), environment.ID, device.HashCredential(first)); !errors.Is(err, store.ErrNotFound) { + t.Fatal("old command credential retained authority", err) + } + if owner, err := s.AuthenticateEnvironmentExecutor(t.Context(), environment.ID, device.HashCredential(next)); err != nil || owner != tenant { + t.Fatal("rotated command credential failed", err) + } + command(tenant, true, "--revoke") + if _, err := s.AuthenticateEnvironmentExecutor(t.Context(), environment.ID, device.HashCredential(next)); !errors.Is(err, store.ErrNotFound) { + t.Fatal("revoked command credential retained authority", err) + } + t.Log("built operator command issued before Session creation, rejected duplicate/foreign requests, rotated and revoked durable credentials") +} diff --git a/services/agents-api/internal/store/environment_executor_credentials.go b/services/agents-api/internal/store/environment_executor_credentials.go new file mode 100644 index 000000000..fdb7c981a --- /dev/null +++ b/services/agents-api/internal/store/environment_executor_credentials.go @@ -0,0 +1,145 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrExecutorCredentialExists = errors.New("executor key ID already exists; rotate explicitly") + +type IssuedExecutorCredential struct { + KeyID string `json:"key_id"` + EnvironmentID string `json:"environment_id,omitempty"` + Token string `json:"executor_token"` +} + +// IssueExecutorCredential returns a new connect-only secret once, without replacing an existing ID. +func (s *Store) IssueExecutorCredential(ctx context.Context, principal identity.Principal, keyID, environment string) (IssuedExecutorCredential, error) { + tenant, id, err := executorCredentialIdentity(principal, keyID) + if err != nil { + return IssuedExecutorCredential{}, err + } + exists, err := s.queries.ExecutorProjectScopeExists(ctx, sqlc.ExecutorProjectScopeExistsParams{TenantID: tenant, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID}) + if err != nil { + return IssuedExecutorCredential{}, err + } + if !exists { + return IssuedExecutorCredential{}, ErrNotFound + } + var restriction pgtype.UUID + if environment != "" { + restriction, err = parseID(environment) + if err != nil { + return IssuedExecutorCredential{}, err + } + } + token, digest, err := newExecutorSecret() + if err != nil { + return IssuedExecutorCredential{}, err + } + var result IssuedExecutorCredential + err = s.withExecutorCredentialTarget(ctx, principal, restriction, func(ctx context.Context, q *sqlc.Queries) error { + row, err := q.IssueExecutorCredential(ctx, sqlc.IssueExecutorCredentialParams{ + KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, + OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, EnvironmentID: restriction, TokenSha256: digest, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrExecutorCredentialExists + } + if err != nil { + return err + } + result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) + return nil + }) + if err != nil { + return IssuedExecutorCredential{}, err + } + return result, nil +} + +func (s *Store) RotateExecutorCredential(ctx context.Context, principal identity.Principal, keyID string) (IssuedExecutorCredential, error) { + tenant, id, err := executorCredentialIdentity(principal, keyID) + if err != nil { + return IssuedExecutorCredential{}, err + } + restriction, err := s.executorCredentialRestriction(ctx, principal, tenant, id) + if err != nil { + return IssuedExecutorCredential{}, err + } + token, digest, err := newExecutorSecret() + if err != nil { + return IssuedExecutorCredential{}, err + } + var result IssuedExecutorCredential + err = s.withExecutorCredentialTarget(ctx, principal, restriction, func(ctx context.Context, q *sqlc.Queries) error { + row, err := q.RotateExecutorCredential(ctx, sqlc.RotateExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, TokenSha256: digest}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) + return nil + }) + if err != nil { + return IssuedExecutorCredential{}, err + } + return result, nil +} + +func (s *Store) RevokeExecutorCredential(ctx context.Context, principal identity.Principal, keyID string) error { + tenant, id, err := executorCredentialIdentity(principal, keyID) + if err != nil { + return err + } + if _, err := s.executorCredentialRestriction(ctx, principal, tenant, id); err != nil { + return err + } + n, err := s.queries.RevokeExecutorCredential(ctx, sqlc.RevokeExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}}) + if err == nil && n == 0 { + return ErrNotFound + } + return err +} + +func (s *Store) executorCredentialRestriction(ctx context.Context, principal identity.Principal, tenant, id pgtype.UUID) (pgtype.UUID, error) { + restriction, err := s.queries.GetExecutorCredentialForPrincipal(ctx, sqlc.GetExecutorCredentialForPrincipalParams{ + KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, + OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return pgtype.UUID{}, ErrNotFound + } + return restriction, err +} + +// AuthenticateEnvironmentExecutor checks the current key and recorded Session creator in one database snapshot. +func (s *Store) AuthenticateEnvironmentExecutor(ctx context.Context, environment, digest string) (string, error) { + id, err := parseID(environment) + if err != nil { + return "", ErrNotFound + } + hash, err := hex.DecodeString(digest) + if err != nil || len(hash) != sha256.Size { + return "", ErrNotFound + } + tenant, err := s.queries.AuthenticateEnvironmentExecutor(ctx, sqlc.AuthenticateEnvironmentExecutorParams{EnvironmentID: id, TokenSha256: hex.EncodeToString(hash)}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("authenticate environment executor: %w", err) + } + return uuid.UUID(tenant.Bytes).String(), nil +} diff --git a/services/agents-api/internal/store/environment_executor_credentials_test.go b/services/agents-api/internal/store/environment_executor_credentials_test.go new file mode 100644 index 000000000..7ababfbd4 --- /dev/null +++ b/services/agents-api/internal/store/environment_executor_credentials_test.go @@ -0,0 +1,179 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "sync" + "testing" + + "github.com/google/uuid" +) + +func executorDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func TestEnvironmentExecutorCredentialLifecycle(t *testing.T) { + s, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + ctx := t.Context() + principal := FixtureExecutorPrincipal(t, s, tenant) + session, err := s.CreateSession(ctx, tenant, environmentInput("credential", "self_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.IssueExecutorCredential(ctx, FixtureExecutorPrincipal(t, s, foreign), environment.ID, environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign issue", err) + } + if _, err := s.RotateExecutorCredential(ctx, principal, environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("rotate manufactured a credential", err) + } + issued, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + token := issued.Token + if err != nil || len(token) != 43 { + t.Fatal("issue failed", err) + } + check := func(st *Store, token string, allowed bool) { + t.Helper() + owner, err := st.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(token)) + if allowed { + if err != nil || owner != tenant { + t.Fatal("credential not accepted for owner", err) + } + } else if !errors.Is(err, ErrNotFound) { + t.Fatal("invalid credential accepted", err) + } + } + check(s, token, true) + check(s, "caller/device/harness/grant", false) + if _, err := s.AuthenticateEnvironmentExecutor(ctx, uuid.NewString(), executorDigest(token)); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Environment accepted", err) + } + if _, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("issue silently replaced credential", err) + } + if err := s.RevokeExecutorCredential(ctx, FixtureExecutorPrincipal(t, s, foreign), environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign revoke", err) + } + if _, err := s.RotateExecutorCredential(ctx, FixtureExecutorPrincipal(t, s, foreign), environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign rotate", err) + } + var stored string + if err := pool.QueryRow(ctx, "SELECT token_sha256 FROM environment_executor_credentials WHERE environment_id=$1", environment.ID).Scan(&stored); err != nil || stored != executorDigest(token) { + t.Fatal("digest persistence", err) + } + // Executor authority does not inherit the five-minute connection grant lifetime. + if _, err := pool.Exec(ctx, "UPDATE environment_executor_credentials SET issued_at=now()-interval '1 day' WHERE environment_id=$1", environment.ID); err != nil { + t.Fatal(err) + } + restarted, _ := testStore(t) + check(restarted, token, true) + next, err := restarted.RotateExecutorCredential(ctx, principal, environment.ID) + if err != nil || next.Token == token { + t.Fatal("rotation failed", err) + } + check(s, token, false) + check(s, next.Token, true) + for range 2 { + if err := s.RevokeExecutorCredential(ctx, principal, environment.ID); err != nil { + t.Fatal(err) + } + } + check(restarted, next.Token, false) + if _, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("ordinary issue resurrected revoked authority", err) + } + restored, err := s.RotateExecutorCredential(ctx, principal, environment.ID) + if err != nil { + t.Fatal(err) + } + check(restarted, next.Token, false) + check(restarted, restored.Token, true) + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + check(restarted, restored.Token, false) + if _, err := s.RotateExecutorCredential(ctx, principal, environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session authority resurrected", err) + } + if err := pool.QueryRow(ctx, "SELECT token_sha256 FROM environment_executor_credentials WHERE environment_id=$1", environment.ID).Scan(&stored); err != nil || stored != executorDigest(restored.Token) { + t.Fatal("deleted ownership was destroyed", err) + } +} + +func TestEnvironmentExecutorConcurrentIssueAndDeletion(t *testing.T) { + s, _ := testStore(t) + other, _ := testStore(t) + ctx := t.Context() + tenant := uuid.NewString() + principal := FixtureExecutorPrincipal(t, s, tenant) + session, err := s.CreateSession(ctx, tenant, environmentInput("concurrent-key", "self_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + // Provisioning remains control-plane work while the execution owner is active. + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(ctx) + const attempts = 8 + var wg sync.WaitGroup + tokens := make(chan string, attempts) + for i := range attempts { + wg.Add(1) + go func() { + defer wg.Done() + st := s + if i%2 != 0 { + st = other + } + token, err := st.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err == nil { + tokens <- token.Token + } else if !errors.Is(err, ErrExecutorCredentialExists) { + t.Error(err) + } + }() + } + wg.Wait() + close(tokens) + if len(tokens) != 1 { + t.Fatal("issue did not choose one winner", len(tokens)) + } + original := <-tokens + rotated := make(chan string, 1) + wg.Add(1) + go func() { + defer wg.Done() + token, err := other.RotateExecutorCredential(ctx, principal, environment.ID) + if err == nil { + rotated <- token.Token + } else if !errors.Is(err, ErrNotFound) { + t.Error(err) + } + }() + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + wg.Wait() + close(rotated) + for token := range rotated { + if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(token)); !errors.Is(err, ErrNotFound) { + t.Fatal("racing rotation authorized deleted Environment", err) + } + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(original)); !errors.Is(err, ErrNotFound) { + t.Fatal("original key survived deletion", err) + } +} diff --git a/services/agents-api/internal/store/environment_expiry_dispatch_test.go b/services/agents-api/internal/store/environment_expiry_dispatch_test.go new file mode 100644 index 000000000..17cc9c360 --- /dev/null +++ b/services/agents-api/internal/store/environment_expiry_dispatch_test.go @@ -0,0 +1,90 @@ +package store_test + +import ( + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func enableEnvironmentExpiryDispatch(h *dispatchHarness) { + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{ + Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, + }}}}) +} + +func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + enableEnvironmentExpiryDispatch(h) + worker, stop := startEnvironmentExpiryWorker(t, h.d) + var requests []proto.Envelope + var sessions []store.Session + for _, key := range []string{"one", "two", "three", "four"} { + session := publicSession(t, h, key) + if _, err := worker.SubmitInputs(t.Context(), h.tenant, session.ID, key, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"remain active"}`)}}); err != nil { + t.Fatal(err) + } + requests = append(requests, h.read(proto.TypePromptRequest)) + sessions = append(sessions, session) + } + tenant, due := newEnvironmentExpiryReservation(t, h.s) + makeEnvironmentExpiryDue(t, pool, &due) + waitEnvironmentExpiry(t, h.s, tenant, due) + for i, request := range requests { + turn, err := h.s.GetTurn(t.Context(), h.tenant, sessions[i].ID, request.ID) + if err != nil || turn.Status != store.TurnInProgress { + t.Fatal("expiry was not observed at full capacity", turn, err) + } + } + for i, request := range requests { + h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + h.session = sessions[i] + waitTurn(t, h, request.ID, store.TurnCompleted) + } + stop() + assertEnvironmentExpiryHasNoHistory(t, pool, due.SessionID) +} + +func TestWorkerEnvironmentExpirySkipsBusySessionAndAllowsDispatch(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + enableEnvironmentExpiryDispatch(h) + lockedTenant, locked := newEnvironmentExpiryReservation(t, h.s) + otherTenant, other := newEnvironmentExpiryReservation(t, h.s) + makeEnvironmentExpiryDue(t, pool, &locked) + makeEnvironmentExpiryDue(t, pool, &other) + tx, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(t.Context()) }() + if _, err := tx.Exec(t.Context(), "SELECT id FROM sessions WHERE id=$1 FOR UPDATE", locked.SessionID); err != nil { + t.Fatal(err) + } + worker, stop := startEnvironmentExpiryWorker(t, h.d) + h.session = publicSession(t, h, "unrelated") + receipt, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"make normal progress"}`)}}) + if err != nil { + t.Fatal(err) + } + waitEnvironmentExpiry(t, h.s, otherTenant, other) + request := h.read(proto.TypePromptRequest) + if request.ID != receipt[0].TurnID { + t.Fatal("unrelated dispatch mismatch", request.ID) + } + h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + waitTurn(t, h, request.ID, store.TurnCompleted) + var state string + if err := pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE id=$1", locked.ID).Scan(&state); err != nil || state != store.EnvironmentInputPending { + t.Fatal("sweep did not honor Session lock", state, err) + } + if err := tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + waitEnvironmentExpiry(t, h.s, lockedTenant, locked) + stop() + assertEnvironmentExpiryHasNoHistory(t, pool, locked.SessionID) + assertEnvironmentExpiryHasNoHistory(t, pool, other.SessionID) +} diff --git a/services/agents-api/internal/store/environment_expiry_worker_test.go b/services/agents-api/internal/store/environment_expiry_worker_test.go new file mode 100644 index 000000000..073ba1d38 --- /dev/null +++ b/services/agents-api/internal/store/environment_expiry_worker_test.go @@ -0,0 +1,123 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func newEnvironmentExpiryReservation(t *testing.T, s *store.Store) (string, store.EnvironmentInputReservation) { + t.Helper() + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), + Engine: "codex", IdempotencyKey: "environment", + Configuration: json.RawMessage(`{"agent":{"model":"fixture-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), + }) + if err != nil { + t.Fatal(err) + } + pending, err := s.ReserveEnvironmentInput(t.Context(), tenant, session.ID, "pending", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wait for the environment"}`)}}) + if err != nil { + t.Fatal(err) + } + return tenant, pending +} + +func makeEnvironmentExpiryDue(t *testing.T, pool *pgxpool.Pool, pending *store.EnvironmentInputReservation) { + t.Helper() + if err := pool.QueryRow(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1 RETURNING deadline", pending.ID).Scan(&pending.Deadline); err != nil { + t.Fatal(err) + } +} + +func startEnvironmentExpiryWorker(t *testing.T, d *execution.Dispatcher) (*execution.Worker, func()) { + t.Helper() + worker, err := execution.StartWorker(t.Context(), d) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + select { + case err := <-done: + if err != nil && !errors.Is(err, context.Canceled) { + t.Error("worker stopped with error", err) + } + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }) + } + t.Cleanup(stop) + return worker, stop +} + +func waitEnvironmentExpiry(t *testing.T, s *store.Store, tenant string, pending store.EnvironmentInputReservation) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + got, err := s.GetEnvironmentInputReservation(t.Context(), tenant, pending.SessionID, pending.ID) + if err != nil { + t.Fatal(err) + } + if got.State == store.EnvironmentInputExpired { + if got.SettledAt == nil || !got.Deadline.Equal(pending.Deadline) || len(got.Receipts) != 0 { + t.Fatal("expiry changed identity or created receipts", got) + } + return + } + if time.Now().After(deadline) { + t.Fatal("worker did not expire input", got.State) + } + time.Sleep(20 * time.Millisecond) + } +} + +func assertEnvironmentExpiryHasNoHistory(t *testing.T, pool *pgxpool.Pool, session string) { + t.Helper() + var count int + err := pool.QueryRow(t.Context(), ` + SELECT (SELECT count(*) FROM turns WHERE session_id=$1) + + (SELECT count(*) FROM turn_inputs WHERE session_id=$1) + + (SELECT count(*) FROM session_items WHERE session_id=$1) + + (SELECT count(*) FROM session_events WHERE session_id=$1 + AND (payload ? 'turn' OR payload->'event' ? 'item'))`, session).Scan(&count) + if err != nil || count != 0 { + t.Fatal("pre-Turn expiry created Turn or Item history", count, err) + } +} + +func TestWorkerEnvironmentExpiryWithoutDevicesAndAfterRestart(t *testing.T) { + s, pool := store.NewTestStore(t) + dueTenant, due := newEnvironmentExpiryReservation(t, s) + futureTenant, future := newEnvironmentExpiryReservation(t, s) + makeEnvironmentExpiryDue(t, pool, &due) + d := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry()} + _, stop := startEnvironmentExpiryWorker(t, d) + waitEnvironmentExpiry(t, s, dueTenant, due) + got, err := s.GetEnvironmentInputReservation(t.Context(), futureTenant, future.SessionID, future.ID) + if err != nil || got.State != store.EnvironmentInputPending || !got.Deadline.Equal(future.Deadline) { + t.Fatal("future input changed", got, err) + } + stop() + makeEnvironmentExpiryDue(t, pool, &future) + _, stop = startEnvironmentExpiryWorker(t, d) + waitEnvironmentExpiry(t, s, futureTenant, future) + stop() + assertEnvironmentExpiryHasNoHistory(t, pool, due.SessionID) + assertEnvironmentExpiryHasNoHistory(t, pool, future.SessionID) +} diff --git a/services/agents-api/internal/store/environment_file_write_migration_test.go b/services/agents-api/internal/store/environment_file_write_migration_test.go new file mode 100644 index 000000000..e582a0396 --- /dev/null +++ b/services/agents-api/internal/store/environment_file_write_migration_test.go @@ -0,0 +1,90 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestEnvironmentFileWriteMigrationRetainsUnresolvedIdentity(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "file_write_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := pool.Exec(cleanup, "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 35); err != nil { + t.Fatal(err) + } + session, tenant, environment, device, write := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) + VALUES ($1,$2,'codex','old','old','{}')`, session, tenant); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO environments(id,session_id) VALUES ($1,$2)`, environment, session); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO devices(id,tenant_id,name,credential_hash) VALUES ($1,$2,'old',$3)`, device, tenant, strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + var before, after string + if err := db.QueryRowContext(ctx, `SELECT to_jsonb(e)::text FROM environments e WHERE id=$1`, environment).Scan(&before); err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := provider.UpTo(ctx, 36); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM environment_file_writes").Scan(&count); err != nil || count != 0 { + t.Fatal("migration manufactured write authority", count, err) + } + if _, err := provider.DownTo(ctx, 35); err != nil { + t.Fatal("empty downgrade", err) + } + } + if _, err := provider.UpTo(ctx, 36); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) + VALUES ($1,$2,$3,$4)`, write, environment, device, strings.Repeat("b", 64)); err != nil { + t.Fatal(err) + } + var original, retained string + if err := db.QueryRowContext(ctx, `SELECT to_jsonb(w)::text FROM environment_file_writes w WHERE id=$1`, write).Scan(&original); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 35); err == nil || !strings.Contains(err.Error(), "Cannot remove durable Environment file write identities") { + t.Fatal("downgrade removed unresolved identity", err) + } + if err := db.QueryRowContext(ctx, `SELECT to_jsonb(w)::text FROM environment_file_writes w WHERE id=$1`, write).Scan(&retained); err != nil || retained != original { + t.Fatal("failed downgrade changed write", err) + } + if err := db.QueryRowContext(ctx, `SELECT to_jsonb(e)::text FROM environments e WHERE id=$1`, environment).Scan(&after); err != nil || after != before { + t.Fatal("migration changed Environment", err) + } +} diff --git a/services/agents-api/internal/store/environment_file_writes.go b/services/agents-api/internal/store/environment_file_writes.go new file mode 100644 index 000000000..2c56a96f9 --- /dev/null +++ b/services/agents-api/internal/store/environment_file_writes.go @@ -0,0 +1,197 @@ +package store + +import ( + "context" + "encoding/hex" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// FileWriteIdentity binds a private mutation to one dedicated local Runtime. RequestSHA256 covers the canonical destination, byte count and data digest. +// The caller must qualify that binding and validate native receipts independently; +// persistence alone is neither placement authority nor permission to send bytes. +type FileWriteIdentity struct { + ID, DeviceID, RequestSHA256 string +} + +type EnvironmentFileWrite struct { + Identity FileWriteIdentity + EnvironmentID, SessionID string + State string + CreatedAt time.Time + SettledAt *time.Time + Replayed bool +} + +func (k FileWriteIdentity) valid() bool { + for _, value := range []string{k.ID, k.DeviceID} { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil || id.String() != value { + return false + } + } + digest, err := hex.DecodeString(k.RequestSHA256) + return err == nil && len(digest) == 32 && hex.EncodeToString(digest) == k.RequestSHA256 +} + +// ReserveEnvironmentFileWrite persists intent before external dispatch. A retry +// observes the earlier operation and never authorizes resending an unknown write. +func (s *Store) ReserveEnvironmentFileWrite(ctx context.Context, tenant, environment string, key FileWriteIdentity) (EnvironmentFileWrite, error) { + if s.executionLease == nil || !key.valid() { + return EnvironmentFileWrite{}, ErrInvalidInput + } + owned, err := s.GetEnvironment(ctx, tenant, environment) + if err != nil { + return EnvironmentFileWrite{}, err + } + lookup, err := fileWriteLookup(tenant, environment, key.ID) + if err != nil { + return EnvironmentFileWrite{}, err + } + var result EnvironmentFileWrite + err = s.withPublicSession(ctx, tenant, owned.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + previous, err := q.GetEnvironmentFileWrite(ctx, lookup) + if err == nil { + result = fileWriteFromRow(previous.EnvironmentFileWrite, session) + result.Replayed = true + if result.Identity != key { + return ErrIdempotencyConflict + } + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + current, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: lookup.TenantID, ID: session}) + if err != nil { + return err + } + if current.Environment.ID != lookup.EnvironmentID || current.Environment.Status == "failed" || current.Environment.Status == "expired" { + return ErrInvalidInput + } + device, err := q.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams{TenantID: lookup.TenantID, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if uuid.UUID(device.ID.Bytes).String() != key.DeviceID || device.EnvironmentID != lookup.EnvironmentID { + return ErrDeviceBindingConflict + } + if err := environmentInputMayStart(ctx, q, session); err != nil { + return err + } + pending, err := q.EnvironmentFileWriteHasPendingInput(ctx, session) + if err != nil { + return err + } + if pending { + return ErrTurnConflict + } + deviceID, _ := parseID(key.DeviceID) + row, err := q.CreateEnvironmentFileWrite(ctx, sqlc.CreateEnvironmentFileWriteParams{ + ID: lookup.ID, EnvironmentID: lookup.EnvironmentID, DeviceID: deviceID, RequestSha256: key.RequestSHA256, + }) + if err == nil { + result = fileWriteFromRow(row, session) + } + return err + }) + if err != nil { + return EnvironmentFileWrite{}, err + } + return result, nil +} + +// GetEnvironmentFileWrite also retains deleted-Session intents for internal +// cleanup. It is not a public resource query and never authorizes dispatch. +func (s *Store) GetEnvironmentFileWrite(ctx context.Context, tenant, environment, id string) (EnvironmentFileWrite, error) { + lookup, err := fileWriteLookup(tenant, environment, id) + if err != nil { + return EnvironmentFileWrite{}, err + } + row, err := s.queries.GetEnvironmentFileWrite(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return EnvironmentFileWrite{}, ErrNotFound + } + if err != nil { + return EnvironmentFileWrite{}, err + } + result := fileWriteFromRow(row.EnvironmentFileWrite, row.SessionID) + result.Replayed = true + return result, nil +} + +// SettleEnvironmentFileWrite requires an independently validated exact receipt. +// A missing receipt, cancellation or owner retirement is not a rejected upload. +func (s *Store) SettleEnvironmentFileWrite(ctx context.Context, tenant, environment string, key FileWriteIdentity, state string) (EnvironmentFileWrite, error) { + if s.executionLease == nil || !key.valid() || (state != "committed" && state != "rejected") { + return EnvironmentFileWrite{}, ErrInvalidInput + } + previous, err := s.GetEnvironmentFileWrite(ctx, tenant, environment, key.ID) + if err != nil { + return EnvironmentFileWrite{}, err + } + lookup, _ := fileWriteLookup(tenant, environment, key.ID) + var result EnvironmentFileWrite + err = s.withSession(ctx, tenant, previous.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + current, err := q.GetEnvironmentFileWrite(ctx, lookup) + if err != nil { + return err + } + result = fileWriteFromRow(current.EnvironmentFileWrite, session) + if result.Identity != key || (result.State != "pending" && result.State != state) { + return ErrIdempotencyConflict + } + if result.State == state { + result.Replayed = true + return nil + } + row, err := q.SettleEnvironmentFileWrite(ctx, sqlc.SettleEnvironmentFileWriteParams{EnvironmentID: lookup.EnvironmentID, ID: lookup.ID, State: state}) + if err == nil { + result = fileWriteFromRow(row, session) + } + return err + }) + if err != nil { + return EnvironmentFileWrite{}, err + } + return result, nil +} + +func fileWriteLookup(tenant, environment, id string) (sqlc.GetEnvironmentFileWriteParams, error) { + var result sqlc.GetEnvironmentFileWriteParams + var err error + result.TenantID, err = parseID(tenant) + if err == nil { + result.EnvironmentID, err = parseID(environment) + } + if err == nil { + result.ID, err = parseID(id) + } + return result, err +} + +func fileWriteFromRow(row sqlc.EnvironmentFileWrite, session pgtype.UUID) EnvironmentFileWrite { + result := EnvironmentFileWrite{Identity: FileWriteIdentity{ID: uuid.UUID(row.ID.Bytes).String(), DeviceID: uuid.UUID(row.DeviceID.Bytes).String(), RequestSHA256: row.RequestSha256}, + EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), SessionID: uuid.UUID(session.Bytes).String(), State: row.State, CreatedAt: row.CreatedAt.Time} + if row.SettledAt.Valid { + result.SettledAt = &row.SettledAt.Time + } + return result +} + +func checkEnvironmentFileWriteGate(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + blocked, err := q.EnvironmentFileWriteBlocksSession(ctx, session) + if err == nil && blocked { + return ErrTurnConflict + } + return err +} diff --git a/services/agents-api/internal/store/environment_file_writes_test.go b/services/agents-api/internal/store/environment_file_writes_test.go new file mode 100644 index 000000000..37314a11b --- /dev/null +++ b/services/agents-api/internal/store/environment_file_writes_test.go @@ -0,0 +1,212 @@ +package store + +import ( + "errors" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/google/uuid" +) + +type fileWriteFixture struct { + s, writer *Store + lease *ExecutionLease + tenant string + session Session + env Environment + key FileWriteIdentity +} + +func newFileWriteFixture(t *testing.T) fileWriteFixture { + t.Helper() + s, _ := testStore(t) + lease := executionLease(t, s) + tenant := uuid.NewString() + session, env := localEnvironment(t, s, tenant) + host, err := s.CreateEnvironmentDevice(t.Context(), tenant, env.ID, "file owner", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + return fileWriteFixture{s: s, writer: lease.Store(), lease: lease, tenant: tenant, session: session, env: env, + key: FileWriteIdentity{ID: uuid.NewString(), DeviceID: host.ID, RequestSHA256: strings.Repeat("a", 64)}} +} + +func TestEnvironmentFileWriteRetainsUnknownAcrossLeaseLoss(t *testing.T) { + f := newFileWriteFixture(t) + ctx := t.Context() + first, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key) + if err != nil || first.Replayed || first.State != "pending" { + t.Fatal(first, err) + } + var killed bool + if err := f.s.pool.QueryRow(ctx, "SELECT pg_terminate_backend($1, 1000)", f.lease.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "committed"); err == nil { + t.Fatal("lost writer settled an upload") + } + reopened, _ := testStore(t) + next := executionLease(t, reopened).Store() + got, err := next.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key) + if err != nil || !got.Replayed || got.State != "pending" || !got.CreatedAt.Equal(first.CreatedAt) || got.Identity != f.key { + t.Fatal("restart lost unknown write identity", got, err) + } + another := f.key + another.ID = uuid.NewString() + if _, err := next.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, another); !errors.Is(err, ErrTurnConflict) { + t.Fatal("restart admitted successor", err) + } + if _, err := reopened.ReserveEnvironmentInput(ctx, f.tenant, f.session.ID, "new-input", []Input{messageInput("new")}); !errors.Is(err, ErrTurnConflict) { + t.Fatal("unknown write admitted input", err) + } + if _, err := reopened.SubmitMessage(ctx, f.tenant, f.session.ID, "direct", messageInput("new").Payload); !errors.Is(err, ErrTurnConflict) { + t.Fatal("direct admission bypassed write", err) + } + if _, err := reopened.GetEnvironment(ctx, f.tenant, f.env.ID); err != nil { + t.Fatal("write gate prevented metadata read", err) + } + if _, err := reopened.GetSession(ctx, f.tenant, f.session.ID); err != nil { + t.Fatal("write gate prevented recovery read", err) + } + if receipt, err := reopened.RequestCancel(ctx, f.tenant, f.session.ID, "idle-cancel"); err != nil || receipt.TurnID != "" { + t.Fatal("write gate imposed mutation admission on idle cancellation", receipt, err) + } + if got, err := reopened.GetEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key.ID); err != nil || got.State != "pending" { + t.Fatal("idle cancellation cleared unknown write", got, err) + } + settled, err := next.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "committed") + if err != nil || settled.State != "committed" || settled.SettledAt == nil { + t.Fatal(settled, err) + } + reserveEnvironmentInput(t, reopened, f.tenant, f.session.ID, "after-commit") +} + +func TestEnvironmentFileWriteMatchesReceiptAndRetainsDeletedOwner(t *testing.T) { + f := newFileWriteFixture(t) + ctx := t.Context() + if _, err := f.s.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); err == nil { + t.Fatal("pooled Store admitted a file write") + } + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); err != nil { + t.Fatal(err) + } + for _, change := range []func(*FileWriteIdentity){ + func(k *FileWriteIdentity) { k.DeviceID = uuid.NewString() }, + func(k *FileWriteIdentity) { k.RequestSHA256 = strings.Repeat("b", 64) }, + } { + wrong := f.key + change(&wrong) + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, wrong); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed retry accepted", err) + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, wrong, "rejected"); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("mismatched receipt settled", err) + } + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, uuid.NewString(), f.env.ID, f.key, "committed"); !errors.Is(err, ErrNotFound) { + t.Fatal("cross-tenant settlement", err) + } + if _, err := f.s.GetEnvironmentFileWrite(ctx, uuid.NewString(), f.env.ID, f.key.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("cross-tenant read", err) + } + for _, state := range []string{"pending", "unknown", "cancelled", "retired"} { + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, state); !errors.Is(err, ErrInvalidInput) { + t.Fatal("non-receipt settled write", state, err) + } + } + if err := f.s.DeleteSession(ctx, f.tenant, f.session.ID); err != nil { + t.Fatal(err) + } + if got, err := f.s.GetEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key.ID); err != nil || got.State != "pending" { + t.Fatal("deletion discarded unresolved write", got, err) + } + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session reopened write", err) + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "rejected"); err != nil { + t.Fatal("cannot settle retained deleted owner", err) + } + if got, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "rejected"); err != nil || !got.Replayed { + t.Fatal("receipt retry lost identity", got, err) + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "committed"); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("terminal outcome changed", err) + } +} + +func TestEnvironmentFileWriteSerializesWithInputAndRetry(t *testing.T) { + f := newFileWriteFixture(t) + original := f.key + ctx := t.Context() + var group sync.WaitGroup + results := make(chan EnvironmentFileWrite, 8) + for range 8 { + group.Go(func() { + got, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key) + if err != nil { + t.Error(err) + return + } + results <- got + }) + } + group.Wait() + close(results) + fresh, total := 0, 0 + for got := range results { + total++ + if !got.Replayed { + fresh++ + } + } + if total != 8 || fresh != 1 { + t.Fatal("retry authorized multiple sends", total, fresh) + } + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "committed"); err != nil { + t.Fatal(err) + } + for range 6 { + f.key.ID = uuid.NewString() + start := make(chan struct{}) + writes, inputs := make(chan error, 1), make(chan error, 1) + var pending EnvironmentInputReservation + group.Go(func() { + <-start + _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key) + writes <- err + }) + inputKey := uuid.NewString() + group.Go(func() { + <-start + var err error + pending, err = f.s.ReserveEnvironmentInput(ctx, f.tenant, f.session.ID, inputKey, []Input{messageInput("race")}) + inputs <- err + }) + close(start) + group.Wait() + writeErr, inputErr := <-writes, <-inputs + if writeErr == nil && errors.Is(inputErr, ErrTurnConflict) { + if _, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key, "rejected"); err != nil { + t.Fatal(err) + } + } else if inputErr == nil && errors.Is(writeErr, ErrTurnConflict) { + if _, err := f.s.CancelEnvironmentInput(ctx, f.tenant, f.session.ID, pending.ID); err != nil { + t.Fatal(err) + } + } else { + t.Fatal("write/input did not serialize", writeErr, inputErr) + } + } + f.key.ID = uuid.NewString() + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); err != nil { + t.Fatal(err) + } + if got, err := f.writer.SettleEnvironmentFileWrite(ctx, f.tenant, f.env.ID, original, "committed"); err != nil || !got.Replayed { + t.Fatal("old receipt retry changed", got, err) + } + if got, err := f.s.GetEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key.ID); err != nil || got.State != "pending" { + t.Fatal("old receipt cleared successor", got, err) + } +} diff --git a/services/agents-api/internal/store/environment_files_native_test.go b/services/agents-api/internal/store/environment_files_native_test.go new file mode 100644 index 000000000..6a891ab1c --- /dev/null +++ b/services/agents-api/internal/store/environment_files_native_test.go @@ -0,0 +1,192 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativePublicEnvironmentFiles(t *testing.T) { + binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + keyFile, python := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE"), os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" || python == "" || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" { + t.Skip("qualified Codex executor, directory artifacts, pinned SDK and real provider required") + } + version, err := exec.Command(binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native Codex 0.153.4 required") + } + keyBytes, err := os.ReadFile(keyFile) + if err != nil || strings.TrimSpace(string(keyBytes)) == "" { + t.Fatal("real model credential unavailable") + } + key := strings.TrimSpace(string(keyBytes)) + t.Setenv("PARSAR_CODEX_BIN", binary) + artifact := prepareWorkerDirectoryArtifact(t, binary) + h, ctx, root := nativeDispatchHarnessWithTimeout(t, 10*time.Minute) + second := &dispatchHarness{t: t, s: h.s, tenant: uuid.NewString(), credential: uuid.NewString(), registry: h.registry, url: h.url} + second.device, err = h.s.CreateDevice(ctx, second.tenant, "second isolated executor", device.HashCredential(second.credential)) + if err != nil { + t.Fatal(err) + } + secondRoot, err := os.MkdirTemp(os.Getenv("PARSAR_NATIVE_PROOF_DIR"), "execution-native-") + if err != nil { + t.Fatal(err) + } + startNativeDispatchDaemon(t, second, secondRoot, os.Getenv("PARSAR_NATIVE_DAEMON_BIN")) + peers, roots := []*dispatchHarness{h, second}, []string{root, secondRoot} + tokens := []string{uuid.NewString(), uuid.NewString()} + secrets := []string{key, h.credential, second.credential, tokens[0], tokens[1]} + var secretMu sync.Mutex + var registry *codex.Registry + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, nil + } + h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { + token, release, err := registry.IssueHarnessCredential(owner, session.TenantID, environment.ID) + secretMu.Lock() + secrets = append(secrets, token) + secretMu.Unlock() + return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, err + } + h.d.CloseEnvironmentConnections = func() { + if registry != nil { + registry.Close() + } + } + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + var started sync.Once + start := func() { started.Do(func() { go func() { done <- worker.Run(workerCtx) }() }) } + server := httptest.NewUnstartedServer(nil) + defer func() { + cancel() + start() + select { + case err := <-done: + if err != nil && err != context.Canceled { + t.Error("worker stopped unexpectedly", err) + } + case <-time.After(15 * time.Second): + t.Error("worker retained Files acceptance ownership") + } + if registry != nil { + registry.Close() + if registry.LifecycleError() != nil { + t.Error("registry lifecycle failed") + } + } + server.Close() + }() + registry, err = codex.New(codex.Config{Store: h.s, CheckOwnership: worker.CheckOwnership, ReplaceConnection: worker.ReplaceEnvironmentConnection, ObserveConnection: worker.ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + keys := make([]api.APIKey, 0, len(peers)) + for index, peer := range peers { + keys = append(keys, api.APIKey{OrganizationID: "test-org", ProjectID: peer.tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(tokens[index]), TenantID: peer.tenant}) + } + auth, err := api.NewAuthenticator(keys) + if err != nil { + t.Fatal(err) + } + public, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentRemoteURL(registry.PublicURL())) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.Handle("/cloud/environment/", registry.Handler()) + mux.Handle("/", public) + server.Config.Handler = mux + server.Start() + start() + tenants := make([]map[string]string, 0, len(peers)) + for index, peer := range peers { + workspace := "/parsar-public-files-" + uuid.NewString() + peer.session, err = createPublicFilesSession(ctx, peer, workspace) + if err != nil { + t.Fatal(err) + } + environment, err := h.s.GetSessionEnvironment(ctx, peer.tenant, peer.session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := h.s.IssueExecutorCredential(ctx, store.FixtureExecutorPrincipal(t, h.s, peer.tenant), uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + secretMu.Lock() + secrets = append(secrets, credential.Token) + secretMu.Unlock() + local := prepareDaemonRemoteWorkspace(t, roots[index], "FILES_"+uuid.NewString()) + artifact.container = startDaemonRemoteExecutor(t, ctx, roots[index], local, workspace, binary, image, registry.PublicURL(), environment.ID, credential) + artifact.installDirectoryHelper(t, ctx) + awaitEnvironmentConnectionState(t, ctx, h.s, peer.tenant, environment.ID, "connected") + tokenFile := filepath.Join(roots[index], "public-token") + if err := os.WriteFile(tokenFile, []byte(tokens[index]), 0600); err != nil { + t.Fatal(err) + } + tenants = append(tenants, map[string]string{"session_id": peer.session.ID, "token_file": tokenFile}) + } + settings, err := json.Marshal(map[string]any{"engine": "codex", "base": server.URL, "tenants": tenants}) + if err != nil { + t.Fatal(err) + } + command := exec.CommandContext(ctx, python, "../../tests/official_environment_files_native.py") + command.Stdin = bytes.NewReader(settings) + output, err := command.CombinedOutput() + secretMu.Lock() + for _, secret := range secrets { + if secret != "" && bytes.Contains(output, []byte(secret)) { + t.Error("credential appeared in public Files evidence") + output = bytes.ReplaceAll(output, []byte(secret), []byte("[REDACTED]")) + } + } + secretMu.Unlock() + if writeErr := os.WriteFile(filepath.Join(root, "public-files-evidence.json"), output, 0600); writeErr != nil { + t.Fatal(writeErr) + } + if err != nil || !json.Valid(output) { + t.Fatal("real public Files SDK/raw verification failed; inspect private evidence", root) + } + for _, home := range roots { + remaining, err := filepath.Glob(filepath.Join(home, "parsar-daemon", "workspace-read", "read-*")) + if err != nil || len(remaining) != 0 { + t.Fatal("public Files returned before temporary read cleanup") + } + } + t.Log("real two-tenant Files.list evidence", root) +} + +func createPublicFilesSession(ctx context.Context, h *dispatchHarness, workspace string) (store.Session, error) { + instructions := "Use the native shell for requested file operations. Do not delegate." + agent := v1.Agent{ID: "agent_" + uuid.NewString(), Model: "MiniMax-M3", Instructions: &instructions, + MultiAgent: v1.MultiAgentConfig{Enabled: false}, Reasoning: v1.Reasoning{}, ServiceTier: "auto", + Text: v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: "medium"}, Tools: []json.RawMessage{}} + configuration, err := json.Marshal(map[string]any{"agent": agent, "environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + if err != nil { + return store.Session{}, err + } + return h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: configuration}) +} diff --git a/services/agents-api/internal/store/environment_initial_input_test.go b/services/agents-api/internal/store/environment_initial_input_test.go new file mode 100644 index 000000000..73fdcd130 --- /dev/null +++ b/services/agents-api/internal/store/environment_initial_input_test.go @@ -0,0 +1,248 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func initialEnvironmentReservation(t *testing.T, s *Store, pool *pgxpool.Pool, tenant, session string) EnvironmentInputReservation { + t.Helper() + var id string + if err := pool.QueryRow(t.Context(), "SELECT id FROM environment_input_reservations WHERE session_id=$1 AND is_initial", session).Scan(&id); err != nil { + t.Fatal(err) + } + reservation, err := s.GetEnvironmentInputReservation(t.Context(), tenant, session, id) + if err != nil || !reservation.IsInitial { + t.Fatal("missing initial origin", reservation, err) + } + return reservation +} + +func TestEnvironmentInitialExpiryRollsBackWithFailureEventAndSerializesPromotion(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("initial-failure-rollback", "self_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("initial")} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID) + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", reservation.ID); err != nil { + t.Fatal(err) + } + constraint := pgx.Identifier{"initial_failure_" + uuid.NewString()[:8]}.Sanitize() + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR payload->'event'->>'type' <> 'agent.session.failed') NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) + }) + writer := executionLease(t, s).Store() + if _, err := writer.ExpireEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID); err == nil { + t.Fatal("expiry committed without its failure event") + } + retained := initialEnvironmentReservation(t, s, pool, tenant, session.ID) + if retained.State != EnvironmentInputPending || retained.SettledAt != nil { + t.Fatal("failure event rollback lost reservation", retained) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", session.Environment.ID) + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + type result struct { + reservation EnvironmentInputReservation + err error + } + results := make(chan result, 2) + for _, settle := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){writer.PromoteEnvironmentInput, s.ExpireEnvironmentInput} { + go func() { r, err := settle(t.Context(), tenant, session.ID, reservation.ID); results <- result{r, err} }() + } + for i := 0; i < 2; i++ { + got := <-results + if got.err != nil || got.reservation.State != EnvironmentInputExpired || len(got.reservation.Receipts) != 0 { + t.Fatal("expiry/promotion race started work", got) + } + } + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil || len(events) != 2 || events[1].Event.Type != "agent.session.failed" { + t.Fatal("racing settlement duplicated or lost failure", events, err) + } + environmentInputHistory(t, pool, session.ID, 0, 0) +} + +func TestEnvironmentInitialInputCreationRetainsCursorIdentityAndPromotion(t *testing.T) { + for _, kind := range []string{"self_hosted", "openai_hosted"} { + t.Run(kind, func(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("initial", kind, "/workspace") + input.InitialInputs = []Input{messageInput("first"), messageInput("second")} + creation, err := s.CreateSessionStream(t.Context(), tenant, input) + if err != nil || !creation.Created || creation.Cursor != 0 || creation.Session.LastTurn != nil || creation.Session.EnvironmentInputActivity != nil { + t.Fatal("creation snapshot borrowed initial work", creation, err) + } + session := creation.Session + reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID) + storedBatch, marshalErr := json.Marshal(reservation.Inputs) + originalBatch, _ := json.Marshal(input.InitialInputs) + if marshalErr != nil || reservation.State != EnvironmentInputPending || reservation.Deadline.Sub(reservation.CreatedAt) != 5*time.Minute || string(storedBatch) != string(originalBatch) { + t.Fatal("initial batch/deadline changed", reservation) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + status, actionEnvironment := "requires_action", session.Environment.ID + if kind == "openai_hosted" { + status, actionEnvironment = "", "" + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, status, actionEnvironment) + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, creation.Cursor) + expectedEvents := 1 + if kind == "openai_hosted" { + expectedEvents = 0 + } + if err != nil || len(events) != expectedEvents || (expectedEvents > 0 && (events[0].Event.Type != "agent.session."+status || events[0].Turn != nil)) { + t.Fatal("creation cursor lost initial activity", events, err) + } + other, _ := testStore(t) + retry, err := other.CreateSessionStream(t.Context(), tenant, input) + if err != nil || retry.Created || retry.Cursor != int64(expectedEvents) || retry.Session.ID != session.ID || retry.Session.EnvironmentInputActivity != nil { + t.Fatal("retry changed creation cursor/snapshot", retry, err) + } + if got := initialEnvironmentReservation(t, other, pool, tenant, session.ID); !reflect.DeepEqual(got, reservation) { + t.Fatal("retry changed initial reservation") + } + changed := input + changed.InitialInputs = []Input{messageInput("different")} + if _, err := other.CreateSession(t.Context(), tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed initial batch accepted", err) + } + changed = input + changed.Creator.ID = "different-creator" + if _, err := other.CreateSession(t.Context(), tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed creator accepted", err) + } + writer := executionLease(t, s).Store() + generation := uuid.NewString() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, session.Environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, session.Environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + connectedStatus := "idle" + if kind == "openai_hosted" { + connectedStatus = "" + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, connectedStatus, "") + environmentInputHistory(t, pool, session.ID, 0, 0) + promoted, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) + if err != nil || promoted.State != EnvironmentInputAdmitted || !promoted.IsInitial || len(promoted.Receipts) != 2 { + t.Fatal("initial batch did not promote", promoted, err) + } + active := requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") + if active.LastTurn == nil || active.LastTurn.Status != TurnInProgress { + t.Fatal("promotion did not claim its Turn", active.LastTurn) + } + replay, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) + if err != nil || len(replay.Receipts) != 2 || !replay.Receipts[0].Replayed || !replay.Receipts[1].Replayed { + t.Fatal("promotion retry granted fresh receipts", replay, err) + } + if _, err := other.CreateSession(t.Context(), tenant, input); err != nil { + t.Fatal(err) + } + environmentInputHistory(t, pool, session.ID, 1, 2) + after, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil || !reflect.DeepEqual(events, after[:len(events)]) { + t.Fatal("initial snapshot changed after promotion", err) + } + }) + } +} + +func TestEnvironmentInitialInputExpiryHasNoTurnAndCannotReplay(t *testing.T) { + for _, kind := range []string{"self_hosted", "openai_hosted"} { + t.Run(kind, func(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("initial-expiry", kind, "/workspace") + input.InitialInputs = []Input{messageInput("private initial text")} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID) + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", reservation.ID); err != nil { + t.Fatal(err) + } + lease := executionLease(t, s) + writer := lease.Store() + for reservation.State == EnvironmentInputPending { + count, err := writer.ExpireEnvironmentInputs(t.Context()) + if err != nil || count < 1 || count > 32 { + t.Fatal("expiry made no bounded progress", count, err) + } + reservation = initialEnvironmentReservation(t, s, pool, tenant, session.ID) + } + failed := requireEnvironmentInputActivity(t, s, tenant, session.ID, "failed", "") + if failed.Environment.Status != "pending" || failed.LastTurn != nil || !failed.EnvironmentInputActivity.LastActiveAt.Equal(*reservation.SettledAt) { + t.Fatal("input expiry changed Environment/Turn", failed) + } + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + expectedEvents := 2 + if kind == "openai_hosted" { + expectedEvents = 1 + } + if err != nil || len(events) != expectedEvents || events[len(events)-1].Event.Type != "agent.session.failed" || events[len(events)-1].Turn != nil || events[len(events)-1].EnvironmentInputActivity.Status != "failed" { + t.Fatal("missing pre-Turn failure snapshot", events, err) + } + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + pool.Close() + reopened, reopenedPool := testStore(t) + writer = executionLease(t, reopened).Store() + if _, err := reopened.CreateSession(t.Context(), tenant, input); err != nil { + t.Fatal(err) + } + if got := initialEnvironmentReservation(t, reopened, reopenedPool, tenant, session.ID); !reflect.DeepEqual(got, reservation) { + t.Fatal("reopened creation retry reset expiry", got) + } + generation := uuid.NewString() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, session.Environment.ID, generation); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, session.Environment.ID, generation, 1, true); err != nil { + t.Fatal(err) + } + late, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) + if err != nil || late.State != EnvironmentInputExpired || len(late.Receipts) != 0 { + t.Fatal("late connection resurrected initial input", late, err) + } + requireEnvironmentInputActivity(t, reopened, tenant, session.ID, "failed", "") + environmentInputHistory(t, reopenedPool, session.ID, 0, 0) + later := reserveEnvironmentInput(t, reopened, tenant, session.ID, "later") + if later.IsInitial { + t.Fatal("later submission inferred initial origin") + } + requireEnvironmentInputActivity(t, reopened, tenant, session.ID, "idle", "") + after, err := reopened.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil || len(after) < len(events) || !reflect.DeepEqual(events, after[:len(events)]) { + t.Fatal("later work changed historical failure", err) + } + if err := reopened.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err := reopened.GetSession(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted initial Session remained visible", err) + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_initial_migration_test.go b/services/agents-api/internal/store/environment_initial_migration_test.go new file mode 100644 index 000000000..867b6ade5 --- /dev/null +++ b/services/agents-api/internal/store/environment_initial_migration_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestEnvironmentInitialMigrationPreservesLaterInputOrigin(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "initial_origin_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 28); err != nil { + t.Fatal(err) + } + session, later := uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash) VALUES ($1,$2,'codex','historical','historical')`, session, uuid.NewString()); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO environments(id,session_id) VALUES ($1,$2)", uuid.NewString(), session); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) + VALUES ($1,$2,'later','[{"kind":"message","payload":{"text":"historical"}}]',clock_timestamp(),clock_timestamp()+interval '5 minutes')`, later, session); err != nil { + t.Fatal(err) + } + snapshot := func() string { + t.Helper() + var result string + if err := db.QueryRowContext(ctx, "SELECT (to_jsonb(r)-'is_initial')::text FROM environment_input_reservations r WHERE id=$1", later).Scan(&result); err != nil { + t.Fatal(err) + } + return result + } + before := snapshot() + for i := 0; i < 2; i++ { + if _, err := provider.UpTo(ctx, 29); err != nil { + t.Fatal(err) + } + var initial bool + if err := db.QueryRowContext(ctx, "SELECT is_initial FROM environment_input_reservations WHERE id=$1", later).Scan(&initial); err != nil || initial || snapshot() != before { + t.Fatal("migration inferred origin or changed historical data", initial, err) + } + if i == 0 { + if _, err := provider.DownTo(ctx, 28); err != nil { + t.Fatal("later-only downgrade failed", err) + } + } + } + initial := uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,is_initial,state,created_at,deadline,settled_at) + VALUES ($1,$2,'initial','[{"kind":"message","payload":{"text":"initial"}}]',true,'expired',clock_timestamp()-interval '6 minutes',clock_timestamp()-interval '1 minute',clock_timestamp())`, initial, session); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 28); err == nil || !strings.Contains(err.Error(), "Cannot remove initial Environment input origin") { + t.Fatal("downgrade discarded initial failure meaning", err) + } + var retained bool + if err := db.QueryRowContext(ctx, "SELECT is_initial FROM environment_input_reservations WHERE id=$1", initial).Scan(&retained); err != nil || !retained || snapshot() != before { + t.Fatal("rejected downgrade changed origin/history", retained, err) + } +} diff --git a/services/agents-api/internal/store/environment_initial_public_test.go b/services/agents-api/internal/store/environment_initial_public_test.go new file mode 100644 index 000000000..d81512c62 --- /dev/null +++ b/services/agents-api/internal/store/environment_initial_public_test.go @@ -0,0 +1,111 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestEnvironmentInitialFailureOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: "other-project", SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + // Private setup isolates the persistence prerequisite from public creation admission. + configuration := json.RawMessage(`{"agent":{"id":"agent_initial_failure","model":"fixture","tools":[],"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"}},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`) + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{ + Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "initial", Configuration: configuration, + InitialInputs: []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"private-input-marker"}`)}}, + }) + if err != nil { + t.Fatal(err) + } + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := lease.Close(ctx); err != nil { + t.Error(err) + } + }) + handler, err := api.NewHandler(s, auth, "codex", api.WithEnvironmentRemoteURL("https://executor.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + directory := t.TempDir() + settings, err := json.Marshal(map[string]string{"base": server.URL, "token": token, "foreign_token": foreign, "session_id": session.ID, "environment_id": session.Environment.ID, "directory": directory}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + command := exec.CommandContext(ctx, python, "../../tests/official_environment_initial_failure.py") + command.Stdin = bytes.NewReader(settings) + type result struct { + output []byte + err error + } + done := make(chan result, 1) + go func() { output, err := command.CombinedOutput(); done <- result{output, err} }() + waited := false + defer func() { + cancel() + if !waited { + <-done + } + }() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + _, sdkErr := os.Stat(filepath.Join(directory, "sdk-ready")) + _, rawErr := os.Stat(filepath.Join(directory, "raw-ready")) + if sdkErr == nil && rawErr == nil { + break + } + select { + case result := <-done: + waited = true + t.Fatalf("official initial failure observer stopped before readiness: %v %s", result.err, result.output) + case <-ticker.C: + case <-ctx.Done(): + t.Fatal("official initial failure observers did not become ready") + } + } + var reservation string + if err := pool.QueryRow(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE session_id=$1 AND is_initial RETURNING id", session.ID).Scan(&reservation); err != nil { + t.Fatal(err) + } + if result, err := lease.Store().ExpireEnvironmentInput(t.Context(), tenant, session.ID, reservation); err != nil || result.State != store.EnvironmentInputExpired { + t.Fatal("initial reservation did not expire", result, err) + } + observed := <-done + waited = true + if observed.err != nil { + t.Fatalf("official initial failure: %v %s", observed.err, observed.output) + } + t.Log(string(observed.output)) +} diff --git a/services/agents-api/internal/store/environment_input_activity.go b/services/agents-api/internal/store/environment_input_activity.go new file mode 100644 index 000000000..9a3974ebe --- /dev/null +++ b/services/agents-api/internal/store/environment_input_activity.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "errors" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentInputActivity is the reservation-owned override before a newer Turn exists. +type EnvironmentInputActivity struct { + Status string `json:"status"` + EnvironmentID string `json:"environment_id,omitempty"` + Failure string `json:"failure,omitempty"` + LastActiveAt time.Time `json:"last_active_at"` +} + +func environmentInputActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (*EnvironmentInputActivity, error) { + row, err := q.GetEnvironmentInputActivity(ctx, session) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + activity := &EnvironmentInputActivity{Status: "idle", LastActiveAt: row.CreatedAt.Time} + if row.SettledAt.Valid { + activity.LastActiveAt = row.SettledAt.Time + } + if row.State == EnvironmentInputFailed { + activity.Status, activity.Failure = "failed", "environment_unavailable" + } + if row.IsInitial && row.State == EnvironmentInputExpired { + activity.Status = "failed" + } + if row.EnvironmentType == "openai_hosted" && row.IsInitial && + (row.State == EnvironmentInputPending || row.State == EnvironmentInputCancelled) { + // No Turn has started. The pinned Session contract permits idle while a + // hosted Environment provisions; neither a caller action nor an invented + // in-progress/idle transition is appropriate here. + return nil, nil + } + if row.State == EnvironmentInputPending && row.EnvironmentType != "openai_hosted" && row.ConnectionStatus != "connected" { + activity.Status = "requires_action" + activity.EnvironmentID = uuid.UUID(row.EnvironmentID.Bytes).String() + } + return activity, nil +} + +func withEnvironmentInputActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, apply func() error) error { + before, err := environmentInputActivity(ctx, q, session) + if err != nil { + return err + } + if err := apply(); err != nil { + return err + } + after, err := environmentInputActivity(ctx, q, session) + if err != nil || after == nil { + // Admitted input is represented by the normal Turn and Session events. + return err + } + if before != nil && before.Status == after.Status && before.EnvironmentID == after.EnvironmentID && before.Failure == after.Failure { + return nil + } + usage, err := q.SessionTokenUsage(ctx, session) + if err != nil { + return err + } + return recordSessionChange(ctx, q, session, SessionChange{ + Event: v1.SessionEvent{Type: "agent.session." + after.Status}, + EnvironmentInputActivity: after, SessionUsage: usage, + }) +} + +func (s *Store) withEnvironmentInputSession(ctx context.Context, tenant, session string, apply func(context.Context, *sqlc.Queries, pgtype.UUID) error) error { + return s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, id pgtype.UUID) error { + return withEnvironmentInputActivity(ctx, q, id, func() error { return apply(ctx, q, id) }) + }) +} diff --git a/services/agents-api/internal/store/environment_input_activity_test.go b/services/agents-api/internal/store/environment_input_activity_test.go new file mode 100644 index 000000000..de9691ad4 --- /dev/null +++ b/services/agents-api/internal/store/environment_input_activity_test.go @@ -0,0 +1,236 @@ +package store + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func requireEnvironmentInputActivity(t *testing.T, s *Store, tenant, session, status, environment string) Session { + t.Helper() + value, err := s.GetSession(t.Context(), tenant, session) + if err != nil { + t.Fatal(err) + } + activity := value.EnvironmentInputActivity + if status == "" { + if activity != nil { + t.Fatal("unexpected input activity", activity) + } + } else if activity == nil || activity.Status != status || activity.EnvironmentID != environment || activity.LastActiveAt.IsZero() { + t.Fatal("input activity", activity, status, environment) + } + page, err := s.ListSessions(t.Context(), tenant, "", 100, false, nil) + if err != nil || len(page.Sessions) != 1 || !reflect.DeepEqual(page.Sessions[0].EnvironmentInputActivity, activity) { + t.Fatal("list and retrieve activity differ", err) + } + return value +} + +func TestEnvironmentInputActivityWaitsBeforeTurnAndClearsOnConnection(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + value := requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") + if value.Environment == nil || value.Environment.Status != "pending" || value.LastTurn != nil { + t.Fatal("idle Environment projection", value) + } + environment := value.Environment.ID + reservation := reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + waiting := requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", environment) + if waiting.LastTurn != nil || !waiting.EnvironmentInputActivity.LastActiveAt.Equal(reservation.CreatedAt) { + t.Fatal("waiting input fabricated a Turn") + } + environmentInputHistory(t, pool, session.ID, 0, 0) + first, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil || len(first) != 1 || first[0].Event.Type != "agent.session.requires_action" || first[0].Turn != nil || first[0].EnvironmentInputActivity == nil { + t.Fatal("missing pre-Turn snapshot", first, err) + } + reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + writer := executionLease(t, s).Store() + generation := uuid.NewString() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, environment, generation); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 1, true); err != nil { + t.Fatal(err) + } + idle := requireEnvironmentInputActivity(t, s, tenant, session.ID, "idle", "") + if idle.LastTurn != nil { + t.Fatal("connection fabricated readiness or Turn") + } + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil || len(changes) != 3 || changes[1].Event.Type != "agent.session.environment.connected" || changes[2].Event.Type != "agent.session.idle" { + t.Fatal("connection/action order", changes, err) + } + if !reflect.DeepEqual(first[0], changes[0]) || changes[2].Turn != nil || changes[2].EnvironmentInputActivity.Status != "idle" { + t.Fatal("activity snapshot changed or borrowed a Turn") + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 1, false); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 2, false); err != nil { + t.Fatal(err) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", environment) + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 3, true); err != nil { + t.Fatal(err) + } + if _, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID); err != nil { + t.Fatal(err) + } + active := requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") + if active.LastTurn == nil || active.LastTurn.Status != TurnInProgress { + t.Fatal("normal Turn did not take ownership") + } + if _, err := s.GetSession(t.Context(), uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Session activity visible", err) + } +} + +func TestEnvironmentInputActivitySettlementAndNewerWork(t *testing.T) { + for _, state := range []string{EnvironmentInputCancelled, EnvironmentInputExpired} { + t.Run(state, func(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + writer := executionLease(t, s).Store() + prior, err := s.SubmitInputs(t.Context(), tenant, session.ID, "prior", []Input{messageInput("prior")}) + if err != nil { + t.Fatal(err) + } + if _, err := writer.TransitionTurn(t.Context(), tenant, session.ID, prior[0].TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed, Outcome: []byte(`{}`)}); err != nil { + t.Fatal(err) + } + reservation := reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + value, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || value.LastTurn.Status != TurnFailed || value.EnvironmentInputActivity.Status != "requires_action" { + t.Fatal("prior failure hid waiting input", err) + } + if state == EnvironmentInputCancelled { + _, err = s.CancelEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) + } else { + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", reservation.ID); err != nil { + t.Fatal(err) + } + // Other retained test rows may precede this reservation in bounded batches. + for reservation.State == EnvironmentInputPending { + count, sweepErr := writer.ExpireEnvironmentInputs(t.Context()) + if sweepErr != nil || count < 1 || count > 32 { + t.Fatal("expiry made no bounded progress", count, sweepErr) + } + reservation, err = s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, reservation.ID) + if err != nil { + t.Fatal(err) + } + } + } + if err != nil { + t.Fatal(err) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "idle", "") + cursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + if after, err := s.SessionEventCursor(t.Context(), tenant, session.ID); err != nil || after != cursor { + t.Fatal("settled retry repeated activity", after, cursor, err) + } + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "newer", []Input{messageInput("newer")}); err != nil { + t.Fatal(err) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "", "") + }) + } +} + +func TestEnvironmentInputActivityRollsBackReservationAndConnection(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + constraint := pgx.Identifier{"input_activity_" + uuid.NewString()[:8]}.Sanitize() + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR NOT (payload ? 'environment_input_activity')) NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) + }) + if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, session.ID, "rollback", []Input{messageInput("pending")}); err == nil { + t.Fatal("activity failure retained reservation") + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", session.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("partial reservation", count, err) + } + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + value := requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", session.Environment.ID) + writer := executionLease(t, s).Store() + generation := uuid.NewString() + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, value.Environment.ID, generation); err != nil { + t.Fatal(err) + } + before := connectionSnapshot(t, pool, value.Environment.ID) + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR payload->'event'->>'type' <> 'agent.session.idle') NOT VALID"); err != nil { + t.Fatal(err) + } + if err := writer.ObserveEnvironmentConnection(t.Context(), tenant, value.Environment.ID, generation, 1, true); err == nil { + t.Fatal("connection committed without activity") + } + if connection := connectionSnapshot(t, pool, value.Environment.ID); connection != before { + t.Fatal("partial connection state", connection) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", value.Environment.ID) +} + +func TestEnvironmentInputActivityRecoversWaitingActionAndHidesDeletion(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + reservation := reserveEnvironmentInput(t, s, tenant, session.ID, "waiting") + old := executionLease(t, s) + generation := uuid.NewString() + environment := session.Environment.ID + if err := old.Store().ReplaceEnvironmentConnection(t.Context(), tenant, environment, generation); err != nil { + t.Fatal(err) + } + if err := old.Store().ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 1, true); err != nil { + t.Fatal(err) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "idle", "") + if err := old.Close(t.Context()); err != nil { + t.Fatal(err) + } + next := executionLease(t, s).Store() + if err := next.ReconcileEnvironmentConnections(t.Context()); err != nil { + t.Fatal(err) + } + requireEnvironmentInputActivity(t, s, tenant, session.ID, "requires_action", environment) + got, err := s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, reservation.ID) + if err != nil || got.State != EnvironmentInputPending || !got.Deadline.Equal(reservation.Deadline) { + t.Fatal("recovery changed waiting input or its deadline", got, err) + } + cursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if err := next.ObserveEnvironmentConnection(t.Context(), tenant, environment, generation, 2, true); err != nil { + t.Fatal(err) + } + if after, err := s.SessionEventCursor(t.Context(), tenant, session.ID); err != nil || after != cursor { + t.Fatal("retired generation changed activity", after, cursor, err) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetSession(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted activity remained visible", err) + } + if _, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted activity events remained visible", err) + } +} diff --git a/services/agents-api/internal/store/environment_input_claim_test.go b/services/agents-api/internal/store/environment_input_claim_test.go new file mode 100644 index 000000000..ec009f673 --- /dev/null +++ b/services/agents-api/internal/store/environment_input_claim_test.go @@ -0,0 +1,94 @@ +package store + +import ( + "sync" + "testing" +) + +func TestEnvironmentInputConcurrentPromotionClaimsOnce(t *testing.T) { + s, pool := testStore(t) + writer := executionLease(t, s).Store() + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + reservationCursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + const count = 8 + results := make(chan EnvironmentInputReservation, count) + var group sync.WaitGroup + for range count { + group.Go(func() { + got, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID) + if err != nil { + t.Error(err) + return + } + results <- got + }) + } + group.Wait() + close(results) + var turnID string + fresh, received := 0, 0 + for got := range results { + received++ + if got.State != EnvironmentInputAdmitted || len(got.Receipts) != 2 { + t.Fatal("promotion lost the original batch", got) + } + if turnID == "" { + turnID = got.Receipts[0].TurnID + } + if !got.Receipts[0].Replayed { + fresh++ + } + for _, receipt := range got.Receipts { + if receipt.TurnID != turnID || receipt.Replayed != got.Receipts[0].Replayed { + t.Fatal("promotion changed execution ownership", got.Receipts) + } + } + } + if received != count || fresh != 1 { + t.Fatal("promotion authorized multiple starts", received, fresh) + } + turn, err := s.GetTurn(t.Context(), tenant, session.ID, turnID) + if err != nil || turn.Status != TurnInProgress || turn.StartedAt.IsZero() { + t.Fatal("promotion did not persist its execution claim", turn, err) + } + environmentInputHistory(t, pool, session.ID, 1, 2) + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, reservationCursor) + if err != nil || len(changes) < 2 { + t.Fatal("missing promotion events", changes, err) + } + created, claimed := changes[0], changes[len(changes)-1] + if created.Event.Type != "agent.session.turn.created" || created.Turn == nil || created.Turn.Status != TurnQueued || claimed.Event.Type != "agent.session.turn.in_progress" || claimed.Turn == nil || claimed.Turn.ID != turnID || claimed.Turn.Status != TurnInProgress { + t.Fatal("claim reordered or replaced admission snapshots", created, claimed) + } + claimEvents := 0 + for _, change := range changes { + if change.Event.Type == "agent.session.turn.in_progress" { + claimEvents++ + } + } + if claimEvents != 1 { + t.Fatal("retry published another claim", claimEvents) + } + transition(t, writer, tenant, session.ID, turnID, TurnInProgress, TurnCompleted) + later := reserveEnvironmentInput(t, s, tenant, session.ID, "later") + cursor, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + retry, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID) + if err != nil || len(retry.Receipts) != 2 || !retry.Receipts[0].Replayed || retry.Receipts[0].TurnID != turnID { + t.Fatal("terminal retry reclaimed execution", retry, err) + } + after, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil || after != cursor { + t.Fatal("terminal retry published events", after, cursor, err) + } + retained, err := s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, later.ID) + if err != nil || retained.State != EnvironmentInputPending || !retained.Deadline.Equal(later.Deadline) { + t.Fatal("old promotion affected new preparation", retained, err) + } +} diff --git a/services/agents-api/internal/store/environment_input_expiry.go b/services/agents-api/internal/store/environment_input_expiry.go new file mode 100644 index 000000000..ae968de2d --- /dev/null +++ b/services/agents-api/internal/store/environment_input_expiry.go @@ -0,0 +1,44 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +// ExpireEnvironmentInputs settles one bounded batch without creating Turn history. +// Only the current execution writer may run this cross-Session maintenance. +func (s *Store) ExpireEnvironmentInputs(ctx context.Context) (int64, error) { + if s.executionLease == nil { + return 0, errors.New("Environment input expiry requires an execution lease") + } + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + var expired int64 + err := s.executionLease.transaction(ctx, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + rows, err := q.ListDueEnvironmentInputs(ctx) + if err != nil { + return err + } + for _, row := range rows { + err := withEnvironmentInputActivity(ctx, q, row.SessionID, func() error { + return q.ExpireEnvironmentInputReservation(ctx, sqlc.ExpireEnvironmentInputReservationParams{SessionID: row.SessionID, ID: row.ID}) + }) + if err != nil { + return err + } + if err := q.PruneSessionEvents(ctx, row.SessionID); err != nil { + return err + } + expired++ + } + return nil + }) + if err != nil { + return 0, err + } + return expired, nil +} diff --git a/services/agents-api/internal/store/environment_input_expiry_test.go b/services/agents-api/internal/store/environment_input_expiry_test.go new file mode 100644 index 000000000..c04195403 --- /dev/null +++ b/services/agents-api/internal/store/environment_input_expiry_test.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" +) + +func TestEnvironmentExpiryBoundsBatchAndRequiresExecutionWriter(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + var reservations []EnvironmentInputReservation + for range 33 { + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + reservations = append(reservations, pending) + if _, err := pool.Exec(ctx, "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + } + if n, err := s.ExpireEnvironmentInputs(ctx); err == nil || n != 0 { + t.Fatal("unleased maintenance", n, err) + } + var before, after, due int64 + if err := pool.QueryRow(ctx, "SELECT count(*) FILTER (WHERE state='expired'), count(*) FILTER (WHERE state='pending' AND deadline <= statement_timestamp()) FROM environment_input_reservations").Scan(&before, &due); err != nil { + t.Fatal(err) + } + lease := executionLease(t, s) + n, err := lease.Store().ExpireEnvironmentInputs(ctx) + if err != nil || n != 32 { + t.Fatal("unbounded or incomplete batch", n, err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environment_input_reservations WHERE state='expired'").Scan(&after); err != nil || after-before != n { + t.Fatal("reported expiry did not match persisted settlement", before, after, n, err) + } + // Existing test rows may precede this fixture; each pass must make bounded progress. + for remaining := due - n; remaining > 0; { + n, err = lease.Store().ExpireEnvironmentInputs(ctx) + if err != nil || n <= 0 || n > 32 { + t.Fatal("expiry backlog did not progress", n, err) + } + remaining -= n + } + for _, pending := range reservations { + var state string + if err := pool.QueryRow(ctx, "SELECT state FROM environment_input_reservations WHERE id=$1", pending.ID).Scan(&state); err != nil || state != EnvironmentInputExpired { + t.Fatal(state, err) + } + environmentInputHistory(t, pool, pending.SessionID, 0, 0) + } +} + +func TestEnvironmentExpiryFencesLostExecutionOwner(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + old := executionLease(t, s) + var killed bool + if err := pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1,1000)", old.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + successor := executionLease(t, s) + if n, err := old.Store().ExpireEnvironmentInputs(t.Context()); err == nil || n != 0 { + t.Fatal("lost owner expired input", n, err) + } + got, err := s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, pending.ID) + if err != nil || got.State != EnvironmentInputPending { + t.Fatal("lost owner wrote through the pool", got, err) + } + for got.State == EnvironmentInputPending { + n, err := successor.Store().ExpireEnvironmentInputs(t.Context()) + if err != nil || n == 0 { + t.Fatal("successor could not expire input", n, err) + } + got, err = s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, pending.ID) + if err != nil { + t.Fatal(err) + } + } + if got.State != EnvironmentInputExpired { + t.Fatal(got) + } + environmentInputHistory(t, pool, session.ID, 0, 0) +} + +func TestEnvironmentExpirySerializesWithTargetedSettlement(t *testing.T) { + for _, action := range []string{"promote", "cancel", "delete"} { + t.Run(action, func(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + lease := executionLease(t, s) + start := make(chan struct{}) + results := make(chan error, 2) + go func() { <-start; _, err := lease.Store().ExpireEnvironmentInputs(t.Context()); results <- err }() + go func() { + <-start + var err error + switch action { + case "promote": + _, err = lease.Store().PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID) + case "cancel": + _, err = s.CancelEnvironmentInput(t.Context(), tenant, session.ID, pending.ID) + case "delete": + err = s.DeleteSession(t.Context(), tenant, session.ID) + } + results <- err + }() + close(start) + for range 2 { + if err := <-results; err != nil { + t.Fatal(err) + } + } + var state string + if err := pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE id=$1", pending.ID).Scan(&state); err != nil { + t.Fatal(err) + } + if state != EnvironmentInputExpired && (action != "delete" || state != EnvironmentInputCancelled) { + t.Fatal("invalid competing settlement", state) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + if action == "delete" { + if _, err := lease.Store().PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted input resurrected", err) + } + return + } + later := reserveEnvironmentInput(t, s, tenant, session.ID, uuid.NewString()) + for _, settle := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){lease.Store().PromoteEnvironmentInput, s.CancelEnvironmentInput, s.ExpireEnvironmentInput} { + old, err := settle(t.Context(), tenant, session.ID, pending.ID) + if err != nil || old.State != state { + t.Fatal("old reservation changed", old, err) + } + } + if _, err := lease.Store().ExpireEnvironmentInputs(t.Context()); err != nil { + t.Fatal(err) + } + got, err := s.GetEnvironmentInputReservation(t.Context(), tenant, session.ID, later.ID) + if err != nil || got.State != EnvironmentInputPending || !got.Deadline.Equal(later.Deadline) { + t.Fatal("old settlement affected successor", got, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_input_migration_test.go b/services/agents-api/internal/store/environment_input_migration_test.go new file mode 100644 index 000000000..c3a82ff5c --- /dev/null +++ b/services/agents-api/internal/store/environment_input_migration_test.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestEnvironmentInputMigrationRetainsHistoryAndRetryIdentity(t *testing.T) { + _, pool := testStore(t) + ctx := context.Background() + schema := "environment_input_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 21); err != nil { + t.Fatal(err) + } + tenant, session := uuid.NewString(), uuid.NewString() + // Historical schemas must be seeded without the current Store's creation contract. + input := environmentInput("session", "self_hosted", "/workspace") + if _, err := db.ExecContext(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) + VALUES ($1,$2,'codex','session','historical',$3)`, session, tenant, input.Configuration); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO environments(id,session_id) VALUES ($1,$2)", uuid.NewString(), session); err != nil { + t.Fatal(err) + } + var before, after string + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", session).Scan(&before); err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 22); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM environment_input_reservations").Scan(&count); err != nil || count != 0 { + t.Fatal("migration manufactured reservations", count, err) + } + if _, err := provider.DownTo(ctx, 21); err != nil { + t.Fatal("empty downgrade", err) + } + if _, err := provider.UpTo(ctx, 22); err != nil { + t.Fatal(err) + } + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", session).Scan(&after); err != nil || before != after { + t.Fatal("migration changed Session", err) + } + // Seed the historical schema directly; current queries require later columns. + pending := uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) + VALUES ($1,$2,'pending','[{"kind":"message","payload":{"text":"pending"}}]',clock_timestamp(),clock_timestamp()+interval '5 minutes')`, pending, session); err != nil { + t.Fatal(err) + } + var original, retained string + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(r)::text FROM environment_input_reservations r WHERE id=$1", pending).Scan(&original); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 21); err == nil || !strings.Contains(err.Error(), "Cannot remove durable Environment input identities") { + t.Fatal("downgrade discarded retry identity", err) + } + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(r)::text FROM environment_input_reservations r WHERE id=$1", pending).Scan(&retained); err != nil || retained != original { + t.Fatal("downgrade lost reservation", retained, err) + } + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", session).Scan(&after); err != nil || before != after { + t.Fatal("migration changed Session", err) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM turns WHERE session_id=$1", session).Scan(&count); err != nil || count != 0 { + t.Fatal("migration manufactured a Turn", count, err) + } +} + +func TestEnvironmentInputPromotionUsesCurrentExecutionWriter(t *testing.T) { + s, pool := testStore(t) + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + if _, err := s.PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID); err == nil { + t.Fatal("pooled Store promoted input without execution ownership") + } + closed := executionLease(t, s) + if err := closed.Close(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := closed.Store().PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID); err == nil { + t.Fatal("closed execution writer promoted pending input") + } + environmentInputHistory(t, pool, session.ID, 0, 0) + old := executionLease(t, s) + writer := old.Store() + var killed bool + if err := pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", old.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + successor := executionLease(t, s) + if _, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID); err == nil { + t.Fatal("stale execution writer promoted pending input") + } + environmentInputHistory(t, pool, session.ID, 0, 0) + got, err := successor.Store().PromoteEnvironmentInput(t.Context(), tenant, session.ID, pending.ID) + if err != nil || got.State != EnvironmentInputAdmitted { + t.Fatal("successor could not promote", got, err) + } + environmentInputHistory(t, pool, session.ID, 1, 2) +} diff --git a/services/agents-api/internal/store/environment_input_settlement_test.go b/services/agents-api/internal/store/environment_input_settlement_test.go new file mode 100644 index 000000000..b6fca37a4 --- /dev/null +++ b/services/agents-api/internal/store/environment_input_settlement_test.go @@ -0,0 +1,259 @@ +package store + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestEnvironmentInputTerminalReservationsCannotRestart(t *testing.T) { + for _, terminal := range []string{EnvironmentInputCancelled, EnvironmentInputExpired} { + t.Run(terminal, func(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + early, err := s.ExpireEnvironmentInput(ctx, tenant, session.ID, pending.ID) + if err != nil || early.State != EnvironmentInputPending || early.SettledAt != nil || !early.Deadline.Equal(pending.Deadline) { + t.Fatal("early expiry", early, err) + } + var settled EnvironmentInputReservation + if terminal == EnvironmentInputExpired { + if _, err := pool.Exec(ctx, "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + settled, err = writer.PromoteEnvironmentInput(ctx, tenant, session.ID, pending.ID) + } else { + settled, err = s.CancelEnvironmentInput(ctx, tenant, session.ID, pending.ID) + } + if err != nil || settled.State != terminal || settled.SettledAt == nil || len(settled.Receipts) != 0 { + t.Fatal("terminal settlement", settled, err) + } + retry, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "pending", pending.Inputs) + if err != nil || retry.State != terminal || retry.ID != pending.ID || !retry.Deadline.Equal(settled.Deadline) || !retry.SettledAt.Equal(*settled.SettledAt) { + t.Fatal("terminal retry changed outcome", retry, err) + } + if _, err := s.SubmitInputs(ctx, tenant, session.ID, "pending", pending.Inputs); !errors.Is(err, ErrTurnConflict) { + t.Fatal("terminal request reopened through direct path", err) + } + if _, err := s.SubmitInputs(ctx, tenant, session.ID, "pending", []Input{messageInput("changed")}); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("terminal identity changed", err) + } + later := reserveEnvironmentInput(t, s, tenant, session.ID, "later") + for _, finish := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){ + writer.PromoteEnvironmentInput, s.CancelEnvironmentInput, s.ExpireEnvironmentInput, + } { + got, err := finish(ctx, tenant, session.ID, pending.ID) + if err != nil || got.State != terminal { + t.Fatal("old settlement changed", got, err) + } + } + got, err := s.GetEnvironmentInputReservation(ctx, tenant, session.ID, later.ID) + if err != nil || got.State != EnvironmentInputPending || !got.Deadline.Equal(later.Deadline) { + t.Fatal("old settlement touched successor", got, err) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + }) + } +} + +func TestEnvironmentInputPromotionRollsBackHistoryAndSettlement(t *testing.T) { + for _, phase := range []string{"input", "settlement", "claim", "claim-event"} { + t.Run(phase, func(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + name := "reservation_failure_" + strings.ReplaceAll(uuid.NewString(), "-", "") + table := "turn_inputs" + expression := "session_id <> '" + session.ID + "'::uuid OR payload->>'text' <> 'second'" + if phase == "settlement" { + table = "environment_input_reservations" + expression = "id <> '" + pending.ID + "'::uuid OR state <> 'admitted'" + } + if phase == "claim" { + table = "turns" + expression = "session_id <> '" + session.ID + "'::uuid OR status <> 'in_progress'" + } + if phase == "claim-event" { + table = "session_events" + expression = "session_id <> '" + session.ID + "'::uuid OR payload->'event'->>'type' <> 'agent.session.turn.in_progress'" + } + if _, err := pool.Exec(ctx, "ALTER TABLE "+table+" ADD CONSTRAINT "+name+" CHECK ("+expression+") NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "ALTER TABLE "+table+" DROP CONSTRAINT IF EXISTS "+name) }) + if _, err := writer.PromoteEnvironmentInput(ctx, tenant, session.ID, pending.ID); err == nil { + t.Fatal("injected failure succeeded") + } + environmentInputHistory(t, pool, session.ID, 0, 0) + got, err := s.GetEnvironmentInputReservation(ctx, tenant, session.ID, pending.ID) + if err != nil || got.State != EnvironmentInputPending || got.SettledAt != nil || !got.Deadline.Equal(pending.Deadline) { + t.Fatal("partial settlement survived", got, err) + } + if _, err := pool.Exec(ctx, "ALTER TABLE "+table+" DROP CONSTRAINT "+name); err != nil { + t.Fatal(err) + } + got, err = writer.PromoteEnvironmentInput(ctx, tenant, session.ID, pending.ID) + if err != nil || got.State != EnvironmentInputAdmitted { + t.Fatal(got, err) + } + environmentInputHistory(t, pool, session.ID, 1, 2) + }) + } +} + +func TestEnvironmentInputDeadlineIsCheckedAfterSessionLock(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(context.Background()) }() + var blocker int32 + if err := tx.QueryRow(ctx, "SELECT pg_backend_pid() FROM sessions WHERE id=$1 FOR UPDATE", session.ID).Scan(&blocker); err != nil { + t.Fatal(err) + } + type outcome struct { + value EnvironmentInputReservation + err error + } + done := make(chan outcome, 1) + go func() { + got, err := writer.PromoteEnvironmentInput(ctx, tenant, session.ID, pending.ID) + done <- outcome{got, err} + }() + for { + var blocked bool + if err := pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid)))", blocker).Scan(&blocked); err != nil { + t.Fatal(err) + } + if blocked { + break + } + select { + case result := <-done: + t.Fatal("promotion bypassed Session lock", result) + case <-ctx.Done(): + t.Fatal("promotion lock wait not observed") + case <-time.After(5 * time.Millisecond): + } + } + // Transaction-start time is now older than the controlled deadline. + if _, err := tx.Exec(ctx, "UPDATE environment_input_reservations SET deadline=clock_timestamp() WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + result := <-done + if result.err != nil || result.value.State != EnvironmentInputExpired || result.value.SettledAt == nil { + t.Fatal("lock wait extended input lifetime", result) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + stored, err := s.GetEnvironmentInputReservation(ctx, tenant, session.ID, pending.ID) + if err != nil || stored.State != EnvironmentInputExpired { + t.Fatal("expiry was rolled back", stored, err) + } +} + +func TestEnvironmentInputCancelAndPromotionShareOneOutcome(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + other, _ := testStore(t) + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + start := make(chan struct{}) + results := make(chan EnvironmentInputReservation, 2) + errs := make(chan error, 2) + for _, finish := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){ + writer.PromoteEnvironmentInput, other.CancelEnvironmentInput, + } { + go func() { + <-start + got, err := finish(ctx, tenant, session.ID, pending.ID) + results <- got + errs <- err + }() + } + close(start) + first, second := <-results, <-results + for range 2 { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + if first.State != second.State || (first.State != EnvironmentInputAdmitted && first.State != EnvironmentInputCancelled) { + t.Fatal("competing settlements diverged", first.State, second.State) + } + turns, inputs := 0, 0 + if first.State == EnvironmentInputAdmitted { + turns, inputs = 1, 2 + } + environmentInputHistory(t, pool, session.ID, turns, inputs) +} + +func TestEnvironmentInputDeletionSettlesPendingAndFencesPromotion(t *testing.T) { + for _, concurrent := range []bool{false, true} { + t.Run(map[bool]string{false: "pending", true: "racing-promotion"}[concurrent], func(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + done := make(chan error, 1) + if concurrent { + go func() { + _, err := writer.PromoteEnvironmentInput(ctx, tenant, session.ID, pending.ID) + done <- err + }() + } + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + if concurrent { + if err := <-done; err != nil && !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + } + for _, action := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){ + s.GetEnvironmentInputReservation, writer.PromoteEnvironmentInput, s.CancelEnvironmentInput, + } { + if _, err := action(ctx, tenant, session.ID, pending.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted reservation remained accessible", err) + } + } + if _, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "late", pending.Inputs); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session accepted reservation", err) + } + var state string + var active int + if err := pool.QueryRow(ctx, "SELECT state FROM environment_input_reservations WHERE id=$1", pending.ID).Scan(&state); err != nil { + t.Fatal(err) + } + if state != EnvironmentInputCancelled && (!concurrent || state != EnvironmentInputAdmitted) { + t.Fatal("deletion lost pending settlement", state) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM turns WHERE session_id=$1 AND (status='queued' OR (status IN ('in_progress','waiting') AND cancel_requested_at IS NULL))", session.ID).Scan(&active); err != nil || active != 0 { + t.Fatal("deleted reservation retained unclaimed or uncancelled work", active, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/agents-api/internal/store/environment_inputs.go new file mode 100644 index 000000000..e1edcedb7 --- /dev/null +++ b/services/agents-api/internal/store/environment_inputs.go @@ -0,0 +1,284 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +const ( + EnvironmentInputPending = "pending" + EnvironmentInputAdmitted = "admitted" + EnvironmentInputExpired = "expired" + EnvironmentInputCancelled = "cancelled" + EnvironmentInputFailed = "failed" +) + +// EnvironmentInputReservation is private admission state, not a public Session projection. +type EnvironmentInputReservation struct { + ID string + SessionID string + State string + IsInitial bool + Inputs []Input + CreatedAt time.Time + Deadline time.Time + SettledAt *time.Time + Receipts []InputReceipt +} + +// ReserveEnvironmentInput appends to active work or reserves an idle message batch. +// The Session lock decides both paths; only promotion can create a new Turn. +func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID, key string, inputs []Input) (EnvironmentInputReservation, error) { + if err := validateInputKey(key); err != nil { + return EnvironmentInputReservation{}, err + } + batch, encoded, err := validateInitialInputs(inputs) + if err != nil { + return EnvironmentInputReservation{}, err + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentInputReservation{}, err + } + var result EnvironmentInputReservation + err = s.withEnvironmentInputSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + previous, err := q.FindEnvironmentInputReservation(ctx, sqlc.FindEnvironmentInputReservationParams{ + SessionID: session, IdempotencyKey: key, Batch: encoded, + }) + if err == nil { + if !previous.Matches { + return ErrIdempotencyConflict + } + result, err = settleEnvironmentInput(ctx, q, tenantID, previous.EnvironmentInputReservation, EnvironmentInputExpired) + return err + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + receipts, err := inputBatchReceipts(ctx, q, session, key, encoded) + if err != nil { + return err + } + if len(receipts) > 0 { + // Earlier direct admission has receipts, but never had a reservation or deadline. + result = EnvironmentInputReservation{SessionID: sessionID, State: EnvironmentInputAdmitted, Receipts: receipts} + return nil + } + if err := checkEnvironmentFileWriteGate(ctx, q, session); err != nil { + return err + } + environment, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrInvalidInput + } else if err != nil { + return err + } + if environment.Environment.Status == "failed" || environment.Environment.Status == "expired" { + return ErrEnvironmentUnavailable + } + if err := checkEnvironmentInputGate(ctx, q, session, key, encoded); err != nil { + return err + } + if active, err := q.GetActiveTurn(ctx, session); err == nil && !active.ArtifactCaptureStarted { + result = EnvironmentInputReservation{SessionID: sessionID, State: EnvironmentInputAdmitted} + for position, input := range batch { + receipt, err := admitInput(ctx, q, tenantID, session, key, int32(position), input) + if err != nil { + return err + } + result.Receipts = append(result.Receipts, receipt) + } + return nil + } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + row, err := q.CreateEnvironmentInputReservation(ctx, sqlc.CreateEnvironmentInputReservationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session, IdempotencyKey: key, Batch: encoded, + }) + if err != nil { + return err + } + result, err = environmentInputFromRow(row) + return err + }) + if err != nil { + return EnvironmentInputReservation{}, err + } + return result, nil +} + +func (s *Store) GetEnvironmentInputReservation(ctx context.Context, tenantID, sessionID, reservationID string) (EnvironmentInputReservation, error) { + id, err := parseID(reservationID) + if err != nil { + return EnvironmentInputReservation{}, err + } + var result EnvironmentInputReservation + err = s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + row, err := q.GetEnvironmentInputReservation(ctx, sqlc.GetEnvironmentInputReservationParams{SessionID: session, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + result, err = environmentInputOutcome(ctx, q, row) + return err + }) + if err != nil { + return EnvironmentInputReservation{}, err + } + return result, nil +} + +// PromoteEnvironmentInput admits and claims work for the retained native preparation. +func (s *Store) PromoteEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (EnvironmentInputReservation, error) { + if s.executionLease == nil { + return EnvironmentInputReservation{}, errors.New("Environment input promotion requires an execution lease") + } + return s.settleEnvironmentInput(ctx, tenantID, sessionID, reservationID, EnvironmentInputAdmitted) +} + +func (s *Store) CancelEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (EnvironmentInputReservation, error) { + return s.settleEnvironmentInput(ctx, tenantID, sessionID, reservationID, EnvironmentInputCancelled) +} + +func (s *Store) ExpireEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (EnvironmentInputReservation, error) { + return s.settleEnvironmentInput(ctx, tenantID, sessionID, reservationID, EnvironmentInputExpired) +} + +func (s *Store) settleEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID, state string) (EnvironmentInputReservation, error) { + id, err := parseID(reservationID) + if err != nil { + return EnvironmentInputReservation{}, err + } + var result EnvironmentInputReservation + err = s.withEnvironmentInputSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + row, err := q.GetEnvironmentInputReservation(ctx, sqlc.GetEnvironmentInputReservationParams{SessionID: session, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + result, err = settleEnvironmentInput(ctx, q, tenantID, row, state) + return err + }) + if err != nil { + return EnvironmentInputReservation{}, err + } + return result, nil +} + +func settleEnvironmentInput(ctx context.Context, q *sqlc.Queries, tenantID string, row sqlc.EnvironmentInputReservation, state string) (EnvironmentInputReservation, error) { + if row.State != EnvironmentInputPending { + return environmentInputOutcome(ctx, q, row) + } + if err := q.ExpireEnvironmentInputReservation(ctx, sqlc.ExpireEnvironmentInputReservationParams{SessionID: row.SessionID, ID: row.ID}); err != nil { + return EnvironmentInputReservation{}, err + } + row, err := q.GetEnvironmentInputReservation(ctx, sqlc.GetEnvironmentInputReservationParams{SessionID: row.SessionID, ID: row.ID}) + if err != nil { + return EnvironmentInputReservation{}, err + } + // Terminal outcomes are successful storage results so settlement is not rolled back. + if row.State != EnvironmentInputPending || state == EnvironmentInputExpired { + return environmentInputOutcome(ctx, q, row) + } + result, err := environmentInputFromRow(row) + if err != nil { + return EnvironmentInputReservation{}, err + } + if state == EnvironmentInputAdmitted { + if err := environmentInputMayStart(ctx, q, row.SessionID); err != nil { + return EnvironmentInputReservation{}, err + } + for position, input := range result.Inputs { + receipt, err := admitInput(ctx, q, tenantID, row.SessionID, row.IdempotencyKey, int32(position), input) + if err != nil { + return EnvironmentInputReservation{}, err + } + result.Receipts = append(result.Receipts, receipt) + } + } + row, err = q.SettleEnvironmentInputReservation(ctx, sqlc.SettleEnvironmentInputReservationParams{SessionID: row.SessionID, ID: row.ID, State: state}) + if err != nil { + return EnvironmentInputReservation{}, err + } + if state == EnvironmentInputAdmitted { + params, err := turnLookup(tenantID, result.SessionID, result.Receipts[0].TurnID) + if err != nil { + return EnvironmentInputReservation{}, err + } + if _, err := transitionTurn(ctx, q, params, TurnTransition{ + ExpectedStatus: TurnQueued, Status: TurnInProgress, Outcome: json.RawMessage(`{}`), + }); err != nil { + return EnvironmentInputReservation{}, err + } + } + result.State = row.State + result.SettledAt = &row.SettledAt.Time + return result, nil +} + +func environmentInputMayStart(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + if err := checkEnvironmentFileWriteGate(ctx, q, session); err != nil { + return err + } + _, err := q.GetActiveTurn(ctx, session) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err == nil { + return ErrTurnConflict + } + return err +} + +func environmentInputOutcome(ctx context.Context, q *sqlc.Queries, row sqlc.EnvironmentInputReservation) (EnvironmentInputReservation, error) { + result, err := environmentInputFromRow(row) + if err != nil || row.State != EnvironmentInputAdmitted { + return result, err + } + result.Receipts, err = inputBatchReceipts(ctx, q, row.SessionID, row.IdempotencyKey, row.Batch) + if err == nil && len(result.Receipts) == 0 { + err = errors.New("admitted Environment input has no receipts") + } + return result, err +} + +func environmentInputFromRow(row sqlc.EnvironmentInputReservation) (EnvironmentInputReservation, error) { + result := EnvironmentInputReservation{ + ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), + State: row.State, IsInitial: row.IsInitial, CreatedAt: row.CreatedAt.Time, Deadline: row.Deadline.Time, + } + if row.SettledAt.Valid { + result.SettledAt = &row.SettledAt.Time + } + if err := json.Unmarshal(row.Batch, &result.Inputs); err != nil { + return EnvironmentInputReservation{}, fmt.Errorf("decode Environment input: %w", err) + } + return result, nil +} + +func checkEnvironmentInputGate(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, key string, batch json.RawMessage) error { + gate, err := q.CheckEnvironmentInputGate(ctx, sqlc.CheckEnvironmentInputGateParams{SessionID: session, IdempotencyKey: key, Batch: batch}) + if err != nil { + return err + } + if !gate.Matches { + return ErrIdempotencyConflict + } + if gate.Blocked { + return ErrTurnConflict + } + return nil +} diff --git a/services/agents-api/internal/store/environment_inputs_test.go b/services/agents-api/internal/store/environment_inputs_test.go new file mode 100644 index 000000000..12e2b30be --- /dev/null +++ b/services/agents-api/internal/store/environment_inputs_test.go @@ -0,0 +1,272 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func environmentInputSession(t *testing.T, s *Store) (string, Session) { + t.Helper() + tenant := uuid.NewString() + session, err := s.CreateSession(context.Background(), tenant, environmentInput("session", "self_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + return tenant, session +} + +func reserveEnvironmentInput(t *testing.T, s *Store, tenant, session, key string) EnvironmentInputReservation { + t.Helper() + got, err := s.ReserveEnvironmentInput(context.Background(), tenant, session, key, []Input{messageInput("first"), messageInput("second")}) + if err != nil { + t.Fatal(err) + } + return got +} + +func environmentInputHistory(t *testing.T, pool *pgxpool.Pool, session string, turns, inputs int) { + t.Helper() + var gotTurns, gotInputs, items, events int + err := pool.QueryRow(context.Background(), ` + SELECT (SELECT count(*) FROM turns WHERE session_id=$1), + (SELECT count(*) FROM turn_inputs WHERE session_id=$1), + (SELECT count(*) FROM session_items WHERE session_id=$1), + (SELECT count(*) FROM session_events WHERE session_id=$1 + AND (payload ? 'turn' OR payload->'event' ? 'item'))`, session).Scan(&gotTurns, &gotInputs, &items, &events) + if err != nil || gotTurns != turns || gotInputs != inputs || items != inputs || (inputs == 0 && events != 0) { + t.Fatal("history", gotTurns, gotInputs, items, events, err) + } +} + +func TestEnvironmentInputReservationConcurrentIdentity(t *testing.T) { + s, pool := testStore(t) + other, _ := testStore(t) + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + batch := []Input{ + {Kind: "message", Payload: json.RawMessage(`{"text":"first","detail":{"a":1,"b":2}}`)}, + messageInput("second"), + } + const count = 8 + results := make(chan EnvironmentInputReservation, count) + var wg sync.WaitGroup + for i := range count { + wg.Add(1) + go func() { + defer wg.Done() + st := s + inputs := append([]Input(nil), batch...) + if i%2 == 0 { + st = other + inputs[0].Payload = json.RawMessage(` { "detail": {"b": 2, "a": 1}, "text": "first" } `) + } + got, err := st.ReserveEnvironmentInput(ctx, tenant, session.ID, "request", inputs) + if err != nil { + t.Error(err) + return + } + results <- got + }() + } + wg.Wait() + close(results) + var first EnvironmentInputReservation + received := 0 + for result := range results { + received++ + if first.ID == "" { + first = result + } + if !reflect.DeepEqual(first, result) { + t.Fatal("reservation identity changed", first, result) + } + } + if received != count || first.State != EnvironmentInputPending || first.ID == "" || first.Deadline.Sub(first.CreatedAt) != 5*time.Minute || first.SettledAt != nil || len(first.Receipts) != 0 { + t.Fatal("invalid pending result", received, first) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + for _, changed := range [][]Input{batch[:1], {batch[1], batch[0]}, {messageInput("changed"), batch[1]}} { + if _, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "request", changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed request accepted", err) + } + } + if _, err := other.ReserveEnvironmentInput(ctx, tenant, session.ID, "other", batch); !errors.Is(err, ErrTurnConflict) { + t.Fatal("second pending request accepted", err) + } + pool.Close() + restarted, _ := testStore(t) + got, err := restarted.ReserveEnvironmentInput(ctx, tenant, session.ID, "request", batch) + if err != nil || !reflect.DeepEqual(first, got) { + t.Fatal("restart changed deadline or identity", got, err) + } +} + +func TestEnvironmentInputReservationPromotionAndDirectRetries(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + first := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + for _, request := range []struct { + key string + inputs []Input + want error + }{ + {"pending", first.Inputs, ErrTurnConflict}, + {"pending", []Input{messageInput("changed")}, ErrIdempotencyConflict}, + {"later", []Input{messageInput("later")}, ErrTurnConflict}, + {"cancel", []Input{{Kind: "cancel", Payload: json.RawMessage(`{}`)}}, ErrTurnConflict}, + } { + if _, err := s.SubmitInputs(ctx, tenant, session.ID, request.key, request.inputs); !errors.Is(err, request.want) { + t.Fatal("direct path bypassed reservation", request.key, err) + } + } + environmentInputHistory(t, pool, session.ID, 0, 0) + promoted, err := writer.PromoteEnvironmentInput(ctx, tenant, session.ID, first.ID) + if err != nil || promoted.State != EnvironmentInputAdmitted || promoted.SettledAt == nil || len(promoted.Receipts) != 2 || !promoted.Deadline.Equal(first.Deadline) { + t.Fatal(promoted, err) + } + for i, receipt := range promoted.Receipts { + if receipt.Replayed || receipt.TurnID == "" || receipt.TurnID != promoted.Receipts[0].TurnID || (i > 0 && receipt.Sequence <= promoted.Receipts[i-1].Sequence) { + t.Fatal("promotion receipts", promoted.Receipts) + } + } + environmentInputHistory(t, pool, session.ID, 1, 2) + for _, read := range []func() (EnvironmentInputReservation, error){ + func() (EnvironmentInputReservation, error) { + return writer.PromoteEnvironmentInput(ctx, tenant, session.ID, first.ID) + }, + func() (EnvironmentInputReservation, error) { + return s.GetEnvironmentInputReservation(ctx, tenant, session.ID, first.ID) + }, + func() (EnvironmentInputReservation, error) { + return s.ReserveEnvironmentInput(ctx, tenant, session.ID, "pending", first.Inputs) + }, + } { + retry, err := read() + if err != nil || retry.ID != first.ID || !retry.Deadline.Equal(first.Deadline) || retry.State != EnvironmentInputAdmitted || len(retry.Receipts) != 2 { + t.Fatal(retry, err) + } + for i, receipt := range retry.Receipts { + if !receipt.Replayed || receipt.Sequence != promoted.Receipts[i].Sequence || receipt.TurnID != promoted.Receipts[i].TurnID { + t.Fatal("retry changed admission", receipt) + } + } + } + retry, err := s.SubmitInputs(ctx, tenant, session.ID, "pending", first.Inputs) + if err != nil || len(retry) != 2 || !retry[0].Replayed || retry[0].Sequence != promoted.Receipts[0].Sequence { + t.Fatal("direct retry after promotion", retry, err) + } + if err := lease.Close(ctx); err != nil { + t.Fatal(err) + } + pool.Close() + restarted, pool := testStore(t) + after, err := executionLease(t, restarted).Store().PromoteEnvironmentInput(ctx, tenant, session.ID, first.ID) + if err != nil || after.State != EnvironmentInputAdmitted || after.Receipts[0].Sequence != promoted.Receipts[0].Sequence { + t.Fatal("restart repeated promotion", after, err) + } + environmentInputHistory(t, pool, session.ID, 1, 2) +} + +func TestEnvironmentInputReservationKeepsEarlierDirectIdentity(t *testing.T) { + s, _ := testStore(t) + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + input := messageInput("already admitted") + receipts, err := s.SubmitInputs(ctx, tenant, session.ID, "direct", []Input{input}) + if err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session.ID, receipts[0].TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, receipts[0].TurnID, TurnInProgress, TurnCompleted) + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "new") + got, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "direct", []Input{input}) + if err != nil || got.State != EnvironmentInputAdmitted || got.ID != "" || !got.Deadline.IsZero() || len(got.Receipts) != 1 || got.Receipts[0].Sequence != receipts[0].Sequence { + t.Fatal("direct admission gained a reservation", got, err) + } + if _, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "direct", []Input{messageInput("changed")}); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + retry, err := s.SubmitInputs(ctx, tenant, session.ID, "direct", []Input{input}) + if err != nil || len(retry) != 1 || !retry[0].Replayed { + t.Fatal(retry, err) + } + retained, err := s.GetEnvironmentInputReservation(ctx, tenant, session.ID, pending.ID) + if err != nil || !reflect.DeepEqual(retained, pending) { + t.Fatal("old retry affected new pending input", retained, err) + } +} + +func TestEnvironmentInputReservationRejectsUnsupportedOrForeignState(t *testing.T) { + s, _ := testStore(t) + lease := executionLease(t, s) + writer := lease.Store() + tenant, session := environmentInputSession(t, s) + ctx := context.Background() + pending := reserveEnvironmentInput(t, s, tenant, session.ID, "pending") + for _, read := range []func() error{ + func() error { + _, err := s.GetEnvironmentInputReservation(ctx, uuid.NewString(), session.ID, pending.ID) + return err + }, + func() error { + _, err := writer.PromoteEnvironmentInput(ctx, uuid.NewString(), session.ID, pending.ID) + return err + }, + func() error { + _, err := s.CancelEnvironmentInput(ctx, uuid.NewString(), session.ID, pending.ID) + return err + }, + func() error { + _, err := s.ReserveEnvironmentInput(ctx, uuid.NewString(), session.ID, "new", pending.Inputs) + return err + }, + } { + if err := read(); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign access", err) + } + } + other, err := s.CreateSession(ctx, tenant, environmentInput("other", "self_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + for _, action := range []func(context.Context, string, string, string) (EnvironmentInputReservation, error){ + s.GetEnvironmentInputReservation, writer.PromoteEnvironmentInput, s.CancelEnvironmentInput, s.ExpireEnvironmentInput, + } { + if _, err := action(ctx, tenant, other.ID, pending.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("reservation crossed Session ownership", err) + } + if _, err := action(ctx, uuid.NewString(), session.ID, pending.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("reservation crossed tenant ownership", err) + } + } + retained, err := s.GetEnvironmentInputReservation(ctx, tenant, session.ID, pending.ID) + if err != nil || !reflect.DeepEqual(retained, pending) { + t.Fatal("foreign operations changed reservation", retained, err) + } + for _, invalid := range [][]Input{nil, {{Kind: "cancel", Payload: json.RawMessage(`{}`)}}, {{Kind: "tool_result", Payload: json.RawMessage(`{}`)}}} { + if _, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "invalid", invalid); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unsupported reservation", err) + } + } + noneTenant, none := newTurnSession(t, s) + if _, err := s.ReserveEnvironmentInput(ctx, noneTenant, none.ID, "none", pending.Inputs); !errors.Is(err, ErrInvalidInput) { + t.Fatal("none reservation", err) + } + activeTenant, active := environmentInputSession(t, s) + activeInput := submitMessage(t, s, activeTenant, active.ID, "active") + steer, err := s.ReserveEnvironmentInput(ctx, activeTenant, active.ID, "new", pending.Inputs) + if err != nil || steer.State != EnvironmentInputAdmitted || steer.ID != "" || !steer.Deadline.IsZero() || len(steer.Receipts) != len(pending.Inputs) || steer.Receipts[0].TurnID != activeInput.TurnID { + t.Fatal("active input did not retain the existing Turn", steer, err) + } +} diff --git a/services/agents-api/internal/store/environment_retrieve_public_test.go b/services/agents-api/internal/store/environment_retrieve_public_test.go new file mode 100644 index 000000000..3171c3fc0 --- /dev/null +++ b/services/agents-api/internal/store/environment_retrieve_public_test.go @@ -0,0 +1,115 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestEnvironmentRetrievalOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: principal.OrganizationID, ProjectID: tenant, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: principal.OrganizationID, ProjectID: tenant, SubjectKind: "user", SubjectID: "different-reader", TokenSHA256: device.HashCredential(peer), TenantID: tenant}, + {OrganizationID: principal.OrganizationID, ProjectID: foreignTenant, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + if err := s.EnsureProjectScopes(t.Context(), auth.ProjectScopes()); err != nil { + t.Fatal(err) + } + executor, err := s.IssueExecutorCredential(t.Context(), principal, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + revoked := false + defer func() { + if !revoked { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.RevokeExecutorCredential(ctx, principal, executor.KeyID); err != nil { + t.Error("owned executor credential cleanup failed", err) + } + } + }() + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(s), api.WithEnvironmentRemoteURL("https://private-registry.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + settings := map[string]string{"base": server.URL, "token": token, "peer_token": peer, "foreign_token": foreign, "executor_token": executor.Token} + run := func() map[string]string { + t.Helper() + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_environment_retrieve.py") + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("official Environment retrieval: %v %s", err, output) + } + var result map[string]string + if err := json.Unmarshal(output, &result); err != nil { + t.Fatal("invalid official Environment retrieval result", err) + } + return result + } + result := run() + before, err := s.GetEnvironment(t.Context(), tenant, result["environment_id"]) + if err != nil { + t.Fatal(err) + } + if err := s.RevokeExecutorCredential(t.Context(), principal, executor.KeyID); err != nil { + t.Fatal(err) + } + revoked = true + server.Close() + pool.Close() + reopened, reopenedPool := store.NewTestStore(t) + handler, err = api.NewHandler(reopened, auth, "codex") + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(handler) + defer recovered.Close() + settings["base"], settings["phase"] = recovered.URL, "reopened" + for key, value := range result { + settings[key] = value + } + run() + after, err := reopened.GetEnvironment(t.Context(), tenant, before.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("public retrieval changed durable Environment state", err) + } + var history int + if err := reopenedPool.QueryRow(t.Context(), `SELECT + (SELECT count(*) FROM turns WHERE session_id=$1) + + (SELECT count(*) FROM environment_input_reservations WHERE session_id=$1) + + (SELECT count(*) FROM session_events WHERE session_id=$1)`, before.SessionID).Scan(&history); err != nil || history != 0 { + t.Fatal("read-only retrieval admitted work or emitted events", history, err) + } +} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go new file mode 100644 index 000000000..0e39e8932 --- /dev/null +++ b/services/agents-api/internal/store/environment_setup.go @@ -0,0 +1,158 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "path" + "regexp" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentSetup is confidential input, never ordinary resource metadata. +// Core freezes it once; the common Runtime initializer executes it in order. +type EnvironmentSetup struct { + Env map[string]string `json:"env,omitempty"` + Commands []SetupCommand `json:"setup_commands,omitempty"` + Packages v1.EnvironmentPackages `json:"packages"` + Skills []InlineSkill `json:"skills,omitempty"` +} + +type SetupCommand struct { + Command string `json:"command"` + CWD string `json:"cwd,omitempty"` +} + +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (s EnvironmentSetup) Empty() bool { + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System)+len(s.Skills) == 0 +} + +func (s EnvironmentSetup) Validate() error { + if ValidateInlineSkills(s.Skills) != nil { + return ErrInvalidInput + } + ordinary := s + ordinary.Skills = nil + raw, err := json.Marshal(ordinary) + if err != nil || len(raw) > 512*1024 { + return ErrInvalidInput + } + for name, value := range s.Env { + // The first three reservations are explicitly part of the public guide; + // PARSAR_* identifies the actual deployment authority and binding. + if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "PARSAR_") || strings.ContainsRune(value, 0) { + return ErrInvalidInput + } + } + for _, command := range s.Commands { + if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { + return ErrInvalidInput + } + } + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python, s.Packages.System} { + for _, item := range packages { + if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { + return ErrInvalidInput + } + } + } + return nil +} + +func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { + if empty { + return nil, nil + } + plaintext, err := json.Marshal(input) + if err != nil { + return nil, err + } + return s.credentialCipher.SealEnvironmentSetup(plaintext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) +} + +func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphertext []byte, output any) error { + if len(ciphertext) == 0 { + return nil + } + plaintext, err := s.credentialCipher.OpenEnvironmentSetup(ciphertext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) + if err != nil { + return err + } + if json.Unmarshal(plaintext, output) != nil { + return ErrInvalidInput + } + return nil +} + +func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { + if err := setup.Validate(); err != nil { + return err + } + if setup.Empty() { + return nil + } + owner, err := parseID(tenant) + if err != nil { + return err + } + encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup, false) + if err != nil { + return err + } + return q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: session, Contents: encrypted}) +} + +func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (EnvironmentSetup, error) { + var result EnvironmentSetup + lookup, err := deviceLookup(tenant, session) + if err != nil { + return result, ErrNotFound + } + encrypted, err := s.queries.GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return result, ErrNotFound + } + if err != nil { + return result, err + } + if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { + return result, err + } + return result, result.Validate() +} + +func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { + result := s.Packages + if result.NPM == nil { + result.NPM = []string{} + } + if result.Python == nil { + result.Python = []string{} + } + if result.System == nil { + result.System = []string{} + } + return result +} + +func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, []byte, error) { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return nil, nil, nil, err + } + env, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "env", setup.Env, len(setup.Env) == 0) + if err != nil { + return nil, nil, nil, err + } + commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) + return packages, env, commands, err +} diff --git a/services/agents-api/internal/store/environment_setup_test.go b/services/agents-api/internal/store/environment_setup_test.go new file mode 100644 index 000000000..32ffd83da --- /dev/null +++ b/services/agents-api/internal/store/environment_setup_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, System: []string{"jq", "libpq-dev"}}} + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{Initialization: setup, SetEnv: true, SetSetup: true, SetPackages: true}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.System, setup.Packages.System) || !public.Initialization.Empty() { + t.Fatal("public metadata requires plaintext or key", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Env, setup.Env) || !reflect.DeepEqual(resolved.Initialization.Commands, setup.Commands) { + t.Fatal("confidential configuration resolution", err) + } + if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign resolution", err) + } + var envCipher, commandCipher []byte + if err := pool.QueryRow(t.Context(), "SELECT env_contents, setup_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&envCipher, &commandCipher); err != nil || bytes.Contains(envCipher, []byte("template-env-canary")) || bytes.Contains(commandCipher, []byte("template-command-canary")) { + t.Fatal("plaintext template storage", err) + } + // Unrelated updates preserve both confidential fields; replacement is scoped. + name := "renamed" + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + retained, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(retained.Initialization, resolved.Initialization) { + t.Fatal("name update changed initialization", err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(session.Configuration, []byte("canary")) { + t.Fatal("plaintext Session metadata") + } + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetEnv: true}); err != nil { + t.Fatal(err) + } + cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(cleared.Initialization.Env) != 0 || !reflect.DeepEqual(cleared.Initialization.Commands, setup.Commands) { + t.Fatal("field replacement lost unrelated values", err) + } + if _, err := s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(frozen, resolved.Initialization) { + t.Fatal("Session did not freeze setup", err) + } + if _, err := s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Session initialization", err) + } + retry, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || retry.ID != session.ID { + t.Fatal("creation replay", err) + } + input.Initialization.Env = map[string]string{"SECRET": "changed"} + if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed setup retried", err) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("uninitialized execution admitted", err) + } +} diff --git a/services/agents-api/internal/store/environment_skills.go b/services/agents-api/internal/store/environment_skills.go new file mode 100644 index 000000000..dd23ddf29 --- /dev/null +++ b/services/agents-api/internal/store/environment_skills.go @@ -0,0 +1,59 @@ +package store + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +// InlineSkill is confidential immutable initialization input. Only Metadata is public. +type InlineSkill struct { + Metadata agentskill.Metadata `json:"metadata"` + Archive []byte `json:"archive"` +} + +const MaxSkillsArchiveBytes = 10 << 20 + +func ValidateInlineSkills(skills []InlineSkill) error { + if len(skills) > 50 { + return ErrInvalidInput + } + seen := map[string]bool{} + total := 0 + expanded := 0 + for _, skill := range skills { + total += len(skill.Archive) + if total > MaxSkillsArchiveBytes || seen[skill.Metadata.Name] { + return ErrInvalidInput + } + seen[skill.Metadata.Name] = true + files, err := agentskill.Read(skill.Archive, skill.Metadata) + if err != nil { + return ErrInvalidInput + } + for _, file := range files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalidInput + } + } + return nil +} + +func (s EnvironmentSetup) SkillMetadata() []agentskill.Metadata { + result := make([]agentskill.Metadata, 0, len(s.Skills)) + for _, skill := range s.Skills { + result = append(result, skill.Metadata) + } + return result +} + +func (s *Store) sealTemplateSkills(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { + metadata, err := json.Marshal(setup.SkillMetadata()) + if err != nil { + return nil, nil, err + } + contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "skills", setup.Skills, len(setup.Skills) == 0) + return metadata, contents, err +} diff --git a/services/agents-api/internal/store/environment_skills_test.go b/services/agents-api/internal/store/environment_skills_test.go new file mode 100644 index 000000000..842f23cf4 --- /dev/null +++ b/services/agents-api/internal/store/environment_skills_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + header := &zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store} + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\nprivate-skill-canary")); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + setup := EnvironmentSetup{Skills: []InlineSkill{{Metadata: agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} + tenant, foreign := uuid.NewString(), uuid.NewString() + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Skills) != 1 || !public.Initialization.Empty() { + t.Fatal("public metadata", err) + } + var metadata, encrypted []byte + if err = pool.QueryRow(t.Context(), "SELECT skills,skill_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || bytes.Contains(metadata, []byte("canary")) || bytes.Contains(encrypted, []byte("canary")) { + t.Fatal("plaintext storage", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Skills, setup.Skills) { + t.Fatal("resolution", err) + } + if _, _, err = s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("tenant isolation", err) + } + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization}) + if err != nil { + t.Fatal(err) + } + name := "renamed" + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(preserved.Initialization.Skills, setup.Skills) { + t.Fatal("unrelated update", err) + } + cleared, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetSkills: true}) + if err != nil || len(cleared.Skills) != 0 { + t.Fatal("clearing", err) + } + if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(frozen.Skills, setup.Skills) { + t.Fatal("frozen content changed", err) + } + if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign content", err) + } +} diff --git a/services/agents-api/internal/store/environment_steering_order_test.go b/services/agents-api/internal/store/environment_steering_order_test.go new file mode 100644 index 000000000..537dbf78f --- /dev/null +++ b/services/agents-api/internal/store/environment_steering_order_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestEnvironmentActiveInputSerializesWithCompletion(t *testing.T) { + for _, completionFirst := range []bool{false, true} { + name := "input-first" + if completionFirst { + name = "completion-first" + } + t.Run(name, func(t *testing.T) { + s, pool := testStore(t) + writer := executionLease(t, s).Store() + tenant, session := environmentInputSession(t, s) + original := submitMessage(t, s, tenant, session.ID, "original") + transition(t, s, tenant, session.ID, original.TurnID, TurnQueued, TurnInProgress) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + blocker, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = blocker.Rollback(context.Background()) }() + var blockerPID int32 + if err := blocker.QueryRow(ctx, "SELECT pg_backend_pid() FROM sessions WHERE id=$1 FOR UPDATE", session.ID).Scan(&blockerPID); err != nil { + t.Fatal(err) + } + waiter := func(pid int32) int32 { + t.Helper() + for ctx.Err() == nil { + var blocked int32 + if err := pool.QueryRow(ctx, "SELECT pid FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid)) ORDER BY pid LIMIT 1", pid).Scan(&blocked); err == nil { + return blocked + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("transaction did not wait for the Session lock") + return 0 + } + type admission struct { + value EnvironmentInputReservation + err error + } + admitted := make(chan admission, 1) + completed := make(chan error, 1) + batch := []Input{messageInput("first"), messageInput("second")} + input := func() { + value, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "racing-input", batch) + admitted <- admission{value, err} + } + complete := func() { + _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, TurnCompleted, nil, "", original.Sequence) + completed <- err + } + first, second := input, complete + if completionFirst { + first, second = complete, input + } + go first() + firstPID := waiter(blockerPID) + go second() + waiter(firstPID) + if err := blocker.Commit(ctx); err != nil { + t.Fatal(err) + } + got, completionErr := <-admitted, <-completed + if got.err != nil { + t.Fatal(got.err) + } + if completionFirst { + if completionErr != nil || got.value.State != EnvironmentInputPending || got.value.ID == "" || len(got.value.Receipts) != 0 || got.value.Deadline.Sub(got.value.CreatedAt) != 5*time.Minute { + t.Fatal("completion winner did not leave new input waiting for preparation", completionErr, got.value) + } + environmentInputHistory(t, pool, session.ID, 1, 1) + prepared, err := writer.PromoteEnvironmentInput(ctx, tenant, session.ID, got.value.ID) + if err != nil || len(prepared.Receipts) != 2 || prepared.Receipts[0].TurnID == original.TurnID { + t.Fatal("prepared successor reused terminal work", err) + } + retry, err := s.ReserveEnvironmentInput(ctx, tenant, session.ID, "racing-input", batch) + if err != nil || retry.ID != got.value.ID || !retry.Deadline.Equal(got.value.Deadline) || len(retry.Receipts) != 2 || !retry.Receipts[0].Replayed || retry.Receipts[0].TurnID != prepared.Receipts[0].TurnID { + t.Fatal("active retry replaced its original reservation", retry, err) + } + if _, err := writer.CompleteExecution(ctx, tenant, session.ID, prepared.Receipts[0].TurnID, TurnCompleted, nil, "", prepared.Receipts[1].Sequence); err != nil { + t.Fatal(err) + } + } else { + if !errors.Is(completionErr, ErrUnappliedInputs) || got.value.State != EnvironmentInputAdmitted || got.value.ID != "" || !got.value.Deadline.IsZero() || len(got.value.Receipts) != 2 || got.value.Receipts[0].TurnID != original.TurnID || got.value.Receipts[0].Replayed { + t.Fatal("admitted input escaped the original Turn or application fence", completionErr, got.value) + } + environmentInputHistory(t, pool, session.ID, 1, 3) + if _, err := writer.CompleteExecution(ctx, tenant, session.ID, original.TurnID, TurnCompleted, nil, "", got.value.Receipts[1].Sequence); err != nil { + t.Fatal("completion after controlled application failed", err) + } + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go new file mode 100644 index 000000000..52b17470b --- /dev/null +++ b/services/agents-api/internal/store/environment_templates.go @@ -0,0 +1,207 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "time" + "unicode/utf8" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentTemplate is configuration ownership, independent of provider images. + +type EnvironmentTemplate struct { + Skills []agentskill.Metadata + Packages v1.EnvironmentPackages + Initialization EnvironmentSetup + Files []InitialFileMetadata + ID string + Name *string + NetworkAccess string + CreatedAt time.Time + UpdatedAt time.Time +} + +type EnvironmentTemplateInput struct { + Initialization EnvironmentSetup + SetEnv, SetSetup, SetPackages, SetSkills bool + Files []InitialFile + SetFiles bool + Name *string + SetName bool + NetworkAccess string + SetNetwork bool +} + +func (in EnvironmentTemplateInput) valid() bool { + return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && + (!in.SetNetwork || in.NetworkAccess == "enabled" || in.NetworkAccess == "disabled") +} + +type templateMetadataRow sqlc.GetEnvironmentTemplateRow + +func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, error) { + if errors.Is(err, pgx.ErrNoRows) { + return EnvironmentTemplate{}, ErrNotFound + } + if err != nil { + return EnvironmentTemplate{}, err + } + result := EnvironmentTemplate{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + if row.Name.Valid { + result.Name = &row.Name.String + } + if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil { + return EnvironmentTemplate{}, ErrInvalidInput + } + return result, nil +} + +func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.SetNetwork { + in.NetworkAccess = "enabled" + in.SetNetwork = true + } + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + id := uuid.New() + metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), id.String(), in.Files) + if err != nil { + return EnvironmentTemplate{}, err + } + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents}) + return templateFromRow(templateMetadataRow(row), err) +} + +func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + id, err := parseID(templateID) + if err != nil { + return EnvironmentTemplate{}, ErrNotFound + } + row, err := s.queries.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: id}) + return templateFromRow(templateMetadataRow(row), err) +} + +// Each supplied field replaces atomically, preserving concurrent unrelated updates. +func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templateID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + id, err := parseID(templateID) + if err != nil { + return EnvironmentTemplate{}, ErrNotFound + } + if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages && !in.SetSkills { + return s.GetEnvironmentTemplate(ctx, tenantID, templateID) + } + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Files) + if err != nil { + return EnvironmentTemplate{}, err + } + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, Skills: skills, SkillContents: skillContents}) + return templateFromRow(templateMetadataRow(row), err) +} + +func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", err + } + id, err := parseID(templateID) + if err != nil { + return "", ErrNotFound + } + result, err := s.queries.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + return uuid.UUID(result.Bytes).String(), nil +} + +type EnvironmentTemplatePage struct { + Templates []EnvironmentTemplate + HasMore bool +} + +func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (EnvironmentTemplatePage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplatePage{}, err + } + if limit < 1 || limit > 100 { + return EnvironmentTemplatePage{}, ErrInvalidInput + } + params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetEnvironmentTemplate(ctx, tenantID, cursor) + if err != nil { + return EnvironmentTemplatePage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListEnvironmentTemplates(ctx, params) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page := EnvironmentTemplatePage{Templates: make([]EnvironmentTemplate, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if len(rows) > limit { + rows = rows[:limit] + } + for _, row := range rows { + value, err := templateFromRow(templateMetadataRow(row), nil) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page.Templates = append(page.Templates, value) + } + return page, nil +} diff --git a/services/agents-api/internal/store/environment_templates_test.go b/services/agents-api/internal/store/environment_templates_test.go new file mode 100644 index 000000000..2b0f6562f --- /dev/null +++ b/services/agents-api/internal/store/environment_templates_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "errors" + "github.com/google/uuid" + "sync" + "testing" +) + +func TestEnvironmentTemplatesDurabilityIsolationAndConcurrentUpdates(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + name := " template " + created, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{Name: &name}) + if err != nil || created.NetworkAccess != "enabled" || created.Name == nil || *created.Name != name || !created.CreatedAt.Equal(created.UpdatedAt) { + t.Fatal(created, err) + } + for _, target := range []string{foreign} { + if _, err := s.GetEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign read", err) + } + if _, err := s.UpdateEnvironmentTemplate(ctx, target, created.ID, EnvironmentTemplateInput{SetName: true}); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign update", err) + } + if _, err := s.DeleteEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign delete", err) + } + if _, err := s.ListEnvironmentTemplates(ctx, target, created.ID, 1, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + } + newName := "changed" + var wg sync.WaitGroup + for _, in := range []EnvironmentTemplateInput{{Name: &newName, SetName: true}, {NetworkAccess: "disabled", SetNetwork: true}} { + wg.Add(1) + go func(in EnvironmentTemplateInput) { + defer wg.Done() + if _, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, in); err != nil { + t.Error(err) + } + }(in) + } + wg.Wait() + got, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID) + if err != nil || got.NetworkAccess != "disabled" || got.Name == nil || *got.Name != newName || !got.CreatedAt.Equal(created.CreatedAt) { + t.Fatal("lost concurrent update", got, err) + } + pool.Close() + s, _ = testStore(t) + got, err = s.GetEnvironmentTemplate(ctx, tenant, created.ID) + if err != nil || got.NetworkAccess != "disabled" { + t.Fatal("lost durable template", got, err) + } + ids := []string{created.ID} + for range 3 { + v, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) + if err != nil { + t.Fatal(err) + } + ids = append(ids, v.ID) + } + first, err := s.ListEnvironmentTemplates(ctx, tenant, "", 2, true) + if err != nil || !first.HasMore || len(first.Templates) != 2 || first.Templates[0].ID != ids[0] { + t.Fatal(first, err) + } + second, err := s.ListEnvironmentTemplates(ctx, tenant, first.Templates[1].ID, 2, true) + if err != nil || second.HasMore || len(second.Templates) != 2 || second.Templates[0].ID != ids[2] { + t.Fatal(second, err) + } + reverse, err := s.ListEnvironmentTemplates(ctx, tenant, "", 1, false) + if err != nil || reverse.Templates[0].ID != ids[3] { + t.Fatal(reverse, err) + } + cleared, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetName: true, SetNetwork: true, NetworkAccess: "enabled"}) + if err != nil || cleared.Name != nil || cleared.NetworkAccess != "enabled" { + t.Fatal(cleared, err) + } + if id, err := s.DeleteEnvironmentTemplate(ctx, tenant, created.ID); err != nil || id != created.ID { + t.Fatal(id, err) + } + if _, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted"}); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/environment_work_test.go b/services/agents-api/internal/store/environment_work_test.go new file mode 100644 index 000000000..127ac0736 --- /dev/null +++ b/services/agents-api/internal/store/environment_work_test.go @@ -0,0 +1,82 @@ +package store_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestEnvironmentInputWorkFiltersAndPagesDevices(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + wanted := map[string]string{} + for range 103 { + pending := workerEnvironmentReservation(t, h) + wanted[pending.ID] = pending.SessionID + } + for _, state := range []string{"expired", "cancelled", "deleted", "unbound", "revoked", "disconnected"} { + pending := workerEnvironmentReservation(t, h) + switch state { + case "expired": + makeEnvironmentExpiryDue(t, pool, &pending) + case "cancelled": + if _, err := h.s.CancelEnvironmentInput(t.Context(), h.tenant, pending.SessionID, pending.ID); err != nil { + t.Fatal(err) + } + case "deleted": + if err := h.s.DeleteSession(t.Context(), h.tenant, pending.SessionID); err != nil { + t.Fatal(err) + } + case "unbound": + wanted[pending.ID] = pending.SessionID + if _, err := pool.Exec(t.Context(), "DELETE FROM session_devices WHERE session_id=$1", pending.SessionID); err != nil { + t.Fatal(err) + } + default: + other, err := h.s.CreateDevice(t.Context(), h.tenant, state, device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE session_devices SET device_id=$2 WHERE session_id=$1", pending.SessionID, other.ID); err != nil { + t.Fatal(err) + } + if state == "revoked" { + if err := h.s.RevokeDevice(t.Context(), h.tenant, other.ID); err != nil { + t.Fatal(err) + } + work, err := h.s.ListEnvironmentInputWork(t.Context(), "", []string{other.ID}) + if err != nil || len(work) != 0 { + t.Fatal("revoked device selected", work, err) + } + } + } + } + for _, devices := range [][]string{nil, {}, {uuid.NewString()}} { + work, err := h.s.ListEnvironmentInputWork(t.Context(), "", devices) + if err != nil || len(work) != 0 { + t.Fatal("unconnected work selected", work, err) + } + } + foreign := *h + foreign.tenant = uuid.NewString() + unboundWorkerEnvironmentReservation(t, &foreign) + seen, cursor := 0, "" + for _, count := range []int{100, 4, 0} { + work, err := h.s.ListEnvironmentInputWork(t.Context(), cursor, []string{h.device.ID}) + if err != nil || len(work) != count { + t.Fatal("environment work page", len(work), count, err) + } + for _, item := range work { + if item.TenantID != h.tenant || item.SessionID != wanted[item.ReservationID] || item.ReservationID <= cursor { + t.Fatal("wrong scope or pagination", item) + } + cursor = item.ReservationID + seen++ + } + } + if seen != len(wanted) { + t.Fatal("pending work lost across pages") + } +} diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/agents-api/internal/store/environment_worker_helpers_test.go new file mode 100644 index 000000000..43a82957e --- /dev/null +++ b/services/agents-api/internal/store/environment_worker_helpers_test.go @@ -0,0 +1,115 @@ +package store_test + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) *atomic.Int32 { + t.Helper() + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: workerEnvironmentCapabilities()}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "worker preparation capability", func() bool { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + return false + } + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.Preparation && info.Capabilities.EnvironmentNone + }) + released := &atomic.Int32{} + h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { + var once sync.Once + return execution.EnvironmentConnection{URL: "http://private-registry.test", Token: "synthetic-worker-token", Release: func() { once.Do(func() { released.Add(1) }) }}, nil + } + return released +} + +func workerEnvironmentCapabilities() proto.AgentKindCapabilities { + return proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true} +} + +func workerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { + t.Helper() + pending := unboundWorkerEnvironmentReservation(t, h) + if err := h.s.BindSessionDevice(t.Context(), h.tenant, pending.SessionID, h.device.ID); err != nil { + t.Fatal(err) + } + return pending +} + +func unboundWorkerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { + t.Helper() + session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/remote"}}`)}) + if err != nil { + t.Fatal(err) + } + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + if err != nil { + t.Fatal(err) + } + return pending +} + +func workerFrames(t *testing.T, h *dispatchHarness) <-chan proto.Envelope { + t.Helper() + frames := make(chan proto.Envelope, 64) + go func() { + defer close(frames) + for { + var env proto.Envelope + if h.conn.ReadJSON(&env) != nil { + return + } + select { + case frames <- env: + case <-t.Context().Done(): + return + } + } + }() + return frames +} + +func nextWorkerFrame(t *testing.T, frames <-chan proto.Envelope, kind string) proto.Envelope { + t.Helper() + select { + case frame, ok := <-frames: + if !ok || frame.Type != kind { + t.Fatal("unexpected worker frame", frame.Type, kind) + } + return frame + case <-time.After(15 * time.Second): + t.Fatal("worker did not send", kind) + return proto.Envelope{} + } +} + +func awaitWorkerEnvironmentRun(t *testing.T, ctx context.Context, s *store.Store, tenant string, pending store.EnvironmentInputReservation) execution.EnvironmentRun { + t.Helper() + var run execution.EnvironmentRun + awaitDaemonRemoteCondition(t, ctx, 5*time.Minute, "worker terminal Environment Turn", func() bool { + var err error + run.Reservation, err = s.GetEnvironmentInputReservation(ctx, tenant, pending.SessionID, pending.ID) + if err != nil { + t.Fatal(err) + } + if len(run.Reservation.Receipts) == 0 { + return false + } + run.Turn, err = s.GetTurn(ctx, tenant, pending.SessionID, run.Reservation.Receipts[0].TurnID) + if err != nil { + t.Fatal(err) + } + return run.Turn.Status == store.TurnCompleted || run.Turn.Status == store.TurnFailed || run.Turn.Status == store.TurnCancelled + }) + return run +} diff --git a/services/agents-api/internal/store/environment_worker_test.go b/services/agents-api/internal/store/environment_worker_test.go new file mode 100644 index 000000000..313a58802 --- /dev/null +++ b/services/agents-api/internal/store/environment_worker_test.go @@ -0,0 +1,160 @@ +package store_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + released := enableWorkerEnvironment(t, h) + frames := workerFrames(t, h) + pending := map[string]store.EnvironmentInputReservation{} + for range 2 { + value := unboundWorkerEnvironmentReservation(t, h) + pending[value.SessionID] = value + } + ordinary := map[string]store.Session{} + for _, key := range []string{"one", "two", "three"} { + session := publicSession(t, h, key) + h.session = session + receipt := h.message(key, "ordinary") + ordinary[receipt.TurnID] = session + } + _, stop := startEnvironmentExpiryWorker(t, h.d) + var normal []proto.Envelope + var preparing []proto.Envelope + for range 4 { + select { + case frame := <-frames: + switch frame.Type { + case proto.TypePromptRequest: + normal = append(normal, frame) + case proto.TypeExecutionPrepare: + preparing = append(preparing, frame) + default: + t.Fatal("unexpected initial frame", frame.Type) + } + case <-time.After(5 * time.Second): + t.Fatal("mixed queues did not fill capacity") + } + } + if len(normal) != 2 || len(preparing) != 2 { + t.Fatal("mixed queues did not share capacity", len(normal), len(preparing)) + } + first := preparing[0] + handle := acknowledgePreparation(h, first.ID) + h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + frame := nextWorkerFrame(t, frames, proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if frame.ID != first.ID || frame.DecodePayload(&start) != nil || start.Handle != handle || start.Prompt != "first" { + t.Fatal("worker changed preparation at Start") + } + h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + second := preparing[1] + secondHandle := acknowledgePreparation(h, second.ID) + var prepare proto.ExecutionPreparePayload + if second.DecodePayload(&prepare) != nil { + t.Fatal("invalid Prepare") + } + waiting := pending[strings.TrimPrefix(prepare.Configuration.AgentStateKey, "agents-api-")] + if waiting.ID == "" { + t.Fatal("wrong waiting Session") + } + select { + case frame := <-frames: + t.Fatal("claim released a slot or duplicated active preparation", frame.Type) + case <-time.After(time.Second): + } + tenant, due := newEnvironmentExpiryReservation(t, h.s) + makeEnvironmentExpiryDue(t, pool, &due) + waitEnvironmentExpiry(t, h.s, tenant, due) + if _, err := h.s.CancelEnvironmentInput(t.Context(), h.tenant, waiting.SessionID, waiting.ID); err != nil { + t.Fatal(err) + } + release := nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + var payload proto.ExecutionReleasePayload + if release.ID != second.ID || release.DecodePayload(&payload) != nil || payload.Handle != secondHandle { + t.Fatal("cancel released the wrong preparation") + } + normal = append(normal, nextWorkerFrame(t, frames, proto.TypePromptRequest)) + if released.Load() != 1 { + t.Fatal("next job preceded preparation cleanup") + } + for _, request := range normal { + h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "ordinary complete"}) + h.session = ordinary[request.ID] + waitTurn(t, h, request.ID, store.TurnCompleted) + } + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "remote complete"}) + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + stop() + if released.Load() != 2 { + t.Fatal("connection owners were not released") + } + assertEnvironmentExpiryHasNoHistory(t, pool, waiting.SessionID) + assertEnvironmentExpiryHasNoHistory(t, pool, due.SessionID) +} + +func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { + h := newDispatchHarness(t) + released := enableWorkerEnvironment(t, h) + frames := workerFrames(t, h) + pending := unboundWorkerEnvironmentReservation(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + first := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + started := time.Now() + handle := acknowledgePreparation(h, first.ID) + h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + h.session = publicSession(t, h, "unrelated") + receipt := h.message("ordinary", "make progress after preparation failure") + request := nextWorkerFrame(t, frames, proto.TypePromptRequest) + if request.ID != receipt.TurnID { + t.Fatal("preparation failure blocked ordinary work") + } + h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + waitTurn(t, h, request.ID, store.TurnCompleted) + second := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + if time.Since(started) < 4*time.Second || first.ID == second.ID || released.Load() != 1 { + t.Fatal("pending preparation retried rapidly or reused a released owner") + } + acknowledgePreparation(h, second.ID) + select { + case frame := <-frames: + t.Fatal("active preparation was duplicated", frame.Type) + case <-time.After(time.Second): + } + stop() + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + stored, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, pending.SessionID, pending.ID) + if err != nil || stored.State != store.EnvironmentInputPending || !stored.Deadline.Equal(pending.Deadline) || len(stored.Receipts) != 0 { + t.Fatal("retry or shutdown changed the original reservation", stored, err) + } + _, stop = startEnvironmentExpiryWorker(t, h.d) + third := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + handle = acknowledgePreparation(h, third.ID) + h.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + request = nextWorkerFrame(t, frames, proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if request.ID != third.ID || json.Unmarshal(request.Payload, &start) != nil || start.Handle != handle { + t.Fatal("restart changed retained preparation") + } + h.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) + if run.Turn.Status != store.TurnCompleted || !run.Reservation.Deadline.Equal(pending.Deadline) { + t.Fatal("restarted worker did not complete original work", run) + } + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + stop() + if released.Load() != 3 { + t.Fatal("worker leaked a preparation owner") + } +} diff --git a/services/agents-api/internal/store/environments.go b/services/agents-api/internal/store/environments.go new file mode 100644 index 000000000..cf9649206 --- /dev/null +++ b/services/agents-api/internal/store/environments.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// Environment retains execution ownership; its configuration is an internal snapshot, not a public response. +type Environment struct { + ID string + SessionID string + TenantID string + Status string + CreatedAt time.Time + Configuration json.RawMessage +} + +func createSessionEnvironment(ctx context.Context, q *sqlc.Queries, session sqlc.Session) error { + var snapshot struct { + Environment *struct { + Type string `json:"type"` + } `json:"environment"` + } + if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { + return fmt.Errorf("%w: invalid environment configuration", ErrInvalidInput) + } + if snapshot.Environment == nil || snapshot.Environment.Type == "none" { + return nil + } + switch snapshot.Environment.Type { + case "self_hosted", "openai_hosted": + return q.CreateEnvironment(ctx, sqlc.CreateEnvironmentParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session.ID, + }) + default: + return fmt.Errorf("%w: unsupported environment type", ErrInvalidInput) + } +} + +func (s *Store) GetEnvironment(ctx context.Context, tenantID, environmentID string) (Environment, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Environment{}, err + } + id, err := parseID(environmentID) + if err != nil { + return Environment{}, err + } + row, err := s.queries.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenant, ID: id}) + return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) +} + +func (s *Store) GetSessionEnvironment(ctx context.Context, tenantID, sessionID string) (Environment, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Environment{}, err + } + id, err := parseID(sessionID) + if err != nil { + return Environment{}, err + } + row, err := s.queries.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: id}) + return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) +} + +func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration []byte, err error) (Environment, error) { + if errors.Is(err, pgx.ErrNoRows) { + return Environment{}, ErrNotFound + } + if err != nil { + return Environment{}, fmt.Errorf("get environment: %w", err) + } + configuration, err = canonicalJSONObject(configuration) + if err != nil { + return Environment{}, fmt.Errorf("decode environment configuration: %w", err) + } + return Environment{ + ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), + TenantID: uuid.UUID(tenant.Bytes).String(), Status: row.Status, + CreatedAt: row.CreatedAt.Time, Configuration: configuration, + }, nil +} diff --git a/services/agents-api/internal/store/environments_migration_test.go b/services/agents-api/internal/store/environments_migration_test.go new file mode 100644 index 000000000..c9cdc21a1 --- /dev/null +++ b/services/agents-api/internal/store/environments_migration_test.go @@ -0,0 +1,131 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestEnvironmentMigrationPreservesHistoryAndGuardsIdentity(t *testing.T) { + _, pool := testStore(t) + ctx := context.Background() + schema := "environment_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 19); err != nil { + t.Fatal(err) + } + tenant := uuid.NewString() + for _, kind := range []string{"none", "self_hosted"} { + if _, err := db.ExecContext(ctx, `INSERT INTO sessions (id,tenant_id,engine,idempotency_key,request_hash,configuration) + VALUES ($1,$2,'codex',$3,'historical',jsonb_build_object('environment',jsonb_build_object('type',$3::text)))`, uuid.NewString(), tenant, kind); err != nil { + t.Fatal(err) + } + } + history := func() string { + t.Helper() + var snapshot string + if err := db.QueryRowContext(ctx, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM sessions s").Scan(&snapshot); err != nil { + t.Fatal(err) + } + return snapshot + } + before := history() + if _, err := provider.UpTo(ctx, 20); err != nil { + t.Fatal(err) + } + if after := history(); after != before { + t.Fatal("migration changed historical Sessions") + } + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM environments").Scan(&count); err != nil || count != 0 { + t.Fatal("manufactured historical ownership", count, err) + } + if _, err := provider.DownTo(ctx, 19); err != nil { + t.Fatal("empty downgrade failed", err) + } + if _, err := provider.UpTo(ctx, 20); err != nil { + t.Fatal(err) + } + poolConfig := pool.Config() + poolConfig.ConnConfig = cfg + migrated, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + t.Fatal(err) + } + t.Cleanup(migrated.Close) + s := New(migrated) + + sessionID, environmentID := uuid.NewString(), uuid.NewString() + tx, err := migrated.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + input := environmentInput("new", "self_hosted", "/workspace") + if _, err := tx.Exec(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) + VALUES ($1,$2,'codex','new','new',$3)`, sessionID, tenant, input.Configuration); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, "INSERT INTO environments(id,session_id) VALUES ($1,$2)", environmentID, sessionID); err != nil { + t.Fatal(err) + } + downgradeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + outcome := make(chan error, 1) + go func() { _, err := provider.DownTo(downgradeCtx, 19); outcome <- err }() + // The uncommitted creator must block the downgrade before its emptiness decision. + for { + var blocked bool + err := pool.QueryRow(downgradeCtx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks WHERE relation=$1::regclass + AND mode='AccessExclusiveLock' AND NOT granted)`, quoted+".environments").Scan(&blocked) + if err != nil { + t.Fatal(err) + } + if blocked { + break + } + select { + case err := <-outcome: + t.Fatal("downgrade did not wait for creator", err) + case <-downgradeCtx.Done(): + t.Fatal("downgrade lock was not observed") + case <-time.After(5 * time.Millisecond): + } + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + if err := <-outcome; err == nil || !strings.Contains(err.Error(), "Cannot remove durable Environment identities") { + t.Fatal("concurrent downgrade discarded identity", err) + } + retained, err := s.GetEnvironment(ctx, tenant, environmentID) + if err != nil || retained.ID != environmentID || retained.SessionID != sessionID { + t.Fatal("downgrade destroyed ownership", retained, err) + } +} diff --git a/services/agents-api/internal/store/environments_test.go b/services/agents-api/internal/store/environments_test.go new file mode 100644 index 000000000..a19ac2f16 --- /dev/null +++ b/services/agents-api/internal/store/environments_test.go @@ -0,0 +1,279 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" +) + +func environmentInput(key, kind, directory string) CreateSessionInput { + configuration, _ := json.Marshal(map[string]any{ + "agent": map[string]string{"model": "fixture-model"}, + "environment": map[string]any{"type": kind, "workspace_directory": directory, "capability_directories": []string{}}, + }) + return CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: key, Configuration: configuration} +} + +func TestEnvironmentOwnershipPersistsAndStaysScoped(t *testing.T) { + for _, kind := range []string{"self_hosted", "openai_hosted"} { + t.Run(kind, func(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant, foreign := uuid.NewString(), uuid.NewString() + input := environmentInput("environment", kind, "/workspace") + session, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + first, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil || first.ID == "" || first.ID == session.ID || first.SessionID != session.ID || first.TenantID != tenant || first.Status != "pending" || first.CreatedAt.IsZero() { + t.Fatal(first, err) + } + got, err := s.GetEnvironment(ctx, tenant, first.ID) + if err != nil || !reflect.DeepEqual(got, first) { + t.Fatal(got, err) + } + for _, lookup := range []func() error{ + func() error { _, err := s.GetEnvironment(ctx, foreign, first.ID); return err }, + func() error { _, err := s.GetSessionEnvironment(ctx, foreign, session.ID); return err }, + } { + if err := lookup(); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign access", err) + } + } + other, err := s.CreateSession(ctx, foreign, input) + if err != nil { + t.Fatal(err) + } + otherEnvironment, err := s.GetSessionEnvironment(ctx, foreign, other.ID) + if err != nil || otherEnvironment.ID == first.ID { + t.Fatal(otherEnvironment, err) + } + changed := environmentInput(input.IdempotencyKey, kind, "/changed") + if _, err := s.CreateSession(ctx, tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed configuration accepted", err) + } + if _, err := s.UpdateSessionMetadata(ctx, tenant, session.ID, map[string]string{"updated": "yes"}); err != nil { + t.Fatal(err) + } + pool.Close() + restarted, _ := testStore(t) + retry, err := restarted.CreateSession(ctx, tenant, input) + if err != nil || retry.ID != session.ID || retry.Metadata["updated"] != "yes" { + t.Fatal(retry, err) + } + retained, err := restarted.GetSessionEnvironment(ctx, tenant, retry.ID) + if err != nil || !reflect.DeepEqual(retained, first) { + t.Fatal(retained, err) + } + }) + } +} + +func TestEnvironmentCreationWinnerOwnsSnapshotAndIdentity(t *testing.T) { + s, pool := testStore(t) + other, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + intent := json.RawMessage(`{"request":"resolved-template"}`) + const count = 8 + type result struct { + creation SessionCreation + environment Environment + } + results := make(chan result, count) + var wg sync.WaitGroup + for i := range count { + wg.Add(1) + go func() { + defer wg.Done() + st := s + if i%2 != 0 { + st = other + } + input := environmentInput("winner", "openai_hosted", fmt.Sprintf("/workspace/%d", i)) + input.CreationRequest = intent + input.InitialInputs = []Input{messageInput("initial")} + creation, err := st.CreateSessionStream(ctx, tenant, input) + if err != nil { + t.Error(err) + return + } + environment, err := st.GetSessionEnvironment(ctx, tenant, creation.Session.ID) + if err != nil { + t.Error(err) + return + } + results <- result{creation, environment} + }() + } + wg.Wait() + close(results) + var first result + created, received := 0, 0 + for got := range results { + received++ + if first.environment.ID == "" { + first = got + } + if got.creation.Created { + created++ + } + if got.creation.Session.ID != first.creation.Session.ID || !reflect.DeepEqual(got.environment, first.environment) { + t.Fatal("concurrent retry changed ownership", got) + } + var snapshot struct { + Environment json.RawMessage `json:"environment"` + } + if err := json.Unmarshal(got.creation.Session.Configuration, &snapshot); err != nil { + t.Fatal(err) + } + canonical, err := canonicalJSONObject(snapshot.Environment) + if err != nil || string(canonical) != string(got.environment.Configuration) { + t.Fatal("configuration diverged", err) + } + } + if received != count || created != 1 { + t.Fatal("creation winners", received, created) + } + var associations, reservations int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments WHERE session_id=$1", first.creation.Session.ID).Scan(&associations); err != nil || associations != 1 { + t.Fatal(associations, err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1 AND is_initial", first.creation.Session.ID).Scan(&reservations); err != nil || reservations != 1 { + t.Fatal(reservations, err) + } + environmentInputHistory(t, pool, first.creation.Session.ID, 0, 0) + events, err := s.ListSessionEvents(ctx, tenant, first.creation.Session.ID, 0) + if err != nil { + t.Fatal(err) + } + pool.Close() + restarted, _ := testStore(t) + retryInput := environmentInput("winner", "openai_hosted", "/changed-resolution") + retryInput.CreationRequest = intent + retryInput.InitialInputs = []Input{messageInput("initial")} + retry, err := restarted.CreateSessionStream(ctx, tenant, retryInput) + if err != nil || retry.Created || retry.Session.ID != first.creation.Session.ID { + t.Fatal(retry, err) + } + found, err := restarted.FindSessionCreation(ctx, tenant, "winner", intent, FixtureCreator()) + if err != nil || found.Created || found.Session.ID != first.creation.Session.ID { + t.Fatal(found, err) + } + environment, err := restarted.GetSessionEnvironment(ctx, tenant, found.Session.ID) + if err != nil || !reflect.DeepEqual(environment, first.environment) { + t.Fatal(environment, err) + } + after, err := restarted.ListSessionEvents(ctx, tenant, found.Session.ID, 0) + if err != nil || !reflect.DeepEqual(events, after) { + t.Fatal("retry emitted work", err) + } +} + +func TestEnvironmentCreationFailureRollsBackAllResources(t *testing.T) { + for _, phase := range []string{"environment", "input", "activity"} { + t.Run(phase, func(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant, marker := uuid.NewString(), uuid.NewString() + constraint := "environment_failure_" + strings.ReplaceAll(marker, "-", "") + table, expression := "environments", "status <> 'pending'" + if phase == "input" { + table, expression = "environment_input_reservations", "NOT (batch @> '[{\"payload\":{\"text\":\""+marker+"\"}}]'::jsonb)" + } + if phase == "activity" { + table, expression = "session_events", "NOT (payload ? 'environment_input_activity')" + } + var before int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments").Scan(&before); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, "ALTER TABLE "+table+" ADD CONSTRAINT "+constraint+" CHECK ("+expression+") NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "ALTER TABLE "+table+" DROP CONSTRAINT IF EXISTS "+constraint) }) + input := environmentInput("rollback", "self_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("first"), messageInput(marker)} + if got, err := s.CreateSession(ctx, tenant, input); err == nil || got.ID != "" { + t.Fatal("partial creation succeeded", got, err) + } + var sessions, after int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&sessions); err != nil || sessions != 0 { + t.Fatal("partial Session survived", sessions, err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments").Scan(&after); err != nil || after != before { + t.Fatal("partial Environment survived", after, err) + } + if _, err := pool.Exec(ctx, "ALTER TABLE "+table+" DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + session, err := s.CreateSession(ctx, tenant, input) + if err != nil || session.LastTurn != nil || session.EnvironmentInputActivity == nil || session.EnvironmentInputActivity.Status != "requires_action" { + t.Fatal("retry remained reserved", session, err) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + if environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID); err != nil || environment.ID == "" { + t.Fatal(environment, err) + } + }) + } +} + +func TestEnvironmentDeletionHidesWithoutDestroyingOwnership(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := environmentInput("deleted", "self_hosted", "/workspace") + session, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetEnvironment(ctx, tenant, environment.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.GetSessionEnvironment(ctx, tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("deleted retry resurrected ownership", err) + } + var retained int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments WHERE id=$1 AND session_id=$2", environment.ID, session.ID).Scan(&retained); err != nil || retained != 1 { + t.Fatal(retained, err) + } +} + +func TestEnvironmentAbsentForNoneAndLegacySnapshots(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + for i, configuration := range []json.RawMessage{nil, json.RawMessage(`{}`), json.RawMessage(`{"environment":{"type":"none"}}`)} { + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: fmt.Sprintf("none-%d", i), Configuration: configuration} + session, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + if _, err := s.GetSessionEnvironment(ctx, tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("unexpected Environment", err) + } + retry, err := s.CreateSession(ctx, tenant, input) + if err != nil || retry.ID != session.ID { + t.Fatal(retry, err) + } + } +} diff --git a/services/agents-api/internal/store/execution_events_test.go b/services/agents-api/internal/store/execution_events_test.go new file mode 100644 index 000000000..ba1404bb0 --- /dev/null +++ b/services/agents-api/internal/store/execution_events_test.go @@ -0,0 +1,104 @@ +package store_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("start", "Stream then cancel") + result := h.run(ctx, input.TurnID) + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "已输出", Sequence: 1}) + h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "in_progress"}}) + h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "after", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "completed"}}) + h.write(input.TurnID, proto.TypeUsage, proto.UsagePayload{Usage: proto.Usage{InputTokens: 11, OutputTokens: 2}}) + reopened, pool := store.NewTestStore(t) + defer pool.Close() + deadline := time.Now().Add(5 * time.Second) + for { + events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(events) == 4 { + break + } + if time.Now().After(deadline) { + t.Fatal("events were not persisted during execution") + } + time.Sleep(20 * time.Millisecond) + } + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "stop"); err != nil { + t.Fatal(err) + } + cancelEnv := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + _ = cancelEnv.DecodePayload(&cancel) + for i := range 30 { + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "片段", Sequence: uint64(i + 2)}) + } + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + h.finished(result, store.TurnCancelled) + events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil { + t.Fatal(err) + } + var text strings.Builder + var kinds []string + for i, event := range events { + if event.Ordinal != int32(i+1) { + t.Fatal("non-contiguous events") + } + kinds = append(kinds, event.Kind) + if event.Kind == proto.TypeDelta { + var delta proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &delta) + text.WriteString(delta.Delta) + } + } + if text.String() != "已输出"+strings.Repeat("片段", 30) { + t.Fatalf("partial text lost: %q", text.String()) + } + if len(events) != 36 || kinds[34] != "cancel_receipt" || kinds[35] != "execution_cancelled" { + t.Fatalf("event order=%v", kinds) + } +} + +func TestExecutionDoesNotCompleteAfterEventPersistenceFailure(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("start", "Output beyond storage budget") + result := h.run(ctx, input.TurnID) + h.read(proto.TypePromptRequest) + _, pool := store.NewTestStore(t) + defer pool.Close() + if _, err := pool.Exec(ctx, "UPDATE turns SET event_bytes=33554432 WHERE id=$1", input.TurnID); err != nil { + t.Fatal(err) + } + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "cannot be stored", Sequence: 1}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "Do not report success", Usage: proto.Usage{InputTokens: 13}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "failed-native"}}) + turn := h.finished(result, store.TurnFailed) + var outcome execution.Result + _ = json.Unmarshal(turn.Outcome, &outcome) + if outcome.ErrorCode != "event_persistence_failed" { + t.Fatal(outcome.ErrorCode) + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "failed-native" || outcome.Done.Usage.InputTokens != 13 { + t.Fatalf("terminal failure lost native continuity or usage: %+v %+v %v", bound, outcome, err) + } + events, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 1 || events[0].Kind != "execution_failed" { + t.Fatalf("events=%+v err=%v", events, err) + } +} diff --git a/services/agents-api/internal/store/execution_lease.go b/services/agents-api/internal/store/execution_lease.go new file mode 100644 index 000000000..0dd91c559 --- /dev/null +++ b/services/agents-api/internal/store/execution_lease.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +const executionTransactionTimeout = 5 * time.Second + +// ExecutionLease owns the connection used for execution writes, not just election. +// Its gate serializes pgx operations; no daemon or model work holds this gate. +type ExecutionLease struct { + conn *pgxpool.Conn + gate chan struct{} + writer Store + cleanupDone <-chan struct{} +} + +// AcquireExecutionLease enforces the gateway's single-service ownership per database. +func (s *Store) AcquireExecutionLease(ctx context.Context) (*ExecutionLease, error) { + conn, err := s.pool.Acquire(ctx) + if err != nil { + return nil, err + } + acquired, err := sqlc.New(conn).TryExecutionLease(ctx) + if err != nil || !acquired { + _ = conn.Hijack().Close(context.Background()) + if err != nil { + return nil, err + } + return nil, errors.New("another execution service owns this database") + } + lease := &ExecutionLease{conn: conn, gate: make(chan struct{}, 1), writer: *s} + lease.writer.executionLease = lease + return lease, nil +} + +// Store returns the execution writer view. Session transactions use the leased +// connection; reads retain the pool. Keep public admission on the original Store. +// Losing or closing the lease never falls back to a pooled writer connection. +func (l *ExecutionLease) Store() *Store { return &l.writer } + +// CheckExecutionOwnership validates the current writer before external preparation. +func (s *Store) CheckExecutionOwnership(ctx context.Context) error { + if s.executionLease == nil { + return errors.New("execution operation requires a leased Store") + } + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + return s.executionLease.Ping(ctx) +} + +func (l *ExecutionLease) lock(ctx context.Context) error { + select { + case l.gate <- struct{}{}: + if err := ctx.Err(); err != nil { + l.unlock() + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (l *ExecutionLease) unlock() { <-l.gate } + +func (l *ExecutionLease) withConn(ctx context.Context, apply func(*pgxpool.Conn) error) error { + if err := l.lock(ctx); err != nil { + return err + } + defer l.unlock() + if l.conn == nil { + return errors.New("execution lease is closed") + } + return apply(l.conn) +} + +func (l *ExecutionLease) transaction(ctx context.Context, apply func(pgx.Tx) error) error { + return l.withConn(ctx, func(conn *pgxpool.Conn) error { + return pgx.BeginFunc(ctx, conn, apply) + }) +} + +func (l *ExecutionLease) Ping(ctx context.Context) error { + return l.withConn(ctx, func(conn *pgxpool.Conn) error { return conn.Ping(ctx) }) +} + +func (l *ExecutionLease) Close(ctx context.Context) error { + if err := l.lock(ctx); err != nil { + return err + } + defer l.unlock() + if l.conn != nil { + conn := l.conn.Hijack() + l.conn = nil + l.cleanupDone = conn.PgConn().CleanupDone() + if err := conn.Close(ctx); err != nil { + return err + } + } + if l.cleanupDone == nil { + return nil + } + // A cancelled pgx connection can be unusable before its asynchronous cleanup ends. + // Retain the channel so a later Close can continue waiting after this deadline. + select { + case <-l.cleanupDone: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/services/agents-api/internal/store/execution_lease_cleanup_test.go b/services/agents-api/internal/store/execution_lease_cleanup_test.go new file mode 100644 index 000000000..842745437 --- /dev/null +++ b/services/agents-api/internal/store/execution_lease_cleanup_test.go @@ -0,0 +1,128 @@ +package store + +import ( + "context" + "errors" + "net" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestExecutionLeaseCloseWaitsForCancelledConnectionCleanup(t *testing.T) { + dsn := os.Getenv("PARSAR_AGENTS_API_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("dedicated PostgreSQL required") + } + cfg, err := testDatabaseConfig(dsn) + if err != nil { + t.Fatal(err) + } + observer, err := pgxpool.NewWithConfig(t.Context(), cfg.Copy()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(observer.Close) + var armed atomic.Bool + blocked, release := make(chan struct{}), make(chan struct{}) + var signal, unblocked sync.Once + unblock := func() { unblocked.Do(func() { close(release) }) } + dial := cfg.ConnConfig.DialFunc + cfg.ConnConfig.DialFunc = func(ctx context.Context, network, address string) (net.Conn, error) { + if armed.Load() { + signal.Do(func() { close(blocked) }) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return dial(ctx, network, address) + } + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + lease, err := New(pool).AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + unblock() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := lease.Close(ctx); err != nil { + t.Error(err) + } + }) + connection := lease.conn.Conn().PgConn() + armed.Store(true) + queryCtx, cancelQuery := context.WithCancel(t.Context()) + defer cancelQuery() + queryDone := make(chan error, 1) + go func() { + queryDone <- lease.withConn(queryCtx, func(conn *pgxpool.Conn) error { + _, err := conn.Exec(queryCtx, "SELECT pg_sleep(10)") + return err + }) + }() + deadline := time.Now().Add(2 * time.Second) + for { + var sleeping bool + err := observer.QueryRow(t.Context(), "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid=$1 AND wait_event='PgSleep')", connection.PID()).Scan(&sleeping) + if err != nil { + t.Fatal(err) + } + if sleeping { + break + } + if time.Now().After(deadline) { + t.Fatal("owner query did not reach PostgreSQL") + } + time.Sleep(5 * time.Millisecond) + } + cancelQuery() + if err := <-queryDone; err == nil || !connection.IsClosed() { + t.Fatal("cancelled query did not close the driver connection", err) + } + select { + case <-blocked: + case <-time.After(2 * time.Second): + t.Fatal("asynchronous cancellation did not reach the dial gate") + } + closeCtx, cancelClose := context.WithTimeout(t.Context(), 25*time.Millisecond) + err = lease.Close(closeCtx) + cancelClose() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("Close returned before blocked cleanup or ignored its deadline", err) + } + if err := lease.Store().CheckExecutionOwnership(t.Context()); err == nil { + t.Fatal("timed-out cleanup restored writer authority") + } + closed := make(chan error, 1) + go func() { closed <- lease.Close(t.Context()) }() + select { + case err := <-closed: + t.Fatal("repeated Close forgot pending cleanup", err) + case <-time.After(25 * time.Millisecond): + } + unblock() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("Close did not finish after cleanup resumed") + } + select { + case <-connection.CleanupDone(): + default: + t.Fatal("successful Close left driver cleanup pending") + } +} diff --git a/services/agents-api/internal/store/execution_lease_test.go b/services/agents-api/internal/store/execution_lease_test.go new file mode 100644 index 000000000..5291b7392 --- /dev/null +++ b/services/agents-api/internal/store/execution_lease_test.go @@ -0,0 +1,207 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/google/uuid" +) + +func executionLease(t *testing.T, s *Store) *ExecutionLease { + t.Helper() + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + return lease +} + +func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { + s, pool := testStore(t) + old := executionLease(t, s) + writer := old.Store() + tenant, active := newTurnSession(t, s) + input := submitMessage(t, s, tenant, active.ID, "active") + transition(t, writer, tenant, active.ID, input.TurnID, TurnQueued, TurnInProgress) + host, err := s.CreateDevice(t.Context(), tenant, "owner test", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = writer.BindSessionDevice(t.Context(), tenant, active.ID, host.ID); err != nil { + t.Fatal(err) + } + queued, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "queued"}) + if err != nil { + t.Fatal(err) + } + pending := submitMessage(t, s, tenant, queued.ID, "queued") + waiting, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "waiting"}) + if err != nil { + t.Fatal(err) + } + waitInput := submitMessage(t, s, tenant, waiting.ID, "waiting") + transition(t, writer, tenant, waiting.ID, waitInput.TurnID, TurnQueued, TurnInProgress) + call := functionCallFixture("saved") + if err = writer.RecordFunctionCall(t.Context(), tenant, waiting.ID, waitInput.TurnID, call); err != nil { + t.Fatal(err) + } + if err = s.SubmitFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID, json.RawMessage(`{"success":true,"output":"saved"}`)); err != nil { + t.Fatal(err) + } + before, err := s.GetSession(t.Context(), tenant, active.ID) + if err != nil { + t.Fatal(err) + } + cursor, err := s.SessionEventCursor(t.Context(), tenant, active.ID) + if err != nil { + t.Fatal(err) + } + // Kill only this test's owner connection. Do not notify the old writer by Ping. + var killed bool + if err = pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", old.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + successor := executionLease(t, s) + mustReject := func(name string, err error) { + t.Helper() + if err == nil { + t.Fatalf("stale %s committed while successor owned lease", name) + } + } + mustReject("binding", writer.BindSessionDevice(t.Context(), tenant, queued.ID, host.ID)) + _, err = writer.TransitionTurn(t.Context(), tenant, queued.ID, pending.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + mustReject("claim", err) + mustReject("journal", writer.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"stale"}`)}})) + mustReject("callback", writer.RecordFunctionCall(t.Context(), tenant, active.ID, input.TurnID, functionCallFixture("late"))) + mustReject("receipt", writer.ConfirmFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID)) + _, err = writer.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, TurnCompleted, json.RawMessage(`{"done":{"content":"stale"}}`), "stale-native", input.Sequence) + mustReject("completion", err) + _, err = writer.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + mustReject("reconciliation", err) + after, err := s.GetSession(t.Context(), tenant, active.ID) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("stale state persisted", after, err) + } + afterCursor, err := s.SessionEventCursor(t.Context(), tenant, active.ID) + if err != nil || afterCursor != cursor { + t.Fatal("stale events published", afterCursor, err) + } + events, err := s.ListTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 0 { + t.Fatal("stale journal persisted", events, err) + } + saved, err := s.GetFunctionCall(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID) + if err != nil || saved.Applied { + t.Fatal("stale receipt persisted", saved, err) + } + if _, err = s.GetSessionDevice(t.Context(), tenant, queued.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("stale binding persisted", err) + } + queuedTurn, err := s.GetTurn(t.Context(), tenant, queued.ID, pending.TurnID) + if err != nil || queuedTurn.Status != TurnQueued { + t.Fatal("queued work changed", queuedTurn, err) + } + // Public admission remains usable with a dead owner connection. + submitMessage(t, s, tenant, queued.ID, "additional") + if err = successor.Ping(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = successor.Store().CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, TurnCompleted, json.RawMessage(`{"done":{"content":"accepted"}}`), "successor-native", input.Sequence); err != nil { + t.Fatal(err) + } + bound, err := s.GetSessionExecutionBinding(t.Context(), tenant, active.ID) + if err != nil || bound.NativeSessionID != "successor-native" { + t.Fatal(bound, err) + } + _, err = successor.Store().TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + if !errors.Is(err, ErrTurnConflict) { + t.Fatal("terminal CAS changed", err) + } + if err = successor.Close(t.Context()); err != nil { + t.Fatal(err) + } + mustReject("closed writer", successor.Store().BindSessionDevice(t.Context(), tenant, queued.ID, host.ID)) +} + +func TestExecutionLeaseSerializesWritesAndPings(t *testing.T) { + s, _ := testStore(t) + lease := executionLease(t, s) + type work struct{ tenant, session, turn string } + tasks := make([]work, 4) + for i := range tasks { + tenant, session := newTurnSession(t, s) + tasks[i] = work{tenant, session.ID, submitMessage(t, s, tenant, session.ID, "start").TurnID} + } + var group sync.WaitGroup + results := make(chan error, len(tasks)*2) + for _, task := range tasks { + group.Go(func() { + _, err := lease.Store().TransitionTurn(t.Context(), task.tenant, task.session, task.turn, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + if err == nil { + err = lease.Store().AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"accepted"}`)}}) + } + results <- err + }) + group.Go(func() { results <- lease.Ping(t.Context()) }) + } + group.Wait() + close(results) + for err := range results { + if err != nil { + t.Fatal(err) + } + } + // While the owner connection is in use, public admission on another Session + // does not need that connection. A waiting owner operation can be cancelled. + if err := lease.lock(t.Context()); err != nil { + t.Fatal(err) + } + defer lease.unlock() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + task := tasks[0] + if _, err := s.SubmitMessage(ctx, task.tenant, task.session, "public", json.RawMessage(`{"text":"additional"}`)); err != nil { + t.Fatal("public admission used owner gate", err) + } + cancelled, stop := context.WithCancel(t.Context()) + stop() + if err := lease.Ping(cancelled); !errors.Is(err, context.Canceled) { + t.Fatal("gate wait ignored cancellation", err) + } +} + +func TestExecutionLeaseBoundsSessionLockWait(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + tenant, session := newTurnSession(t, s) + input := submitMessage(t, s, tenant, session.ID, "start") + blocker, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer blocker.Rollback(context.Background()) + if _, err = blocker.Exec(t.Context(), "SELECT id FROM sessions WHERE id=$1 FOR UPDATE", session.ID); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second) + defer cancel() + start := time.Now() + _, err = lease.Store().TransitionTurn(ctx, tenant, session.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) >= 7*time.Second { + t.Fatal("owner transaction did not enforce its shorter deadline", err) + } + if err = blocker.Rollback(t.Context()); err != nil { + t.Fatal(err) + } + turn, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) + if err != nil || turn.Status != TurnQueued { + t.Fatal("timed-out claim changed queued work", turn, err) + } +} diff --git a/services/agents-api/internal/store/execution_messages_test.go b/services/agents-api/internal/store/execution_messages_test.go new file mode 100644 index 000000000..c5d622d49 --- /dev/null +++ b/services/agents-api/internal/store/execution_messages_test.go @@ -0,0 +1,83 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + first := h.message("legacy", "legacy observation policy") + result := h.run(ctx, first.TurnID) + var request proto.PromptRequestPayload + env := h.read(proto.TypePromptRequest) + _ = env.DecodePayload(&request) + if request.ObserveMessages { + t.Fatal("unadvertised observation capability requested") + } + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) + h.finished(result, store.TurnCompleted) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, MessageItems: true, NativeSessionRecovery: true}}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + t.Fatal(err) + } + info, _, _ := peer.AgentKindStatus("codex") + if info.Capabilities.MessageItems { + break + } + if time.Now().After(deadline) { + t.Fatal("message capability lost in gateway") + } + time.Sleep(10 * time.Millisecond) + } + input := h.message("observed", "stream separate messages") + result = h.run(ctx, input.TurnID) + env = h.read(proto.TypePromptRequest) + _ = env.DecodePayload(&request) + if !request.ObserveMessages { + t.Fatal("advertised capability was not requested") + } + text := "complete" + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "in_progress", Phase: "commentary"}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "a", Delta: text, Sequence: 1}) + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "completed", Phase: "commentary", Text: &text}) + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "b", Status: "in_progress", Phase: "final_answer"}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "b", Delta: "partial", Sequence: 2}) + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + env = h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + _ = env.DecodePayload(&cancel) + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + h.finished(result, store.TurnCancelled) + reopened, pool := store.NewTestStore(t) + defer pool.Close() + events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(events) != 7 { + t.Fatalf("lost message observations: %+v", events) + } + var complete proto.OutputMessagePayload + if err = json.Unmarshal(events[2].Payload, &complete); err != nil || complete.ID != "a" || complete.Text == nil || *complete.Text != "complete" || complete.Phase != "commentary" { + t.Fatalf("completed snapshot: %+v %v", complete, err) + } + var delta proto.DeltaPayload + if err = json.Unmarshal(events[4].Payload, &delta); err != nil || delta.ItemID != "b" || delta.Delta != "partial" { + t.Fatalf("cancelled partial identity lost: %+v %v", delta, err) + } + if events[5].Kind != "cancel_receipt" || events[6].Kind != "execution_cancelled" { + t.Fatal("terminal ordering changed") + } +} diff --git a/services/agents-api/internal/store/execution_tools_test.go b/services/agents-api/internal/store/execution_tools_test.go new file mode 100644 index 000000000..607fb324a --- /dev/null +++ b/services/agents-api/internal/store/execution_tools_test.go @@ -0,0 +1,86 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("observed", "run tools") + result := h.run(ctx, input.TurnID) + env := h.read(proto.TypePromptRequest) + var request proto.PromptRequestPayload + _ = env.DecodePayload(&request) + if !request.ObserveToolObservations || request.ObserveTools || request.ObserveMessages { + t.Fatal("advertised capability was not requested") + } + start := json.RawMessage(`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"key":"value"},"status":"in_progress","output":null,"error":null}`) + complete := json.RawMessage(`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"key":"value"},"status":"completed","output":{"content":[{"type":"text","text":"answer"}],"structuredContent":{"version":9007199254740993}},"error":null}`) + partial := json.RawMessage(`{"kind":"command","command":"long-running","status":"in_progress"}`) + for i, raw := range []json.RawMessage{start, complete, partial} { + id, stage := "a", "before" + if i == 1 { + stage = "after" + } + if i == 2 { + id = "b" + } + var observation proto.ToolObservation + if err := json.Unmarshal(raw, &observation); err != nil { + t.Fatal(err) + } + h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: id, Stage: stage, Observation: &observation}) + } + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + env = h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + _ = env.DecodePayload(&cancel) + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + h.finished(result, store.TurnCancelled) + reopened, pool := store.NewTestStore(t) + defer pool.Close() + events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(events) != 5 { + t.Fatalf("lost tool observations: %+v", events) + } + for i, expected := range []json.RawMessage{start, complete, partial} { + var tool proto.ToolCallPayload + if err = json.Unmarshal(events[i].Payload, &tool); err != nil { + t.Fatal(err) + } + // PostgreSQL canonicalizes object order; compare JSON values without floating-point coercion. + var actualValue, expectedValue any + decode := func(raw []byte, target *any) { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if e := d.Decode(target); e != nil { + t.Fatal(e) + } + } + actual, _ := json.Marshal(tool.Observation) + decode(actual, &actualValue) + decode(expected, &expectedValue) + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Fatalf("tool snapshot changed: %s", actual) + } + if i == 2 && tool.Stage != "before" { + t.Fatal("unfinished call acquired a completion") + } + } + if events[3].Kind != "cancel_receipt" || events[4].Kind != "execution_cancelled" { + t.Fatal("terminal ordering changed") + } +} diff --git a/services/agents-api/internal/store/executor_credential_target.go b/services/agents-api/internal/store/executor_credential_target.go new file mode 100644 index 000000000..c574fb774 --- /dev/null +++ b/services/agents-api/internal/store/executor_credential_target.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func executorCredentialIdentity(principal identity.Principal, keyID string) (pgtype.UUID, pgtype.UUID, error) { + if err := principal.Validate(); err != nil { + return pgtype.UUID{}, pgtype.UUID{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + tenant, err := parseID(principal.TenantID) + if err != nil { + return pgtype.UUID{}, pgtype.UUID{}, err + } + id, err := parseID(keyID) + return tenant, id, err +} + +func newExecutorSecret() (string, string, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return "", "", err + } + token := base64.RawURLEncoding.EncodeToString(secret) + hash := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(hash[:]), nil +} + +func issuedExecutorCredential(id, environment pgtype.UUID, token string) IssuedExecutorCredential { + result := IssuedExecutorCredential{KeyID: uuid.UUID(id.Bytes).String(), Token: token} + if environment.Valid { + result.EnvironmentID = uuid.UUID(environment.Bytes).String() + } + return result +} + +func (s *Store) withExecutorCredentialTarget(ctx context.Context, principal identity.Principal, environment pgtype.UUID, apply func(context.Context, *sqlc.Queries) error) error { + if !environment.Valid { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { return apply(ctx, s.queries.WithTx(tx)) }) + } + owned, err := s.GetEnvironment(ctx, principal.TenantID, uuid.UUID(environment.Bytes).String()) + if err != nil { + return err + } + tenant, _ := parseID(principal.TenantID) + // The Session lock orders exact-target issuance and rotation against deletion. + return s.withPublicSession(ctx, principal.TenantID, owned.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + row, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: tenant, ID: session}) + if err != nil { + return err + } + if !row.CreatorKind.Valid || !row.CreatorID.Valid || row.CreatorKind.String != principal.SubjectKind || row.CreatorID.String != principal.SubjectID { + return ErrNotFound + } + return apply(ctx, q) + }) +} diff --git a/services/agents-api/internal/store/executor_launcher_helpers_test.go b/services/agents-api/internal/store/executor_launcher_helpers_test.go new file mode 100644 index 000000000..fc588761b --- /dev/null +++ b/services/agents-api/internal/store/executor_launcher_helpers_test.go @@ -0,0 +1,174 @@ +package store_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +const launcherTestHost = "agents-executor.test" + +func launcherTestCertificate(t *testing.T, root string) tls.Certificate { + t.Helper() + pub, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: launcherTestHost}, + DNSNames: []string{launcherTestHost}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, pub, key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil { + t.Fatal(err) + } + leafPublic, leafKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: launcherTestHost}, + DNSNames: []string{launcherTestHost}, NotBefore: template.NotBefore, NotAfter: template.NotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leaf, template, leafPublic, key) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{leafDER, der}, PrivateKey: leafKey} +} + +func launcherContainerArgs(t *testing.T, binary string) ([]string, string) { + t.Helper() + if filepath.Base(binary) != "codex" || filepath.Base(filepath.Dir(binary)) != "bin" { + t.Fatal("fixture requires the native Codex platform installation's bin/codex") + } + name := "parsar-executor-client-" + uuid.NewString() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = exec.CommandContext(ctx, "docker", "rm", "-f", name).Run() + }) + return []string{"run", "--name", name, "--network", "host", + "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined", + "--add-host", launcherTestHost + ":127.0.0.1", "--add-host", "wrong." + launcherTestHost + ":127.0.0.1", + "--mount", "type=bind,src=" + filepath.Dir(filepath.Dir(binary)) + ",dst=/opt/codex,readonly", + "--env", "NO_PROXY=*", "--env", "no_proxy=*", + "--env", "HTTP_PROXY=", "--env", "HTTPS_PROXY=", "--env", "ALL_PROXY=", + "--env", "http_proxy=", "--env", "https_proxy=", "--env", "all_proxy="}, name +} + +func startLauncherContainer(t *testing.T, ctx context.Context, root, image, binary, launcher, remote, environment string, trusted bool) string { + t.Helper() + args, name := launcherContainerArgs(t, binary) + args = append(args, "--detach", "--workdir", root, + "--mount", "type=bind,src="+root+",dst="+root, + "--env", "HOME="+filepath.Join(root, "executor"), + "--mount", "type=bind,src="+launcher+",dst=/usr/local/bin/agents-api-codex-executor,readonly") + if trusted { + args = append(args, "--env", "SSL_CERT_FILE="+filepath.Join(root, "ca.pem")) + } + args = append(args, "--entrypoint", "/usr/local/bin/agents-api-codex-executor", image, + "--remote", remote, "--environment-id", environment, "--credentials", filepath.Join(root, "executor", "credential.json"), + "--codex-bin", "/opt/codex/bin/codex") + if err := exec.CommandContext(ctx, "docker", args...).Run(); err != nil { + t.Fatal("launcher container failed", err) + } + return name +} + +func stopLauncherContainer(t *testing.T, ctx context.Context, name string, success bool) { + t.Helper() + if err := exec.CommandContext(ctx, "docker", "stop", "--time", "20", name).Run(); err != nil { + t.Fatal(err) + } + exit, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.ExitCode}}", name).Output() + code := strings.TrimSpace(string(exit)) + if err != nil || (code != "0" && (success || code != "1")) { + t.Fatal("launcher did not stop gracefully", err) + } +} + +func startLauncherProbe(t *testing.T, ctx context.Context, root, image, binary, probe, remote, environment, token, phase string) *relayProcess { + t.Helper() + args, _ := launcherContainerArgs(t, binary) + args = append(args, "--rm", "--workdir", root, + "--mount", "type=bind,src="+root+",dst="+root, + "--env", "HOME="+filepath.Join(root, "harness"), + "--env", "SSL_CERT_FILE="+filepath.Join(root, "ca.pem"), + "--env", "PARSAR_NATIVE_ENV_PROOF="+root, + "--env", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL="+remote, + "--env", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID="+environment, + "--env", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", + "--mount", "type=bind,src="+probe+",dst=/usr/local/bin/probe,readonly", + "--entrypoint", "/usr/local/bin/probe", image, phase) + return startRelayProcess(t, ctx, root, append(os.Environ(), "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN="+token), "docker", args...) +} + +func writeLauncherCredential(t *testing.T, path string, credential store.IssuedExecutorCredential) { + t.Helper() + data, err := json.Marshal(credential) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func startDaemonLauncherExecutor(t *testing.T, ctx context.Context, root, local, remote, binary, image, registryURL, environment string, credential store.IssuedExecutorCredential, launcher string) string { + t.Helper() + writeLauncherCredential(t, filepath.Join(root, "executor", "credential.json"), credential) + args, name := launcherContainerArgs(t, binary) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + logs, _ := exec.CommandContext(ctx, "docker", "logs", name).CombinedOutput() + text := string(logs) + if strings.Contains(text, credential.Token) { + t.Error("executor credential appeared in launcher diagnostics") + text = strings.ReplaceAll(text, credential.Token, "[redacted]") + } + _ = os.WriteFile(filepath.Join(root, "launcher.stderr"), []byte(text), 0600) + }) + args = append(args, "--detach", "--workdir", remote, + "--mount", "type=bind,src="+filepath.Join(root, "executor")+",dst="+filepath.Join(root, "executor"), + "--env", "HOME="+filepath.Join(root, "executor"), + "--mount", "type=bind,src="+launcher+",dst=/usr/local/bin/agents-api-codex-executor,readonly", + "--mount", "type=bind,src="+local+",dst="+remote, + "--entrypoint", "/usr/local/bin/agents-api-codex-executor", image, + "--remote", registryURL, "--environment-id", environment, "--credentials", filepath.Join(root, "executor", "credential.json"), + "--codex-bin", "/opt/codex/bin/codex") + if err := exec.CommandContext(ctx, "docker", args...).Run(); err != nil { + t.Fatal("daemon test launcher container failed", err) + } + for _, private := range []string{"harness", "parsar-daemon"} { + if err := exec.CommandContext(ctx, "docker", "exec", name, "test", "!", "-e", filepath.Join(root, private)).Run(); err != nil { + t.Fatal("private harness/daemon state is visible inside executor") + } + } + return name +} diff --git a/services/agents-api/internal/store/executor_launcher_test.go b/services/agents-api/internal/store/executor_launcher_test.go new file mode 100644 index 000000000..c1ccfc42b --- /dev/null +++ b/services/agents-api/internal/store/executor_launcher_test.go @@ -0,0 +1,157 @@ +package store_test + +import ( + "context" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeExecutorLauncherTLSAndHelpers(t *testing.T) { + launcher, binary, probe := os.Getenv("PARSAR_EXECUTOR_LAUNCHER"), os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_NATIVE_RELAY_PROBE") + proof, image := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + if launcher == "" || binary == "" || probe == "" || proof == "" || image == "" { + t.Skip("built launcher, native Codex installation/probe, private proof directory and pinned executor image required") + } + if !strings.HasPrefix(image, "sha256:") { + t.Fatal("pin the preloaded executor image") + } + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) + defer cancel() + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "launcher", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(proof, "launcher-tls-") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"executor", "harness", "workspace"} { + if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + writeLauncherCredential(t, filepath.Join(root, "executor", "credential.json"), credential) + server := httptest.NewUnstartedServer(nil) + _, port, err := net.SplitHostPort(server.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + remote := "https://" + launcherTestHost + ":" + port + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: remote}) + if err != nil { + t.Fatal(err) + } + harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer releaseHarness() + + var connections, requests atomic.Int64 + server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + connections.Add(1) + } + } + observation := &relayObservation{} + handler := registry.Handler() + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: r.URL.Path}, r) + }) + server.TLS = &tls.Config{Certificates: []tls.Certificate{launcherTestCertificate(t, root)}, MinVersion: tls.VersionTLS12} + server.StartTLS() + defer func() { registry.Close(); server.Close() }() + + for _, test := range []struct { + name, remote string + trusted bool + }{ + {"untrusted-ca", remote, false}, + {"wrong-hostname", strings.Replace(remote, launcherTestHost, "wrong."+launcherTestHost, 1), true}, + } { + t.Run(test.name, func(t *testing.T) { + before := connections.Load() + container := startLauncherContainer(t, ctx, root, image, binary, launcher, test.remote, environment.ID, test.trusted) + awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "TLS connection attempt", func() bool { return connections.Load() > before }) + stopLauncherContainer(t, ctx, container, false) + if requests.Load() != 0 { + t.Fatal("unverified TLS reached registry HTTP handling") + } + }) + } + container := startLauncherContainer(t, ctx, root, image, binary, launcher, remote, environment.ID, true) + awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "verified executor registration", func() bool { + connected, err := registry.Connected(ctx, tenant, environment.ID) + return err == nil && connected + }) + first := startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "first") + awaitDaemonRemoteCondition(t, ctx, 60*time.Second, "native relay recovery checkpoint", func() bool { + _, err := os.Stat(filepath.Join(root, "ready-to-disconnect")) + return err == nil + }) + observation.mu.Lock() + connection := observation.harness + observation.mu.Unlock() + if connection == nil { + t.Fatal("native harness socket missing") + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } + first.wait(t) + startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "fresh").wait(t) + startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "helpers").wait(t) + stopLauncherContainer(t, ctx, container, true) + logs, err := exec.CommandContext(ctx, "docker", "logs", container).CombinedOutput() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(logs), credential.Token) || strings.Contains(string(logs), harnessToken) { + t.Fatal("credential appeared in launcher logs") + } + if err := os.WriteFile(filepath.Join(root, "launcher.log"), logs, 0600); err != nil { + t.Fatal(err) + } + report := map[string]any{"tls_hostname_and_ca": true, "untrusted_ca_rejected": true, "wrong_hostname_rejected": true, + "native_recovery": true, "native_helpers": true, "graceful_launcher_exit": true, "model_calls": 0, + "limits": "Controlled test DNS and CA; not public DNS/certificate deployment, full Environment API, model execution or OS quiescence."} + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "acceptance.json"), data, 0600); err != nil { + t.Fatal(err) + } + t.Log("native launcher TLS/helper evidence", root) +} diff --git a/services/agents-api/internal/store/executor_principals_migration_test.go b/services/agents-api/internal/store/executor_principals_migration_test.go new file mode 100644 index 000000000..e5e681527 --- /dev/null +++ b/services/agents-api/internal/store/executor_principals_migration_test.go @@ -0,0 +1,128 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestExecutorPrincipalMigrationRetiresUnknownAuthority(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "executor_principal_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 25); err != nil { + t.Fatal(err) + } + tenant, session, environment := uuid.NewString(), uuid.NewString(), uuid.NewString() + input := environmentInput("legacy", "self_hosted", "/workspace") + if _, err := db.ExecContext(ctx, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) + VALUES ($1,$2,'codex','legacy','historical',$3)`, session, tenant, input.Configuration); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO environments(id,session_id) VALUES ($1,$2)", environment, session); err != nil { + t.Fatal(err) + } + _, digest, err := newExecutorSecret() + if err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO environment_executor_credentials(environment_id,token_sha256,issued_at) + VALUES ($1,$2,now()-interval '2 days')`, environment, digest); err != nil { + t.Fatal(err) + } + var oldSession, oldCredential, after string + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", session).Scan(&oldSession); err != nil { + t.Fatal(err) + } + if err := db.QueryRowContext(ctx, "SELECT (to_jsonb(c)-'revoked_at')::text FROM environment_executor_credentials c WHERE environment_id=$1", environment).Scan(&oldCredential); err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 26); err != nil { + t.Fatal(err) + } + if err := db.QueryRowContext(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", session).Scan(&after); err != nil || oldSession != after { + t.Fatal("migration changed Session", err) + } + if err := db.QueryRowContext(ctx, `SELECT (to_jsonb(c)-ARRAY['key_id','tenant_id','subject_kind','subject_id','created_at','revoked_at'])::text + FROM environment_executor_credentials c WHERE key_id=$1`, environment).Scan(&after); err != nil || oldCredential != after { + t.Fatal("migration changed legacy digest/restriction/issuance", err) + } + var retired bool + if err := db.QueryRowContext(ctx, `SELECT key_id=environment_id AND tenant_id IS NULL AND subject_kind IS NULL + AND subject_id IS NULL AND created_at IS NULL AND revoked_at IS NOT NULL FROM environment_executor_credentials WHERE key_id=$1`, environment).Scan(&retired); err != nil || !retired { + t.Fatal("migration inferred authority", err) + } + var mappings int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM execution_project_scopes").Scan(&mappings); err != nil || mappings != 0 { + t.Fatal("migration invented project", err) + } + if _, err := db.ExecContext(ctx, "UPDATE environment_executor_credentials SET revoked_at=NULL WHERE key_id=$1", environment); err == nil { + t.Fatal("unknown principal regained authority") + } + if _, err := provider.DownTo(ctx, 25); err != nil { + t.Fatal("legacy-only downgrade", err) + } + if err := db.QueryRowContext(ctx, "SELECT revoked_at IS NOT NULL FROM environment_executor_credentials WHERE environment_id=$1", environment).Scan(&retired); err != nil || !retired { + t.Fatal("downgrade undid revocation", err) + } + if _, err := provider.UpTo(ctx, 26); err != nil { + t.Fatal(err) + } + poolConfig := pool.Config() + poolConfig.ConnConfig = cfg + migrated, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + t.Fatal(err) + } + t.Cleanup(migrated.Close) + s := New(migrated) + p := FixtureExecutorPrincipal(t, s, tenant) + if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment, digest); !errors.Is(err, ErrNotFound) { + t.Fatal("legacy credential accepted", err) + } + if _, err := s.IssueExecutorCredential(ctx, p, environment, ""); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("legacy key ID claimed", err) + } + if _, err := s.RotateExecutorCredential(ctx, p, environment); !errors.Is(err, ErrNotFound) { + t.Fatal("legacy principal claimed", err) + } + if err := s.RevokeExecutorCredential(ctx, p, environment); !errors.Is(err, ErrNotFound) { + t.Fatal("legacy principal manufactured", err) + } + key, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 25); err == nil || !strings.Contains(err.Error(), "Cannot remove durable executor principal identities") { + t.Fatal("downgrade lost principal keys", err) + } + if _, err := s.RotateExecutorCredential(ctx, p, key.KeyID); err != nil { + t.Fatal("failed downgrade damaged identity", err) + } +} diff --git a/services/agents-api/internal/store/executor_principals_test.go b/services/agents-api/internal/store/executor_principals_test.go new file mode 100644 index 000000000..6559ad95f --- /dev/null +++ b/services/agents-api/internal/store/executor_principals_test.go @@ -0,0 +1,167 @@ +package store + +import ( + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func TestExecutorPrincipalBeforeSessionAndSharedLifecycle(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + p := FixtureExecutorPrincipal(t, s, uuid.NewString()) + keyID := uuid.NewString() + issued, err := s.IssueExecutorCredential(ctx, p, keyID, "") + if err != nil || issued.KeyID != keyID || issued.EnvironmentID != "" || len(issued.Token) != 43 { + t.Fatal("pre-Session principal issuance failed", err) + } + page, err := s.ListSessions(ctx, p.TenantID, "", 10, false, nil) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("issuance created a Session", err) + } + create := func(principal identity.Principal) (Session, Environment) { + t.Helper() + input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") + input.Creator = principal.Subject() + session, err := s.CreateSession(ctx, principal.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + return session, environment + } + check := func(st *Store, environment string, key IssuedExecutorCredential, allowed bool) { + t.Helper() + owner, err := st.AuthenticateEnvironmentExecutor(ctx, environment, executorDigest(key.Token)) + if allowed && (err != nil || owner != p.TenantID) || !allowed && !errors.Is(err, ErrNotFound) { + t.Fatal("unexpected principal authorization", allowed, err) + } + } + first, one := create(p) + _, two := create(p) + check(s, one.ID, issued, true) + check(s, two.ID, issued, true) + check(s, uuid.NewString(), issued, false) + otherKind, otherID := p, p + otherKind.SubjectKind = "user" + otherID.SubjectID = "other-subject" + foreign := FixtureExecutorPrincipal(t, s, uuid.NewString()) + for _, different := range []identity.Principal{otherKind, otherID, foreign} { + _, target := create(different) + check(s, target.ID, issued, false) + if _, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("restricted key accepted another creator/project", err) + } + if _, err := s.RotateExecutorCredential(ctx, different, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign rotation", err) + } + if err := s.RevokeExecutorCredential(ctx, different, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign revocation", err) + } + } + for _, different := range []identity.Principal{ + {ProjectScope: identity.ProjectScope{TenantID: p.TenantID, OrganizationID: "other-org", ProjectID: p.ProjectID}, SubjectKind: p.SubjectKind, SubjectID: p.SubjectID}, + {ProjectScope: identity.ProjectScope{TenantID: p.TenantID, OrganizationID: p.OrganizationID, ProjectID: "other-project"}, SubjectKind: p.SubjectKind, SubjectID: p.SubjectID}, + } { + if _, err := s.IssueExecutorCredential(ctx, different, uuid.NewString(), ""); !errors.Is(err, ErrNotFound) { + t.Fatal("unverified scope issuance", err) + } + if _, err := s.RotateExecutorCredential(ctx, different, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("unverified scope rotation", err) + } + if err := s.RevokeExecutorCredential(ctx, different, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("unverified scope revocation", err) + } + } + restricted, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), one.ID) + if err != nil { + t.Fatal(err) + } + check(s, one.ID, restricted, true) + check(s, two.ID, restricted, false) + // Reopening the database preserves both key identity and multi-Session authority. + pool.Close() + restarted, newPool := testStore(t) + check(restarted, one.ID, issued, true) + check(restarted, two.ID, issued, true) + if err := restarted.DeleteSession(ctx, p.TenantID, first.ID); err != nil { + t.Fatal(err) + } + check(restarted, one.ID, issued, false) + check(restarted, two.ID, issued, true) + if err := restarted.RevokeExecutorCredential(ctx, p, restricted.KeyID); err != nil { + t.Fatal("revoke deleted restriction", err) + } + rotated, err := restarted.RotateExecutorCredential(ctx, p, keyID) + if err != nil || rotated.KeyID != keyID || rotated.EnvironmentID != "" || rotated.Token == issued.Token { + t.Fatal("principal rotation", err) + } + check(restarted, two.ID, issued, false) + check(restarted, two.ID, rotated, true) + var retained bool + if err := newPool.QueryRow(ctx, `SELECT tenant_id=$2 AND subject_kind=$3 AND subject_id=$4 + AND environment_id IS NULL AND created_at < issued_at AND revoked_at IS NULL + FROM environment_executor_credentials WHERE key_id=$1`, keyID, p.TenantID, p.SubjectKind, p.SubjectID).Scan(&retained); err != nil || !retained { + t.Fatal("rotation changed principal or creation identity", err) + } + if err := restarted.RevokeExecutorCredential(ctx, p, keyID); err != nil { + t.Fatal(err) + } + check(restarted, two.ID, rotated, false) + if _, err := restarted.IssueExecutorCredential(ctx, p, keyID, ""); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("issue restored revoked authority", err) + } + restored, err := restarted.RotateExecutorCredential(ctx, p, keyID) + if err != nil { + t.Fatal(err) + } + check(restarted, two.ID, restored, true) +} + +func TestExecutorPrincipalRequiresVerifiedScopeAndRecordedCreator(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.NewString(), OrganizationID: "org", ProjectID: uuid.NewString()}, SubjectKind: "user", SubjectID: "owner"} + if _, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), ""); !errors.Is(err, ErrNotFound) { + t.Fatal("issuer manufactured a project mapping", err) + } + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{p.ProjectScope}); err != nil { + t.Fatal(err) + } + for _, invalid := range []identity.Principal{{}, {ProjectScope: p.ProjectScope}, {ProjectScope: p.ProjectScope, SubjectKind: "workspace", SubjectID: "owner"}} { + if _, err := s.IssueExecutorCredential(ctx, invalid, uuid.NewString(), ""); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid principal", err) + } + } + input := environmentInput("unknown-creator", "self_hosted", "/workspace") + input.Creator = p.Subject() + session, err := s.CreateSession(ctx, p.TenantID, input) + if err != nil { + t.Fatal(err) + } + target, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + key, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + if owner, err := s.AuthenticateEnvironmentExecutor(ctx, target.ID, executorDigest(key.Token)); err != nil || owner != p.TenantID { + t.Fatal("user principal failed", err) + } + if _, err := pool.Exec(ctx, "UPDATE sessions SET creator_kind=NULL,creator_id=NULL WHERE id=$1", session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, target.ID, executorDigest(key.Token)); !errors.Is(err, ErrNotFound) { + t.Fatal("unknown creator accepted", err) + } + if _, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("unknown creator claimed", err) + } +} diff --git a/services/agents-api/internal/store/executor_registration_public_test.go b/services/agents-api/internal/store/executor_registration_public_test.go new file mode 100644 index 000000000..2778ba248 --- /dev/null +++ b/services/agents-api/internal/store/executor_registration_public_test.go @@ -0,0 +1,295 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func TestExecutorRegistrationPostgreSQLAndNativeReconnect(t *testing.T) { + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant, foreign := uuid.NewString(), uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + foreignPrincipal := store.FixtureExecutorPrincipal(t, s, foreign) + credential, err := s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + config := json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-registry", Configuration: config}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + other, err := s.CreateSession(ctx, foreign, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-registry", Configuration: config}) + if err != nil { + t.Fatal(err) + } + otherEnvironment, err := s.GetSessionEnvironment(ctx, foreign, other.ID) + if err != nil { + t.Fatal(err) + } + foreignCredential, err := s.IssueExecutorCredential(ctx, foreignPrincipal, otherEnvironment.ID, otherEnvironment.ID) + if err != nil { + t.Fatal(err) + } + token, wrongTenantToken := credential.Token, foreignCredential.Token + sibling, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "same-principal", Configuration: config}) + if err != nil { + t.Fatal(err) + } + siblingEnvironment, err := s.GetSessionEnvironment(ctx, tenant, sibling.ID) + if err != nil { + t.Fatal(err) + } + otherCreator := store.FixtureCreator() + otherCreator.Kind = "user" + otherSubject, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: otherCreator, Engine: "codex", IdempotencyKey: "different-kind", Configuration: config}) + if err != nil { + t.Fatal(err) + } + otherSubjectEnvironment, err := s.GetSessionEnvironment(ctx, tenant, otherSubject.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + address := server.Listener.Addr().String() + var attempts atomic.Int64 + start := func(server *httptest.Server) *codex.Registry { + r, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + address}) + if err != nil { + t.Fatal(err) + } + handler := r.Handler() + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, "/register") { + attempts.Add(1) + } + handler.ServeHTTP(w, req) + }) + server.Start() + return r + } + registry := start(server) + defer func() { registry.Close(); server.Close() }() + register := func(id, key string, status int) codex.RegistrationResponse { + t.Helper() + body := codex.RegistrationRequest{SecurityProfile: "noise_hybrid_ik_v1", ExecutorPublicKey: codex.PublicKey{Suite: "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256", X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))}} + encoded, _ := json.Marshal(body) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/cloud/environment/"+id+"/register", bytes.NewReader(encoded)) + req.Header.Set("Authorization", "Bearer "+key) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != status { + t.Fatalf("register status %d expected %d", resp.StatusCode, status) + } + var result codex.RegistrationResponse + if status == 200 { + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + } + return result + } + register(siblingEnvironment.ID, token, 200) + register(otherSubjectEnvironment.ID, token, 401) + register(environment.ID, "caller/device/harness/grant", 401) + register(otherEnvironment.ID, token, 401) + register(environment.ID, wrongTenantToken, 401) + for _, alias := range []string{strings.ToUpper(environment.ID), "{" + environment.ID + "}", "urn:uuid:" + environment.ID, strings.ReplaceAll(environment.ID, "-", "")} { + if alias != environment.ID { + register(alias, token, 401) + } + } + valid := register(environment.ID, token, 200) + socket, response, err := websocket.DefaultDialer.DialContext(ctx, valid.URL, nil) + if err != nil { + if response != nil { + response.Body.Close() + } + t.Fatal("scoped socket failed") + } + awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "connected") + socket.Close() + connected := func(want bool) { + t.Helper() + until := time.Now().Add(20 * time.Second) + for time.Now().Before(until) { + got, err := registry.Connected(ctx, tenant, environment.ID) + if err == nil && got == want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("native presence did not converge") + } + connected(false) + awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") + binary := os.Getenv("PARSAR_CODEX_BINARY") + if binary != "" { + version, err := exec.CommandContext(ctx, binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("pinned Codex0.153.4 required") + } + proof := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + if proof == "" { + t.Fatal("private runtime directory required") + } + runtime, err := os.MkdirTemp(proof, "native-presence-") + if err != nil { + t.Fatal(err) + } + home := filepath.Join(runtime, "codex") + if err := os.Mkdir(home, 0700); err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(ctx, binary, "exec-server", "--remote", server.URL, "--environment-id", environment.ID) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = 5 * time.Second + cmd.Dir = runtime + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "CODEX_HOME=" + home, "CODEX_API_KEY=" + token, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + cmd.Stdout, cmd.Stderr = io.Discard, io.Discard + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("native executor failed to exit") + } + }() + connected(true) + before := attempts.Load() + registry.Close() + server.Close() + listener, err := net.Listen("tcp", address) + if err != nil { + t.Fatal(err) + } + server = httptest.NewUnstartedServer(nil) + server.Listener.Close() + server.Listener = listener + registry = start(server) + connected(true) + if attempts.Load() <= before { + t.Fatal("native executor did not re-register after registry restart") + } + previous := token + credential, err = s.RotateExecutorCredential(ctx, principal, credential.KeyID) + if err != nil { + t.Fatal(err) + } + token = credential.Token + connected(false) + register(environment.ID, previous, 401) + before = attempts.Load() + until := time.Now().Add(15 * time.Second) + for attempts.Load() == before && time.Now().Before(until) { + time.Sleep(20 * time.Millisecond) + } + if attempts.Load() == before { + t.Fatal("native executor did not retry its retired credential") + } + connected(false) + t.Log("unmodified Codex0.153.4 registered, reconnected after registry restart, and lost registration authority after rotation") + } else { + t.Log("native CLI verification not requested; real PostgreSQL/socket checks remain active") + } + stale := register(environment.ID, token, 200) + rotated, err := s.RotateExecutorCredential(ctx, principal, credential.KeyID) + if err != nil { + t.Fatal(err) + } + register(environment.ID, token, 401) + rejected, resp, e := websocket.DefaultDialer.DialContext(ctx, stale.URL, nil) + if rejected != nil { + rejected.Close() + } + if resp != nil { + resp.Body.Close() + } + if e == nil || resp == nil || resp.StatusCode != 401 { + t.Fatal("retired credential's ticket accepted") + } + token = rotated.Token + current := register(environment.ID, token, 200) + socket, response, err = websocket.DefaultDialer.DialContext(ctx, current.URL, nil) + if err != nil { + t.Fatal("rotated key could not connect") + } + defer socket.Close() + awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "connected") + if err := s.RevokeExecutorCredential(ctx, principal, credential.KeyID); err != nil { + t.Fatal(err) + } + register(environment.ID, token, 401) + _ = socket.SetReadDeadline(time.Now().Add(10 * time.Second)) + if _, _, err := socket.ReadMessage(); err == nil { + t.Fatal("revoked socket survived") + } + connected(false) + awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") + if len(retainedEnvironmentEvents(t, ctx, s, tenant, session.ID, environment.ID)) < 4 { + t.Fatal("real sockets did not persist connection transitions") + } + register(otherEnvironment.ID, wrongTenantToken, 200) + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + register(environment.ID, token, 401) + restored, err := s.RotateExecutorCredential(ctx, principal, credential.KeyID) + if err != nil { + t.Fatal(err) + } + register(environment.ID, restored.Token, 401) + register(siblingEnvironment.ID, restored.Token, 200) + register(otherSubjectEnvironment.ID, restored.Token, 401) + _, response, err = websocket.DefaultDialer.DialContext(ctx, valid.URL, nil) + if err == nil { + t.Fatal("deleted Environment accepted old connection") + } + if response != nil { + response.Body.Close() + } + if _, err := s.GetEnvironment(ctx, foreign, otherEnvironment.ID); err != nil { + t.Fatal("another tenant was affected", err) + } +} diff --git a/services/agents-api/internal/store/export_test.go b/services/agents-api/internal/store/export_test.go new file mode 100644 index 000000000..3cdda8e9c --- /dev/null +++ b/services/agents-api/internal/store/export_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/jackc/pgx/v5/pgxpool" + "testing" +) + +func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) } + +// FixtureCreator is an explicit synthetic principal for newly created test Sessions. +func FixtureCreator() identity.Subject { + return identity.Subject{Kind: "service_account", ID: "test-runner"} +} + +// FixtureExecutorPrincipal explicitly provisions a synthetic project for executor fixtures. +func FixtureExecutorPrincipal(t *testing.T, s *Store, tenant string) identity.Principal { + t.Helper() + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: "test-org", ProjectID: tenant}, SubjectKind: FixtureCreator().Kind, SubjectID: FixtureCreator().ID} + if err := s.EnsureProjectScopes(t.Context(), []identity.ProjectScope{p.ProjectScope}); err != nil { + t.Fatal(err) + } + return p +} diff --git a/services/agents-api/internal/store/function_calls.go b/services/agents-api/internal/store/function_calls.go new file mode 100644 index 000000000..310f378b4 --- /dev/null +++ b/services/agents-api/internal/store/function_calls.go @@ -0,0 +1,114 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// FunctionCall retains public identity and its opaque execution-adapter reference. +type FunctionCall struct { + CallID, ExecutorCallID, Name string + Arguments json.RawMessage + Result json.RawMessage + Applied bool +} + +// RecordFunctionCall commits an execution callback and its required-action state together. +func (s *Store) RecordFunctionCall(ctx context.Context, tenantID, sessionID, turnID string, call FunctionCall) error { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return err + } + if !validFunctionIdentity(call.CallID) || !validFunctionIdentity(call.ExecutorCallID) || !validFunctionIdentity(call.Name) || len(call.Arguments) > 512*1024 || !json.Valid(call.Arguments) || call.Result != nil || call.Applied { + return ErrInvalidInput + } + return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + turn, err := q.GetTurn(ctx, p) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + matches, err := q.MatchFunctionCall(ctx, sqlc.MatchFunctionCallParams{SessionID: session, TurnID: p.ID, CallID: call.CallID, ExecutorCallID: call.ExecutorCallID, Name: call.Name, Arguments: call.Arguments}) + if err == nil { + if !matches { + return ErrIdempotencyConflict + } + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + if !acceptsFunctionResult(turn) { + return ErrTurnConflict + } + count, err := q.CreateFunctionCall(ctx, sqlc.CreateFunctionCallParams{SessionID: session, TurnID: p.ID, CallID: call.CallID, ExecutorCallID: call.ExecutorCallID, Name: call.Name, Arguments: call.Arguments}) + if err != nil { + return err + } + if count != 1 { + return ErrIdempotencyConflict + } + return recordFunctionState(ctx, q, turn) + }) +} + +func (s *Store) GetFunctionCall(ctx context.Context, tenantID, sessionID, turnID, callID string) (FunctionCall, error) { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return FunctionCall{}, err + } + if !validFunctionIdentity(callID) { + return FunctionCall{}, ErrInvalidInput + } + row, err := s.queries.GetFunctionCall(ctx, sqlc.GetFunctionCallParams{TenantID: p.TenantID, SessionID: p.SessionID, TurnID: p.ID, CallID: callID}) + if errors.Is(err, pgx.ErrNoRows) { + return FunctionCall{}, ErrNotFound + } + if err != nil { + return FunctionCall{}, err + } + return functionCallFromRow(row), nil +} + +// PendingFunctionCalls excludes applied results and cancelling or terminal Turns. +func (s *Store) PendingFunctionCalls(ctx context.Context, tenantID, sessionID, turnID string) ([]FunctionCall, error) { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return nil, err + } + result := make([]FunctionCall, 0) + err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + if _, err := q.GetTurn(ctx, p); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + rows, err := q.ListPendingFunctionCalls(ctx, sqlc.ListPendingFunctionCallsParams{SessionID: session, TurnID: p.ID}) + if err != nil { + return err + } + for _, row := range rows { + result = append(result, functionCallFromRow(row)) + } + return nil + }) + return result, err +} + +func functionCallFromRow(row sqlc.FunctionCall) FunctionCall { + return FunctionCall{CallID: row.CallID, ExecutorCallID: row.ExecutorCallID, Name: row.Name, Arguments: row.Arguments, Result: row.Result, Applied: row.Applied} +} + +func validFunctionIdentity(id string) bool { return strings.TrimSpace(id) != "" && len(id) <= 512 } + +func acceptsFunctionResult(turn sqlc.Turn) bool { + return (turn.Status == TurnInProgress || turn.Status == TurnWaiting) && !turn.CancelRequestedAt.Valid +} diff --git a/services/agents-api/internal/store/function_calls_test.go b/services/agents-api/internal/store/function_calls_test.go new file mode 100644 index 000000000..af64a0977 --- /dev/null +++ b/services/agents-api/internal/store/function_calls_test.go @@ -0,0 +1,269 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" +) + +func functionCallFixture(id string) FunctionCall { + return FunctionCall{CallID: id, ExecutorCallID: "native-" + id, Name: "lookup", Arguments: json.RawMessage(`{"ticket":9007199254740993}`)} +} + +func TestFunctionCallsPersistCompleteResultsAndReceipts(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, turn, TurnInProgress, TurnWaiting) + results := []string{ + `{"success":true,"output":"answer"}`, + `{"success":true,"output":""}`, + `{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AA=="},{"type":"input_text","text":""}],"error":"tool failed"}`, + `{"success":true,"output":[],"error":null}`, + `{"success":false,"output":null,"error":"missing"}`, + `{"success":false}`, + } + for i, raw := range results { + call := functionCallFixture(fmt.Sprint(i)) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); err != nil { + t.Fatal(err) + } + retry := call + retry.Arguments = json.RawMessage(`{ "ticket" : 9007199254740993 }`) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, retry); err != nil { + t.Fatal(err) + } + if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, call.CallID); !errors.Is(err, ErrTurnConflict) { + t.Fatal("unsubmitted result applied", err) + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, call.CallID, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + before, err := s.PendingFunctionCalls(t.Context(), tenant, session.ID, turn) + if err != nil || len(before) != len(results) { + t.Fatal(before, err) + } + pool.Close() + reopened, _ := testStore(t) + restored, err := reopened.PendingFunctionCalls(t.Context(), tenant, session.ID, turn) + if err != nil || !reflect.DeepEqual(before, restored) { + t.Fatal("recovery lost calls/results", restored, err) + } + for i, expected := range results { + id := fmt.Sprint(i) + row, err := reopened.GetFunctionCall(t.Context(), tenant, session.ID, turn, id) + if err != nil || row.Applied || row.ExecutorCallID != "native-"+id || !strings.Contains(string(row.Arguments), "9007199254740993") { + t.Fatal(row, err) + } + normalized, err := canonicalJSONObject(row.Result) + wanted, _ := canonicalJSONObject(json.RawMessage(expected)) + if err != nil || string(normalized) != string(wanted) { + t.Fatal("result changed", string(row.Result), err) + } + if err := reopened.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(expected)); err != nil { + t.Fatal(err) + } + if err := reopened.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":false,"error":"changed"}`)); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + for range 2 { + if err := reopened.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, id); err != nil { + t.Fatal(err) + } + } + } + pending, err := reopened.PendingFunctionCalls(t.Context(), tenant, session.ID, turn) + if err != nil || len(pending) != 0 { + t.Fatal(pending, err) + } + again, _ := testStore(t) + row, err := again.GetFunctionCall(t.Context(), tenant, session.ID, turn, "0") + if err != nil || !row.Applied { + t.Fatal("receipt did not persist", row, err) + } +} + +func TestFunctionCallsAreScopedAndImmutable(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + call := functionCallFixture("call") + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); !errors.Is(err, ErrTurnConflict) { + t.Fatal("queued turn accepted callback", err) + } + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); err != nil { + t.Fatal(err) + } + for _, field := range []string{"call", "executor", "name", "arguments"} { + changed := call + switch field { + case "call": + changed.CallID = "another-public-id" + case "executor": + changed.ExecutorCallID = "another-executor-id" + case "name": + changed.Name = "another-function" + case "arguments": + changed.Arguments = json.RawMessage(`{"ticket":9007199254740992}`) + } + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(field, err) + } + } + for _, scope := range []struct{ tenant, session, turn string }{ + {uuid.NewString(), session.ID, turn}, {tenant, uuid.NewString(), turn}, {tenant, session.ID, uuid.NewString()}, + } { + _, err := s.GetFunctionCall(t.Context(), scope.tenant, scope.session, scope.turn, "call") + if !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.PendingFunctionCalls(t.Context(), scope.tenant, scope.session, scope.turn); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := s.RecordFunctionCall(t.Context(), scope.tenant, scope.session, scope.turn, call); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := s.SubmitFunctionResult(t.Context(), scope.tenant, scope.session, scope.turn, "call", json.RawMessage(`{"success":true}`)); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := s.ConfirmFunctionResult(t.Context(), scope.tenant, scope.session, scope.turn, "call"); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "missing", json.RawMessage(`{"success":true}`)); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } +} + +func TestFunctionResultsCannotApplyAfterCancellationOrCompletion(t *testing.T) { + for _, terminal := range []string{TurnCancelled, TurnCompleted, TurnFailed} { + t.Run(terminal, func(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + for _, id := range []string{"submitted", "pending"} { + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { + t.Fatal(err) + } + } + result := json.RawMessage(`{"success":true,"output":"saved"}`) + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "submitted", result); err != nil { + t.Fatal(err) + } + if terminal == TurnCancelled { + if _, err := s.RequestCancel(t.Context(), tenant, session.ID, "cancel"); err != nil { + t.Fatal(err) + } + assertNoPendingFunctions(t, s, tenant, session.ID, turn) + if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, "submitted"); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "pending", result); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + } + transition(t, s, tenant, session.ID, turn, TurnWaiting, terminal) + assertNoPendingFunctions(t, s, tenant, session.ID, turn) + if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, "submitted"); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "pending", result); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture("late")); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "submitted", result); err != nil { + t.Fatal("identical retry changed its result", err) + } + row, err := s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "submitted") + if err != nil || row.Applied || len(row.Result) == 0 { + t.Fatal("historical result lost or falsely applied", row, err) + } + next := submitMessage(t, s, tenant, session.ID, "next").TurnID + transition(t, s, tenant, session.ID, next, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, next, functionCallFixture("pending")); err != nil { + t.Fatal("call identity leaked across Turns", err) + } + }) + } +} + +func assertNoPendingFunctions(t *testing.T, s *Store, tenant, session, turn string) { + t.Helper() + rows, err := s.PendingFunctionCalls(t.Context(), tenant, session, turn) + if err != nil || len(rows) != 0 { + t.Fatal(rows, err) + } +} + +func TestFunctionResultConcurrentSubmissionsChooseOneValue(t *testing.T) { + s, _ := testStore(t) + other, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture("call")); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + outcomes := make(chan error, 2) + for _, output := range []string{"first", "second"} { + wg.Add(1) + go func() { + defer wg.Done() + result, _ := json.Marshal(map[string]any{"success": true, "output": output}) + outcomes <- other.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "call", result) + }() + } + wg.Wait() + close(outcomes) + winners, conflicts := 0, 0 + for err := range outcomes { + if err == nil { + winners++ + } else if errors.Is(err, ErrIdempotencyConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if winners != 1 || conflicts != 1 { + t.Fatal(winners, conflicts) + } +} + +func TestFunctionResultInvalidStorageInputDoesNotConsumeCall(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture("call")); err != nil { + t.Fatal(err) + } + for _, raw := range []string{"", "null", "[]", "{} {}", `{"output":"` + strings.Repeat("a", 512*1024) + `"}`} { + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, "call", json.RawMessage(raw)); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := s.SubmitFunctionResult(ctx, tenant, session.ID, turn, "call", json.RawMessage(`{"success":true}`)); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + row, err := s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "call") + if err != nil || row.Result != nil || row.Applied { + t.Fatal(row, err) + } +} diff --git a/services/agents-api/internal/store/function_execution_native_test.go b/services/agents-api/internal/store/function_execution_native_test.go new file mode 100644 index 000000000..c69f47865 --- /dev/null +++ b/services/agents-api/internal/store/function_execution_native_test.go @@ -0,0 +1,85 @@ +package store_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativeFunctionExecutionPersistsCallsResultsAndContinuity(t *testing.T) { + h, ctx, home := nativeDispatchHarness(t) + var err error + h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-functions", Configuration: json.RawMessage(functionConfiguration)}) + if err != nil { + t.Fatal(err) + } + if err := h.s.BindSessionDevice(ctx, h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + model, output, requests := nativeFunctionModel(t, home) + defer model.Close() + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"codex_provider": map[string]any{"base_url": model.URL + "/v1", "bearer_token": "synthetic-test-token"}}, nil + } + nativeID := "" + for index := range 3 { + input := h.message(fmt.Sprint(index), "Look up ticket 42") + running := h.run(ctx, input.TurnID) + state := functionState(t, h, 1) + action := state.RequiredActions[0] + if action.Name != "lookup_ticket" || action.TurnID != input.TurnID || state.LastTurn.Status != store.TurnWaiting { + t.Fatal(action, state.LastTurn) + } + if index == 2 { + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "native-cancel"); err != nil { + t.Fatal(err) + } + h.finished(running, store.TurnCancelled) + break + } + value := map[string]any{"success": index == 0, "output": output} + if index == 1 { + value["error"] = "synthetic failure" + } + raw, _ := json.Marshal(value) + for range 2 { + if err := h.s.SubmitFunctionResult(ctx, h.tenant, h.session.ID, input.TurnID, action.CallID, raw); err != nil { + t.Fatal(err) + } + } + h.finished(running, store.TurnCompleted) + saved, err := h.s.GetFunctionCall(ctx, h.tenant, h.session.ID, input.TurnID, action.CallID) + if err != nil || !saved.Applied { + t.Fatal(saved, err) + } + page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + found := false + for _, item := range page.Items { + if item.Type == "function_call" && item.CallID == action.CallID { + found = true + } + } + if !found { + t.Fatal("required action identity differs from recovered function item") + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID == "" || (nativeID != "" && bound.NativeSessionID != nativeID) { + t.Fatal(bound, err) + } + nativeID = bound.NativeSessionID + } + functionState(t, h, 0) + if requests.Load() != 5 { + t.Fatal("function replay or missing model continuation", requests.Load()) + } + if t.Failed() { + return + } + t.Logf("Native daemon/engine functions, complete text/image/error results, receipts, Items identity, resume and cancellation passed; evidence %s", home) +} diff --git a/services/agents-api/internal/store/function_execution_test.go b/services/agents-api/internal/store/function_execution_test.go new file mode 100644 index 000000000..8b567dce0 --- /dev/null +++ b/services/agents-api/internal/store/function_execution_test.go @@ -0,0 +1,208 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +const functionConfiguration = `{"agent":{"model":"gpt-5.5","tools":[{"type":"function","name":"lookup_ticket","description":"Read a synthetic ticket","parameters":{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false},"defer_loading":false}]},"environment":{"type":"none"}}` + +func newFunctionHarness(t *testing.T) *dispatchHarness { + t.Helper() + h := newDispatchHarness(t) + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "functions", Configuration: json.RawMessage(functionConfiguration)}) + if err != nil { + t.Fatal(err) + } + if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true, FunctionTools: true}}}}) + deadline := time.Now().Add(time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("codex") + if info.Capabilities.FunctionTools { + return h + } + if time.Now().After(deadline) { + t.Fatal("function heartbeat missing") + } + time.Sleep(time.Millisecond) + } +} + +func functionState(t *testing.T, h *dispatchHarness, count int) store.Session { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for { + state, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil { + t.Fatal(err) + } + if len(state.RequiredActions) == count { + return state + } + if time.Now().After(deadline) { + t.Fatalf("waiting for %d function actions: %+v", count, state) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { + h := newFunctionHarness(t) + input := h.message("start", "Run functions") + result := h.run(t.Context(), input.TurnID) + var prompt proto.PromptRequestPayload + _ = h.read(proto.TypePromptRequest).DecodePayload(&prompt) + if len(prompt.FunctionTools) != 1 || prompt.FunctionTools[0].Name != "lookup_ticket" { + t.Fatal(prompt.FunctionTools) + } + for _, id := range []string{"a", "b"} { + for range 2 { + h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: id, Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)}) + } + } + state := functionState(t, h, 2) + if state.LastTurn.Status != store.TurnWaiting { + t.Fatal(state.LastTurn) + } + for _, id := range []string{"a", "b"} { + public := items.Identity(input.TurnID, "tool:"+id) + if err := h.s.SubmitFunctionResult(t.Context(), h.tenant, h.session.ID, input.TurnID, public, json.RawMessage(`{"success":true,"output":"saved"}`)); err != nil { + t.Fatal(err) + } + } + for index := range 2 { + var reply proto.FunctionResultPayload + _ = h.read(proto.TypeFunctionResult).DecodePayload(&reply) + public := items.Identity(input.TurnID, "tool:"+reply.CallID) + saved, err := h.s.GetFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, public) + if err != nil || saved.Applied || reply.DeliveryID != "function:"+public || len(reply.Content) != 1 || *reply.Content[0].Text != "saved" { + t.Fatal(saved, reply, err) + } + if index == 1 { + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-functions"}}) + select { + case got := <-result: + t.Fatal("Done bypassed outstanding receipt", got) + case <-time.After(40 * time.Millisecond): + } + } + for range 2 { + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: reply.DeliveryID, Applied: true}) + } + } + h.finished(result, store.TurnCompleted) + functionState(t, h, 0) + next := h.message("next", "Resume") + result = h.run(t.Context(), next.TurnID) + _ = h.read(proto.TypePromptRequest).DecodePayload(&prompt) + if prompt.AgentSessionID != "native-functions" || len(prompt.FunctionTools) != 1 { + t.Fatal(prompt) + } + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + h.finished(result, store.TurnCompleted) +} + +func TestExecutionFunctionsCancellationAndUnconfirmedResults(t *testing.T) { + for _, cancel := range []bool{false, true} { + t.Run(map[bool]string{false: "unconfirmed", true: "cancel"}[cancel], func(t *testing.T) { + h := newFunctionHarness(t) + input := h.message("start", "Run") + result := h.run(t.Context(), input.TurnID) + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) + state := functionState(t, h, 1) + id := state.RequiredActions[0].CallID + if err := h.s.SubmitFunctionResult(t.Context(), h.tenant, h.session.ID, input.TurnID, id, json.RawMessage(`{"success":false,"error":"tool failed"}`)); err != nil { + t.Fatal(err) + } + var reply proto.FunctionResultPayload + _ = h.read(proto.TypeFunctionResult).DecodePayload(&reply) + if reply.Success || len(reply.Content) != 1 || *reply.Content[0].Text != "tool failed" { + t.Fatal(reply) + } + status := store.TurnFailed + if cancel { + if _, err := h.s.RequestCancel(t.Context(), h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + var request proto.PromptCancelPayload + _ = h.read(proto.TypePromptCancel).DecodePayload(&request) + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: reply.DeliveryID, ErrorCode: "not_pending"}) + select { + case got := <-result: + t.Fatal("function rejection bypassed outstanding cancellation", got) + case <-time.After(40 * time.Millisecond): + } + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled-functions"}}}) + status = store.TurnCancelled + } else { + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: reply.DeliveryID, ErrorCode: "not_pending"}) + } + h.finished(result, status) + if cancel { + bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "native-cancelled-functions" { + t.Fatal(bound, err) + } + } + saved, err := h.s.GetFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, id) + if err != nil || saved.Applied || len(saved.Result) == 0 { + t.Fatal(saved, err) + } + if err := h.s.ConfirmFunctionResult(t.Context(), h.tenant, h.session.ID, input.TurnID, id); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal(err) + } + functionState(t, h, 0) + }) + } +} + +func TestExecutionFunctionsRejectUndeclaredCallsAndPrematureDone(t *testing.T) { + for _, name := range []string{"undeclared", "lookup_ticket"} { + t.Run(name, func(t *testing.T) { + h := newFunctionHarness(t) + input := h.message("start", "Run") + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + result := h.run(ctx, input.TurnID) + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: name, Arguments: json.RawMessage(`{}`)}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) + h.finished(result, store.TurnFailed) + }) + } +} + +func TestExecutionFunctionsRequireAdvertisedCapability(t *testing.T) { + h := newDispatchHarness(t) + session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "functions", Configuration: json.RawMessage(functionConfiguration)}) + if err != nil { + t.Fatal(err) + } + h.session = session + if err := h.s.BindSessionDevice(t.Context(), h.tenant, session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + input := h.message("start", "Run") + result := <-h.run(t.Context(), input.TurnID) + if result.err == nil || !strings.Contains(result.err.Error(), "function_tools") { + t.Fatal(result) + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } +} diff --git a/services/agents-api/internal/store/function_input_execution_test.go b/services/agents-api/internal/store/function_input_execution_test.go new file mode 100644 index 000000000..4db5ee718 --- /dev/null +++ b/services/agents-api/internal/store/function_input_execution_test.go @@ -0,0 +1,59 @@ +package store_test + +import ( + "encoding/json" + "strconv" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { + h := newFunctionHarness(t) + input := h.message("start", "Run") + running := h.run(t.Context(), input.TurnID) + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) + state := functionState(t, h, 1) + raw, _ := json.Marshal(store.FunctionResultInput{TurnID: input.TurnID, CallID: state.RequiredActions[0].CallID, Result: json.RawMessage(`{"success":true,"output":"answer"}`)}) + batch := []store.Input{{Kind: "tool_result", Payload: raw}, {Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}} + receipts, err := h.s.SubmitInputs(t.Context(), h.tenant, h.session.ID, "mixed", batch) + if err != nil { + t.Fatal(err) + } + if _, err := h.s.SubmitInputs(t.Context(), h.tenant, h.session.ID, "mixed", batch); err != nil { + t.Fatal(err) + } + resultSeen, messageSeen := false, false + _ = h.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for !resultSeen || !messageSeen { + var env proto.Envelope + if err := h.conn.ReadJSON(&env); err != nil { + t.Fatal(err) + } + switch env.Type { + case proto.TypeFunctionResult: + var result proto.FunctionResultPayload + if env.DecodePayload(&result) != nil || resultSeen || result.CallID != "a" { + t.Fatal(result) + } + resultSeen = true + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: result.DeliveryID, Applied: true}) + case proto.TypePromptSteer: + var steer proto.PromptSteerPayload + if env.DecodePayload(&steer) != nil || messageSeen || steer.Text != "Follow up" || steer.InputID != strconv.FormatInt(receipts[1].Sequence, 10) { + t.Fatal(steer) + } + messageSeen = true + h.write(input.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) + } + } + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + h.finished(running, store.TurnCompleted) + saved, err := h.s.GetFunctionCall(t.Context(), h.tenant, h.session.ID, input.TurnID, state.RequiredActions[0].CallID) + if err != nil || !saved.Applied { + t.Fatal(saved, err) + } +} diff --git a/services/agents-api/internal/store/function_inputs.go b/services/agents-api/internal/store/function_inputs.go new file mode 100644 index 000000000..e0e079117 --- /dev/null +++ b/services/agents-api/internal/store/function_inputs.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// FunctionResultInput identifies a persisted call; Result is validated by the API. +// It is an internal command, not an upstream input event. +type FunctionResultInput struct { + TurnID string `json:"turn_id"` + CallID string `json:"call_id"` + Result json.RawMessage `json:"result"` +} + +func functionInput(raw json.RawMessage) (FunctionResultInput, error) { + var input FunctionResultInput + if json.Unmarshal(raw, &input) != nil || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { + return input, ErrInvalidInput + } + if _, err := uuid.Parse(input.TurnID); err != nil { + return input, ErrInvalidInput + } + result, err := canonicalJSONObject(input.Result) + if err != nil { + return input, err + } + input.Result = result + return input, nil +} + +func admitFunctionResult(ctx context.Context, q *sqlc.Queries, tenantID string, session pgtype.UUID, key string, position int32, input Input) (InputReceipt, error) { + result, err := functionInput(input.Payload) + if err != nil { + return InputReceipt{}, err + } + lookup, err := turnLookup(tenantID, uuid.UUID(session.Bytes).String(), result.TurnID) + if err != nil { + return InputReceipt{}, err + } + turn, err := q.GetTurn(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return InputReceipt{}, ErrNotFound + } + if err != nil { + return InputReceipt{}, err + } + if err := storeFunctionResult(ctx, q, turn, result.CallID, result.Result); err != nil { + return InputReceipt{}, err + } + sequence, err := q.CreateTurnInput(ctx, sqlc.CreateTurnInputParams{ + SessionID: session, TurnID: turn.ID, IdempotencyKey: key, Kind: input.Kind, Payload: input.Payload, BatchPosition: position, + }) + if err != nil { + return InputReceipt{}, err + } + return inputReceipt(sequence, turn.ID, false), nil +} diff --git a/services/agents-api/internal/store/function_inputs_public_test.go b/services/agents-api/internal/store/function_inputs_public_test.go new file mode 100644 index 000000000..af3568e2f --- /dev/null +++ b/services/agents-api/internal/store/function_inputs_public_test.go @@ -0,0 +1,134 @@ +package store_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("PARSAR_OFFICIAL_SDK_PYTHON is required for official-client verification") + } + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "fixture"}) + if err != nil { + t.Fatal(err) + } + other, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "other"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"fixture"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b", "c", "rollback", "late"} { + if err := s.RecordFunctionCall(ctx, tenant, session.ID, input.TurnID, store.FunctionCall{CallID: id, ExecutorCallID: "native-" + id, Name: "lookup", Arguments: json.RawMessage(`{}`)}); err != nil { + t.Fatal(err) + } + } + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + command := exec.CommandContext(ctx, python, "../../tests/official_function_inputs.py", server.URL, token, foreign, session.ID, input.TurnID, other.ID) + var stderr bytes.Buffer + command.Stderr = &stderr + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + stdin, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { cancel(); _ = command.Wait() }() + if line, err := bufio.NewReader(stdout).ReadString('\n'); err != nil || line != "saved\n" { + t.Fatalf("SDK admission: %s %v %s", line, err, stderr.String()) + } + for _, id := range []string{"a", "b", "c", "rollback", "late"} { + call, err := s.GetFunctionCall(ctx, tenant, session.ID, input.TurnID, id) + if err != nil || call.Applied { + t.Fatal(call, err) + } + var result map[string]json.RawMessage + if call.Result != nil { + if err := json.Unmarshal(call.Result, &result); err != nil { + t.Fatal(err) + } + } + switch id { + case "a": + var parts []map[string]any + _ = json.Unmarshal(result["output"], &parts) + if string(result["success"]) != "false" || string(result["error"]) != `"failure"` || len(parts) != 3 || parts[0]["text"] != "" || parts[1]["image_url"] != "data:image/png;base64,AA==" || parts[2]["text"] != "last" { + t.Fatal(result) + } + case "b": + if string(result["output"]) != "null" || string(result["error"]) != "null" { + t.Fatal(result) + } + case "c": + if len(result) != 1 || string(result["success"]) != "true" { + t.Fatal(result) + } + default: + if call.Result != nil { + t.Fatal("failed batch saved a result", call) + } + } + } + history, err := s.ListTurnInputs(ctx, tenant, session.ID, input.TurnID, 0, 100) + if err != nil || len(history) != 6 { + t.Fatal(history, err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnWaiting, Status: store.TurnFailed}); err != nil { + t.Fatal(err) + } + next, err := s.SubmitMessage(ctx, tenant, session.ID, "next", json.RawMessage(`{"text":"next"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := stdin.Write([]byte("terminal\n")); err != nil { + t.Fatal(err) + } + if err := command.Wait(); err != nil { + t.Fatalf("SDK retry: %v %s", err, stderr.String()) + } + history, err = s.ListTurnInputs(ctx, tenant, session.ID, next.TurnID, 0, 100) + if err != nil || len(history) != 1 { + t.Fatal(history, err) + } + current, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) + if err != nil || current.Status != store.TurnQueued || !current.CancelRequestedAt.IsZero() { + t.Fatal(current, err) + } +} diff --git a/services/agents-api/internal/store/function_inputs_test.go b/services/agents-api/internal/store/function_inputs_test.go new file mode 100644 index 000000000..a81544798 --- /dev/null +++ b/services/agents-api/internal/store/function_inputs_test.go @@ -0,0 +1,219 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "sync" + "testing" + + "github.com/google/uuid" +) + +func resultInput(t *testing.T, turn, call, result string) Input { + t.Helper() + raw, err := json.Marshal(FunctionResultInput{TurnID: turn, CallID: call, Result: json.RawMessage(result)}) + if err != nil { + t.Fatal(err) + } + return Input{Kind: "tool_result", Payload: raw} +} + +func functionInputFixture(t *testing.T, s *Store) (string, Session, string) { + t.Helper() + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + for _, id := range []string{"a", "b"} { + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { + t.Fatal(err) + } + } + return tenant, session, turn +} + +func TestFunctionInputBatchesPersistAndReplayWithoutRetargeting(t *testing.T) { + s, pool := testStore(t) + tenant, session, turn := functionInputFixture(t, s) + full := `{"success":false,"output":[{"type":"input_text","text":""},{"type":"input_image","image_url":"data:image/png;base64,AA=="},{"type":"input_text","text":"after"}],"error":"failed"}` + batch := []Input{resultInput(t, turn, "a", full), {Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}, resultInput(t, turn, "b", `{"success":true,"output":null,"error":null}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} + receipts, err := s.SubmitInputs(t.Context(), tenant, session.ID, "batch", batch) + if err != nil || len(receipts) != 4 { + t.Fatal(receipts, err) + } + for i, receipt := range receipts { + if receipt.Replayed || receipt.TurnID != turn || (i > 0 && receipt.Sequence <= receipts[i-1].Sequence) { + t.Fatal(receipts) + } + } + call, err := s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "a") + got, _ := canonicalJSONObject(call.Result) + want, _ := canonicalJSONObject(json.RawMessage(full)) + if err != nil || call.Applied || string(got) != string(want) { + t.Fatal(call, err) + } + // A result retry and its messages stay attached to their first Turn after restart. + transition(t, s, tenant, session.ID, turn, TurnWaiting, TurnFailed) + next := submitMessage(t, s, tenant, session.ID, "next").TurnID + pool.Close() + s, _ = testStore(t) + retry, err := s.SubmitInputs(t.Context(), tenant, session.ID, "batch", batch) + if err != nil || len(retry) != len(receipts) { + t.Fatal(retry, err) + } + for i := range retry { + if !retry[i].Replayed { + t.Fatal(retry) + } + retry[i].Replayed = false + } + if !reflect.DeepEqual(retry, receipts) { + t.Fatal(retry, receipts) + } + history, err := s.ListTurnInputs(t.Context(), tenant, session.ID, turn, 0, 100) + if err != nil || len(history) != 5 || history[1].Kind != "tool_result" || history[2].Kind != "message" { + t.Fatal(history, err) + } + future, err := s.ListTurnInputs(t.Context(), tenant, session.ID, next, 0, 100) + if err != nil || len(future) != 1 { + t.Fatal(future, err) + } + current, err := s.GetTurn(t.Context(), tenant, session.ID, next) + if err != nil || !current.CancelRequestedAt.IsZero() || current.Status != TurnQueued { + t.Fatal(current, err) + } + changed := []Input{batch[1], batch[0], batch[2], batch[3]} + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "batch", changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + // A new request identity can repeat an identical saved result, without native application. + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "same-result", batch[:1]); err != nil { + t.Fatal(err) + } + call, err = s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "a") + if err != nil || call.Applied { + t.Fatal(call, err) + } +} + +func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { + for _, mode := range []string{"missing-call", "foreign-turn", "same-tenant-turn", "cancel-first", "changed-result"} { + t.Run(mode, func(t *testing.T) { + s, _ := testStore(t) + tenant, session, turn := functionInputFixture(t, s) + message := Input{Kind: "message", Payload: json.RawMessage(`{"text":"Must roll back"}`)} + cancel := Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} + first := resultInput(t, turn, "a", `{"success":true}`) + batch := []Input{message, first, cancel} + expected := ErrNotFound + switch mode { + case "missing-call": + batch = append(batch, resultInput(t, turn, "missing", `{"success":true}`)) + case "foreign-turn", "same-tenant-turn": + otherTenant := tenant + if mode == "foreign-turn" { + otherTenant = uuid.NewString() + } + other, err := s.CreateSession(t.Context(), otherTenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "other"}) + if err != nil { + t.Fatal(err) + } + otherTurn := submitMessage(t, s, otherTenant, other.ID, "start").TurnID + transition(t, s, otherTenant, other.ID, otherTurn, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), otherTenant, other.ID, otherTurn, functionCallFixture("a")); err != nil { + t.Fatal(err) + } + batch = append(batch, resultInput(t, otherTurn, "a", `{"success":true}`)) + case "cancel-first": + batch = []Input{message, cancel, first} + expected = ErrTurnConflict + case "changed-result": + batch = append(batch, resultInput(t, turn, "a", `{"success":false}`)) + expected = ErrIdempotencyConflict + } + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "failed-batch", batch); !errors.Is(err, expected) { + t.Fatal(err) + } + call, err := s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "a") + if err != nil || call.Result != nil || call.Applied { + t.Fatal(call, err) + } + state, err := s.GetTurn(t.Context(), tenant, session.ID, turn) + if err != nil || !state.CancelRequestedAt.IsZero() || state.Status != TurnWaiting { + t.Fatal(state, err) + } + history, err := s.ListTurnInputs(t.Context(), tenant, session.ID, turn, 0, 100) + if err != nil || len(history) != 1 { + t.Fatal(history, err) + } + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "failed-batch", []Input{first, cancel}); err != nil { + t.Fatal("failed transaction retained retry identity", err) + } + }) + } +} + +func TestFunctionInputConcurrentBatchesSelectOneResult(t *testing.T) { + s, _ := testStore(t) + other, _ := testStore(t) + tenant, session, turn := functionInputFixture(t, s) + var wg sync.WaitGroup + results := make(chan error, 2) + for i := range 2 { + batch := []Input{{Kind: "message", Payload: json.RawMessage(fmt.Sprintf(`{"text":"message-%d"}`, i))}, resultInput(t, turn, "a", fmt.Sprintf(`{"success":true,"output":"%d"}`, i))} + wg.Add(1) + go func() { + defer wg.Done() + _, err := other.SubmitInputs(t.Context(), tenant, session.ID, fmt.Sprint(i), batch) + results <- err + }() + } + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrIdempotencyConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatal(wins, conflicts) + } + history, err := s.ListTurnInputs(t.Context(), tenant, session.ID, turn, 0, 100) + if err != nil || len(history) != 3 { + t.Fatal(history, err) + } +} + +func TestFunctionInputsRejectInvalidTargetsAndStorageObjects(t *testing.T) { + s, _ := testStore(t) + tenant, session, turn := functionInputFixture(t, s) + for _, raw := range []string{`{}`, `{"turn_id":"bad","call_id":"a","result":{}}`, fmt.Sprintf(`{"turn_id":%q,"call_id":"a"}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":null}`, turn), fmt.Sprintf(`{"turn_id":%q,"call_id":"a","result":[]}`, turn)} { + _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "invalid", []Input{{Kind: "tool_result", Payload: json.RawMessage(raw)}}) + if !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + } + input := resultInput(t, turn, "a", `{"success":true}`) + for _, scope := range []struct{ tenant, session string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}} { + if _, err := s.SubmitInputs(t.Context(), scope.tenant, scope.session, "foreign", []Input{input}); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + } + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "missing-turn", []Input{resultInput(t, uuid.NewString(), "a", `{"success":true}`)}); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + transition(t, s, tenant, session.ID, turn, TurnWaiting, TurnFailed) + if _, err := s.SubmitInputs(t.Context(), tenant, session.ID, "late", []Input{input}); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + current, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || current.LastTurn.ID != turn || current.LastTurn.Status != TurnFailed { + t.Fatal(current, err) + } +} diff --git a/services/agents-api/internal/store/function_item_events_test.go b/services/agents-api/internal/store/function_item_events_test.go new file mode 100644 index 000000000..307288616 --- /dev/null +++ b/services/agents-api/internal/store/function_item_events_test.go @@ -0,0 +1,124 @@ +package store + +import ( + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "reflect" + "testing" +) + +func TestFunctionResultEventsAreInputs(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + events := []ExecutionEvent{ + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"call","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","arguments":{},"content":[{"type":"input_text","text":"result"}]}}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"answer"}`)}, + } + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, events); err != nil { + t.Fatal(err) + } + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + results := 0 + for _, change := range changes { + event := change.Event + if event.Item == nil { + continue + } + if event.Item.Type == "function_call_output" { + results++ + if event.Type != "agent.session.turn.item.added" || event.OutputIndex != nil { + t.Fatal("function result is not agent output", event) + } + } + if event.Item.Type == "message" && event.Item.Role == "assistant" && (event.OutputIndex == nil || *event.OutputIndex != 1) { + t.Fatal("function result consumed an output index", event) + } + } + page, err := s.ListItems(t.Context(), tenant, session.ID, "", 100, true) + if err != nil || results != 1 { + t.Fatal(page, results, err) + } + found := false + for _, item := range page.Items { + if item.Type == "function_call_output" { + found = true + } + } + if !found { + t.Fatal("function result missing from recovery items") + } +} + +func TestFunctionResultItemsRetainSubmittedFields(t *testing.T) { + for _, raw := range []string{ + `{"success":true}`, + `{"success":false,"output":null,"error":null}`, + `{"success":true,"output":"original"}`, + `{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AA=="}],"error":"failure"}`, + } { + t.Run(raw, func(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + call := functionCallFixture(items.Identity(turn, "tool:call")) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, call); err != nil { + t.Fatal(err) + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, call.CallID, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + event := ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(`{"id":"call","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","arguments":{},"content":[{"type":"input_text","text":"normalized"}]}}`)} + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, []ExecutionEvent{event}); err != nil { + t.Fatal(err) + } + assertFields := func(value any) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var expected, actual map[string]any + if json.Unmarshal([]byte(raw), &expected) != nil || json.Unmarshal(encoded, &actual) != nil { + t.Fatal(string(encoded)) + } + for _, field := range []string{"output", "error"} { + wanted, present := expected[field] + got, exists := actual[field] + if present != exists || !reflect.DeepEqual(wanted, got) { + t.Fatalf("%s changed: %s", field, encoded) + } + } + } + page, err := s.ListItems(t.Context(), tenant, session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + results := 0 + for _, item := range page.Items { + if item.Type == "function_call_output" { + assertFields(item) + results++ + } + } + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + for _, change := range changes { + if change.Event.Item != nil && change.Event.Item.Type == "function_call_output" { + assertFields(change.Event.Item) + results++ + } + } + if results != 2 { + t.Fatal("missing saved or streamed result", results) + } + }) + } +} diff --git a/services/agents-api/internal/store/function_model_test.go b/services/agents-api/internal/store/function_model_test.go new file mode 100644 index 000000000..814f477b2 --- /dev/null +++ b/services/agents-api/internal/store/function_model_test.go @@ -0,0 +1,121 @@ +package store_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" +) + +func nativeFunctionModel(t *testing.T, home string) (*httptest.Server, []any, *atomic.Int32) { + t.Helper() + picture := image.NewRGBA(image.Rect(0, 0, 1, 1)) + picture.Set(0, 0, color.RGBA{R: 255, A: 255}) + var encoded bytes.Buffer + if err := png.Encode(&encoded, picture); err != nil { + t.Fatal(err) + } + output := []any{map[string]any{"type": "input_text", "text": "before"}, map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())}, map[string]any{"type": "input_text", "text": "after"}} + expected := append([]any(nil), output...) + // Codex adds its default image detail at the model transport boundary. + expected[1] = map[string]any{"type": "input_image", "image_url": output[1].(map[string]any)["image_url"], "detail": "high"} + failure := append(append([]any(nil), expected...), map[string]any{"type": "input_text", "text": "synthetic failure"}) + model, requests := nativeFunctionResultsModel(t, home, []any{expected, failure}) + return model, output, requests +} + +func nativeFunctionResultsModel(t *testing.T, home string, results []any) (*httptest.Server, *atomic.Int32) { + t.Helper() + var requests atomic.Int32 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + assertNativeSubagentsDisabled(t, body) + n := requests.Add(1) + raw, _ := json.MarshalIndent(body, "", " ") + _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("functions-model-%d.json", n)), raw, 0600) + var config struct { + Agent struct{ Tools []map[string]any } + } + if err := json.Unmarshal([]byte(functionConfiguration), &config); err != nil { + t.Error(err) + return + } + expectedTool := config.Agent.Tools[0] + delete(expectedTool, "defer_loading") + expectedTool["strict"] = false + foundTool := false + for _, tool := range body["tools"].([]any) { + if reflect.DeepEqual(tool, expectedTool) { + foundTool = true + } + } + if !foundTool { + t.Error("configured function changed at the model boundary") + } + var entry map[string]any + if n%2 == 1 { + entry = map[string]any{"id": fmt.Sprintf("fc_%d", n), "type": "function_call", "call_id": fmt.Sprintf("call_%d", n), "name": "lookup_ticket", "arguments": `{"ticket":"42"}`, "status": "completed"} + } else { + found := false + for _, value := range body["input"].([]any) { + item := value.(map[string]any) + if item["type"] != "function_call_output" || item["call_id"] != fmt.Sprintf("call_%d", n-1) { + continue + } + found = true + if int(n/2) > len(results) { + t.Error("unexpected native result continuation", n) + return + } + expected := results[n/2-1] + if !reflect.DeepEqual(item["output"], expected) { + t.Errorf("complete result changed: %v", item["output"]) + } + } + if !found { + t.Error("native model did not receive stored result") + } + entry = map[string]any{"id": fmt.Sprintf("message_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "FUNCTION-EXECUTION-OK", "annotations": []any{}}}} + } + w.Header().Set("Content-Type", "text/event-stream") + send := func(kind string, data map[string]any) { + data["type"] = kind + raw, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, raw) + w.(http.Flusher).Flush() + } + send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "status": "in_progress", "output": []any{}}}) + send("response.output_item.added", map[string]any{"output_index": 0, "item": entry}) + send("response.output_item.done", map[string]any{"output_index": 0, "item": entry}) + send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "object": "response", "created_at": 0, "status": "completed", "model": "gpt-5.5", "output": []any{entry}}}) + })) + return model, &requests +} + +func assertNativeSubagentsDisabled(t *testing.T, body map[string]any) { + t.Helper() + raw, err := json.Marshal(body["tools"]) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"Multi-agent tools:", "spawn_agent", "send_input", "wait_agent", "resume_agent", "close_agent", "send_message_to_agent"} { + if strings.Contains(string(raw), forbidden) { + t.Errorf("disabled subagent tool remains discoverable: %s", forbidden) + } + } +} diff --git a/services/agents-api/internal/store/function_public_native_test.go b/services/agents-api/internal/store/function_public_native_test.go new file mode 100644 index 000000000..54bfae2d5 --- /dev/null +++ b/services/agents-api/internal/store/function_public_native_test.go @@ -0,0 +1,96 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativePublicFunctionExecution(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + h, ctx, home := nativeDispatchHarness(t) + model, output, requests := nativeFunctionModel(t, home) + defer model.Close() + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"enable_features": []any{"multi_agent", "multi_agent_v2"}, "codex_provider": map[string]any{"base_url": model.URL + "/v1", "bearer_token": "synthetic-test-token"}}, nil + } + serverURL, token := nativePublicFunctionServer(t, h, ctx) + outputPath, proofPath := filepath.Join(home, "function-output.json"), filepath.Join(home, "public-functions.json") + raw, _ := json.Marshal(output) + if err := os.WriteFile(outputPath, raw, 0600); err != nil { + t.Fatal(err) + } + command := exec.CommandContext(ctx, python, "../../tests/official_functions.py", serverURL, token, outputPath, proofPath) + if log, err := command.CombinedOutput(); err != nil { + t.Fatalf("official native functions: %v %s", err, log) + } + var proof struct { + Session string `json:"session"` + Turns []string `json:"turns"` + Calls []string `json:"calls"` + } + raw, err := os.ReadFile(proofPath) + if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Turns) != 3 || len(proof.Calls) != 3 { + t.Fatal(proof, err) + } + for i, callID := range proof.Calls { + call, err := h.s.GetFunctionCall(ctx, h.tenant, proof.Session, proof.Turns[i], callID) + if err != nil || call.Applied != (i < 2) { + t.Fatal(call, err) + } + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || bound.NativeSessionID == "" || bound.Device.ID != h.device.ID { + t.Fatal(bound, err) + } + if requests.Load() != 5 { + t.Fatal("unexpected replay or missing native continuation", requests.Load()) + } + t.Logf("Official SDK configured functions, native text/image/error results, application receipts, next Turn and cancellation passed; evidence %s", home) +} + +func nativePublicFunctionServer(t *testing.T, h *dispatchHarness, ctx context.Context) (string, string) { + t.Helper() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }) + token := uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server.URL, token +} diff --git a/services/agents-api/internal/store/function_results.go b/services/agents-api/internal/store/function_results.go new file mode 100644 index 000000000..d92ac070d --- /dev/null +++ b/services/agents-api/internal/store/function_results.go @@ -0,0 +1,88 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// SubmitFunctionResult stores a caller-validated result object; its wire schema belongs to the API. +func (s *Store) SubmitFunctionResult(ctx context.Context, tenantID, sessionID, turnID, callID string, result json.RawMessage) error { + if len(result) == 0 || len(result) > 512*1024 { + return ErrInvalidInput + } + result, err := canonicalJSONObject(result) + if err != nil { + return err + } + return s.withFunctionCall(ctx, tenantID, sessionID, turnID, callID, func(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, call sqlc.FunctionCall) error { + return storeFunctionResult(ctx, q, turn, call.CallID, result) + }) +} + +// ConfirmFunctionResult records native application, not external tool success. +func (s *Store) ConfirmFunctionResult(ctx context.Context, tenantID, sessionID, turnID, callID string) error { + return s.withFunctionCall(ctx, tenantID, sessionID, turnID, callID, func(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, call sqlc.FunctionCall) error { + if call.Applied { + return nil + } + if len(call.Result) == 0 || !acceptsFunctionResult(turn) { + return ErrTurnConflict + } + if err := q.ApplyFunctionResult(ctx, sqlc.ApplyFunctionResultParams{SessionID: turn.SessionID, TurnID: turn.ID, CallID: call.CallID}); err != nil { + return err + } + return recordFunctionState(ctx, q, turn) + }) +} + +func (s *Store) withFunctionCall(ctx context.Context, tenantID, sessionID, turnID, callID string, fn func(context.Context, *sqlc.Queries, sqlc.Turn, sqlc.FunctionCall) error) error { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return err + } + if !validFunctionIdentity(callID) { + return ErrInvalidInput + } + return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + turn, err := q.GetTurn(ctx, p) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + call, err := q.GetFunctionCall(ctx, sqlc.GetFunctionCallParams{TenantID: p.TenantID, SessionID: session, TurnID: p.ID, CallID: callID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + return fn(ctx, q, turn, call) + }) +} + +func storeFunctionResult(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, callID string, result json.RawMessage) error { + match, err := q.MatchFunctionResult(ctx, sqlc.MatchFunctionResultParams{SessionID: turn.SessionID, TurnID: turn.ID, CallID: callID, Result: result}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if match.Submitted { + if !match.Matches { + return ErrIdempotencyConflict + } + return nil + } + if !acceptsFunctionResult(turn) { + return ErrTurnConflict + } + return q.SubmitFunctionResult(ctx, sqlc.SubmitFunctionResultParams{SessionID: turn.SessionID, TurnID: turn.ID, CallID: callID, Result: result}) +} diff --git a/services/agents-api/internal/store/function_state.go b/services/agents-api/internal/store/function_state.go new file mode 100644 index 000000000..cec067d78 --- /dev/null +++ b/services/agents-api/internal/store/function_state.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" +) + +func functionActions(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn) ([]v1.FunctionCallAction, error) { + actions := make([]v1.FunctionCallAction, 0) + if !acceptsFunctionResult(turn) { + return actions, nil + } + calls, err := q.ListPendingFunctionCalls(ctx, sqlc.ListPendingFunctionCallsParams{SessionID: turn.SessionID, TurnID: turn.ID}) + if err != nil { + return nil, err + } + for _, call := range calls { + actions = append(actions, v1.FunctionCallAction{Type: "function_call", CallID: call.CallID, Name: call.Name, TurnID: uuid.UUID(turn.ID.Bytes).String(), Arguments: json.RawMessage(call.Arguments)}) + } + return actions, nil +} + +func recordFunctionState(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn) error { + actions, err := functionActions(ctx, q, turn) + if err != nil { + return err + } + status := TurnInProgress + if len(actions) > 0 { + status = TurnWaiting + } + if turn.Status != status { + turn, err = q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ID: turn.ID, SessionID: turn.SessionID, ExpectedStatus: turn.Status, NewStatus: status, Outcome: []byte(`{}`)}) + if err != nil { + return err + } + if err := recordTurnChange(ctx, q, turn, false); err != nil { + return err + } + } + return recordSessionActivity(ctx, q, turn, actions) +} + +func recordSessionActivity(ctx context.Context, q *sqlc.Queries, row sqlc.Turn, actions []v1.FunctionCallAction) error { + turn := turnFromRow(row) + turn.Outcome = nil + status := "in_progress" + if terminalStatus(row.Status) { + status = "idle" + if row.Status == TurnFailed { + status = "failed" + } + } else if len(actions) > 0 { + status = "requires_action" + } + usage, err := q.SessionTokenUsage(ctx, row.SessionID) + if err != nil { + return err + } + return recordSessionChange(ctx, q, row.SessionID, SessionChange{ + Event: v1.SessionEvent{Type: "agent.session." + status}, Turn: &turn, + SessionUsage: usage, RequiredActions: actions, + }) +} diff --git a/services/agents-api/internal/store/function_state_public_test.go b/services/agents-api/internal/store/function_state_public_test.go new file mode 100644 index 000000000..a971118a2 --- /dev/null +++ b/services/agents-api/internal/store/function_state_public_test.go @@ -0,0 +1,91 @@ +package store_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestFunctionStateOfficialClientReadsAndLiveEvents(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("PARSAR_OFFICIAL_SDK_PYTHON is required for official-client verification") + } + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + cfg := json.RawMessage(`{"agent":{"id":"agent_fixture","model":"fixture","tools":[],"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"}},"environment":{"type":"none"}}`) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "fixture", Configuration: cfg}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"fixture"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + record := func(id string) { + t.Helper() + if err := s.RecordFunctionCall(ctx, tenant, session.ID, input.TurnID, store.FunctionCall{CallID: id, ExecutorCallID: "private-" + id, Name: "lookup", Arguments: json.RawMessage(`{"ticket":9007199254740993}`)}); err != nil { + t.Fatal(err) + } + } + record("first") + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, "codex") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + command := exec.CommandContext(ctx, python, "../../tests/official_function_state.py", server.URL, token, foreign, session.ID, input.TurnID) + var stderr bytes.Buffer + command.Stderr = &stderr + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { cancel(); _ = command.Wait() }() + reader := bufio.NewReader(stdout) + if line, err := reader.ReadString('\n'); err != nil || line != "connected\n" { + cancel() + _ = command.Wait() + t.Fatalf("SDK readiness: %s %v %s", line, err, stderr.String()) + } + record("second") + for _, id := range []string{"first", "second"} { + if err := s.SubmitFunctionResult(ctx, tenant, session.ID, input.TurnID, id, json.RawMessage(`{"success":true,"output":"private"}`)); err != nil { + t.Fatal(err) + } + if err := s.ConfirmFunctionResult(ctx, tenant, session.ID, input.TurnID, id); err != nil { + t.Fatal(err) + } + } + if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCompleted, nil, "", input.Sequence); err != nil { + t.Fatal(err) + } + if err := command.Wait(); err != nil { + t.Fatalf("official function-state verification: %v\n%s", err, stderr.String()) + } + t.Log("Pinned official client verified persisted waiting reads, isolation, exact event fields and changing action snapshots") +} diff --git a/services/agents-api/internal/store/function_state_test.go b/services/agents-api/internal/store/function_state_test.go new file mode 100644 index 000000000..18a66c40e --- /dev/null +++ b/services/agents-api/internal/store/function_state_test.go @@ -0,0 +1,178 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + + "github.com/google/uuid" +) + +func TestFunctionStateSnapshotsRecoveryAndRetries(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + before, err := s.SessionEventCursor(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"first", "second"} { + for range 2 { + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { + t.Fatal(err) + } + } + } + assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) + for _, id := range []string{"first", "second"} { + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":true,"output":"private result"}`)); err != nil { + t.Fatal(err) + } + } + assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) + pool.Close() + s, _ = testStore(t) + assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 2) + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, turn, TurnCompleted, nil, "", 1); !errors.Is(err, ErrTurnConflict) { + t.Fatal("waiting execution completed", err) + } + for i, id := range []string{"first", "second"} { + for range 2 { + if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, id); err != nil { + t.Fatal(err) + } + } + status := TurnWaiting + if i == 1 { + status = TurnInProgress + } + assertFunctionState(t, s, tenant, session.ID, status, 1-i) + } + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, before) + if err != nil { + t.Fatal(err) + } + counts := []int{1, 2, 1, 0, 0} + types := []string{"agent.session.requires_action", "agent.session.requires_action", "agent.session.requires_action", "agent.session.turn.in_progress", "agent.session.in_progress"} + if len(changes) != len(counts) { + t.Fatalf("duplicate or missing events: %+v", changes) + } + for i, change := range changes { + if change.Event.Type != types[i] || len(change.RequiredActions) != counts[i] { + t.Fatalf("snapshot %d: %+v", i, change) + } + if counts[i] > 0 { + raw, err := json.Marshal(change.RequiredActions[0].Arguments) + if err != nil || string(raw) != `{"ticket":9007199254740993}` { + t.Fatal("argument precision lost", string(raw), err) + } + if change.Turn.Status != TurnWaiting { + t.Fatal(change.Turn) + } + } + } + if _, err := s.GetSession(t.Context(), uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.ListSessionEvents(t.Context(), uuid.NewString(), session.ID, before); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } +} + +func TestFunctionStateCancellationAndTerminalCleanup(t *testing.T) { + for _, status := range []string{TurnCancelled, TurnFailed, TurnCompleted} { + t.Run(status, func(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + input := submitMessage(t, s, tenant, session.ID, "start") + transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, input.TurnID, functionCallFixture("call")); err != nil { + t.Fatal(err) + } + if status == TurnCancelled { + before, _ := s.SessionEventCursor(t.Context(), tenant, session.ID) + for _, key := range []string{"cancel", "cancel", "another-cancel"} { + if _, err := s.RequestCancel(t.Context(), tenant, session.ID, key); err != nil { + t.Fatal(err) + } + } + assertFunctionState(t, s, tenant, session.ID, TurnWaiting, 0) + changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, before) + if err != nil || len(changes) != 1 || changes[0].Event.Type != "agent.session.in_progress" || len(changes[0].RequiredActions) != 0 || changes[0].Turn.CancelRequestedAt.IsZero() { + t.Fatal(changes, err) + } + } + if status == TurnCompleted { + transition(t, s, tenant, session.ID, input.TurnID, TurnWaiting, status) + } else if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, status, nil, "", input.Sequence); err != nil { + t.Fatal(err) + } + assertFunctionState(t, s, tenant, session.ID, status, 0) + if _, err := s.GetFunctionCall(t.Context(), tenant, session.ID, input.TurnID, "call"); err != nil { + t.Fatal("history lost", err) + } + next := submitMessage(t, s, tenant, session.ID, "next") + if next.TurnID == input.TurnID { + t.Fatal("terminal turn reused") + } + assertFunctionState(t, s, tenant, session.ID, TurnQueued, 0) + }) + } +} + +func TestFunctionStateReadsRemainConsistentDuringReceipts(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + turn := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, s, tenant, session.ID, turn, TurnQueued, TurnInProgress) + var wg sync.WaitGroup + done := make(chan struct{}) + wg.Go(func() { + defer close(done) + for i := range 30 { + id := fmt.Sprint(i) + if err := s.RecordFunctionCall(t.Context(), tenant, session.ID, turn, functionCallFixture(id)); err != nil { + t.Error(err) + return + } + if err := s.SubmitFunctionResult(t.Context(), tenant, session.ID, turn, id, json.RawMessage(`{"success":true}`)); err != nil { + t.Error(err) + return + } + if err := s.ConfirmFunctionResult(t.Context(), tenant, session.ID, turn, id); err != nil { + t.Error(err) + return + } + } + }) + defer wg.Wait() + for { + current, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if (current.LastTurn.Status == TurnWaiting) != (len(current.RequiredActions) > 0) { + t.Fatalf("torn activity snapshot: %+v", current) + } + select { + case <-done: + return + default: + } + } +} + +func assertFunctionState(t *testing.T, s *Store, tenant, sessionID, status string, count int) { + t.Helper() + current, err := s.GetSession(t.Context(), tenant, sessionID) + if err != nil || current.LastTurn == nil || current.LastTurn.Status != status || len(current.RequiredActions) != count { + t.Fatalf("state: %+v; %v", current, err) + } + page, err := s.ListSessions(t.Context(), tenant, "", 10, true, nil) + if err != nil || len(page.Sessions) != 1 || len(page.Sessions[0].RequiredActions) != count || page.Sessions[0].LastTurn.Status != status { + t.Fatal("list differs from retrieve", page, err) + } +} diff --git a/services/agents-api/internal/store/function_stream_native_test.go b/services/agents-api/internal/store/function_stream_native_test.go new file mode 100644 index 000000000..a1b82fb35 --- /dev/null +++ b/services/agents-api/internal/store/function_stream_native_test.go @@ -0,0 +1,57 @@ +package store_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePublicFunctionStreamHelper(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + h, ctx, home := nativeDispatchHarness(t) + model, requests := nativeFunctionResultsModel(t, home, []any{ + `{"ticket":"42","status":"open"}`, + "Tool handler failed.", + }) + defer model.Close() + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"codex_provider": map[string]any{"base_url": model.URL + "/v1", "bearer_token": "synthetic-test-token"}}, nil + } + serverURL, token := nativePublicFunctionServer(t, h, ctx) + proofPath := filepath.Join(home, "public-function-stream.json") + command := exec.CommandContext(ctx, python, "../../tests/official_function_stream.py", serverURL, token, proofPath) + if log, err := command.CombinedOutput(); err != nil { + t.Fatalf("official native stream helper: %v %s", err, log) + } + var proof struct { + Session string `json:"session"` + Turns []string `json:"turns"` + Calls []string `json:"calls"` + } + raw, err := os.ReadFile(proofPath) + if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Turns) != 2 || len(proof.Calls) != 2 { + t.Fatal(proof, err) + } + for i, callID := range proof.Calls { + call, err := h.s.GetFunctionCall(ctx, h.tenant, proof.Session, proof.Turns[i], callID) + if err != nil || !call.Applied { + t.Fatal(call, err) + } + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || bound.NativeSessionID == "" || bound.Device.ID != h.device.ID { + t.Fatal(bound, err) + } + if requests.Load() != 4 { + t.Fatal("unexpected replay or missing native continuation", requests.Load()) + } + t.Logf("Official SDK stream tool handlers, error omission, public history and native application passed; evidence %s", home) +} diff --git a/services/agents-api/internal/store/function_worker_test.go b/services/agents-api/internal/store/function_worker_test.go new file mode 100644 index 000000000..3fed06915 --- /dev/null +++ b/services/agents-api/internal/store/function_worker_test.go @@ -0,0 +1,154 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestWorkerWaitsForToolCapabilities(t *testing.T) { + for _, missing := range []string{"durable_input_receipts", "execution_controls", "function_tools", "tool_observations", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"} { + for _, prebound := range []bool{false, true} { + t.Run(missing+"/"+map[bool]string{false: "select", true: "bound"}[prebound], func(t *testing.T) { + h := newFunctionHarness(t) + configuration := functionConfiguration + isMCP := strings.HasPrefix(missing, "mcp_http_") + token := "" + if isMCP { + configuration = mcpWorkerConfiguration + } + if missing == "mcp_http_bearer_auth" { + configuration, token = mcpBearerWorkerConfiguration(t, h) + } + if missing == "mcp_http_required" { + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + } + if !prebound || isMCP { + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "unbound", Configuration: []byte(configuration)}) + if err != nil { + t.Fatal(err) + } + } + if prebound && isMCP { + if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + } + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: missing != "durable_input_receipts", EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: missing != "execution_controls", SubagentControl: true, ToolObservations: missing != "tool_observations", MCPHTTPTools: missing != "mcp_http_tools", MCPHTTPRequired: missing != "mcp_http_required", MCPHTTPBearerAuth: missing != "mcp_http_bearer_auth", FunctionTools: missing != "function_tools" && !isMCP} + heartbeat := func() { + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + } + heartbeat() + deadline := time.Now().Add(3 * time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("codex") + if info.Capabilities.ExecutionControls == caps.ExecutionControls && info.Capabilities.FunctionTools == caps.FunctionTools && info.Capabilities.ToolObservations == caps.ToolObservations && info.Capabilities.MCPHTTPTools == caps.MCPHTTPTools && info.Capabilities.MCPHTTPBearerAuth == caps.MCPHTTPBearerAuth && info.Capabilities.MCPHTTPRequired == caps.MCPHTTPRequired { + break + } + if time.Now().After(deadline) { + t.Fatal("heartbeat not applied") + } + time.Sleep(10 * time.Millisecond) + } + input := h.message("queued", "Look up ticket") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + time.Sleep(650 * time.Millisecond) + current, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || current.Status != store.TurnQueued { + t.Fatal(current, err) + } + if !prebound { + if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("bound an incapable device", err) + } + } + caps.DurableInputReceipts, caps.ExecutionControls, caps.ToolObservations, caps.MCPHTTPTools = true, true, true, true + caps.MCPHTTPBearerAuth, caps.FunctionTools, caps.MCPHTTPRequired = true, !isMCP, true + heartbeat() + request := h.read(proto.TypePromptRequest) + var prompt proto.PromptRequestPayload + if request.DecodePayload(&prompt) != nil { + t.Fatal("invalid prompt") + } + if isMCP { + if prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 || (*prompt.MCPHTTPServers)[0].ServerLabel != "tickets" || (*prompt.MCPHTTPServers)[0].AllowedTools == nil || len(*(*prompt.MCPHTTPServers)[0].AllowedTools) != 0 || len(prompt.FunctionTools) != 0 { + t.Fatal("MCP declaration lost during dispatch") + } + if (*prompt.MCPHTTPServers)[0].Required != (missing == "mcp_http_required") { + t.Fatal("dispatch changed required initialization") + } + bearer := (*prompt.MCPHTTPServers)[0].BearerToken + if token != "" && (bearer == nil || *bearer != token) || token == "" && bearer != nil { + t.Fatal("dispatch lost selected authentication or authenticated an anonymous server") + } + } else if len(prompt.FunctionTools) != 1 || prompt.FunctionTools[0].Name != "lookup_ticket" { + t.Fatal(prompt) + } + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + waitTurn(t, h, input.TurnID, store.TurnCompleted) + }) + } + } +} + +const mcpWorkerConfiguration = `{"agent":{"model":"gpt-5.5","tools":[{"type":"mcp","server_label":"tickets","transport":{"type":"http","server_url":"http://127.0.0.1:9191/mcp"},"connection_origin":"service","allowed_tools":[],"credential_id":null,"request_metadata":{},"required":false}]},"environment":{"type":"none"}}` + +func mcpBearerWorkerConfiguration(t *testing.T, h *dispatchHarness) (string, string) { + t.Helper() + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New([]byte(strings.Repeat("k", 32))) + if err != nil { + t.Fatal(err) + } + h.s = store.NewWithCredentialCipher(pool, cipher) + h.d.Store = h.s + vault, err := h.s.CreateVault(t.Context(), h.tenant, store.CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + token, endpoint := uuid.NewString(), "https://mcp.example/tools" + _, err = h.s.CreateStaticCredential(t.Context(), h.tenant, vault.ID, store.CreateStaticCredentialInput{Name: "worker", MCPServerURL: endpoint, Token: token}) + if err != nil { + t.Fatal(err) + } + var snapshot execution.Snapshot + if json.Unmarshal([]byte(strings.ReplaceAll(mcpWorkerConfiguration, "http://127.0.0.1:9191/mcp", endpoint)), &snapshot) != nil { + t.Fatal("invalid worker fixture") + } + snapshot.VaultIDs = []string{vault.ID} + snapshot.MCPCredentials, err = h.s.ResolveMCPCredentials(t.Context(), h.tenant, snapshot.VaultIDs, []store.MCPCredentialRequest{{ServerLabel: "tickets", ServerURL: endpoint}}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(snapshot) + if err != nil || strings.Contains(string(raw), token) { + t.Fatal("unsafe worker configuration") + } + return string(raw), token +} diff --git a/services/agents-api/internal/store/harness_authorization_test.go b/services/agents-api/internal/store/harness_authorization_test.go new file mode 100644 index 000000000..0e003518b --- /dev/null +++ b/services/agents-api/internal/store/harness_authorization_test.go @@ -0,0 +1,125 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func TestHarnessGrantsCheckPostgreSQLTenantOwnership(t *testing.T) { + s, _ := store.NewTestStore(t) + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenants := []string{uuid.NewString(), uuid.NewString()} + sessions, environments := []string{}, []string{} + for _, tenant := range tenants { + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "harness-scope", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + sessions = append(sessions, session.ID) + environments = append(environments, environment.ID) + } + principal := store.FixtureExecutorPrincipal(t, s, tenants[0]) + credential, err := s.IssueExecutorCredential(t.Context(), principal, environments[0], environments[0]) + if err != nil { + t.Fatal(err) + } + executorToken := credential.Token + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + tokens := []string{} + for i, tenant := range tenants { + token, release, err := registry.IssueHarnessCredential(t.Context(), tenant, environments[i]) + if err != nil { + t.Fatal(err) + } + defer release() + tokens = append(tokens, token) + } + if _, _, err := registry.IssueHarnessCredential(t.Context(), tenants[1], environments[0]); !errors.Is(err, store.ErrNotFound) { + t.Fatal("cross-tenant harness issuance accepted", err) + } + + server.Config.Handler = registry.Handler() + server.Start() + defer func() { registry.Close(); server.Close() }() + post := func(environment, route, token string, body any, status int, result any) { + t.Helper() + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/cloud/environment/"+environment+"/"+route, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != status { + t.Fatalf("%s status %d, expected %d", route, resp.StatusCode, status) + } + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + t.Fatal(err) + } + } + } + publicKey := codex.PublicKey{Suite: "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256", X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))} + var registration codex.RegistrationResponse + post(environments[0], "register", executorToken, codex.RegistrationRequest{SecurityProfile: "noise_hybrid_ik_v1", ExecutorPublicKey: publicKey}, 200, ®istration) + executor, resp, err := websocket.DefaultDialer.DialContext(t.Context(), registration.URL, nil) + if resp != nil { + resp.Body.Close() + } + if err != nil { + t.Fatal("executor connection failed") + } + defer executor.Close() + body := codex.ConnectRequest{HarnessPublicKey: publicKey} + post(environments[1], "connect", tokens[0], body, 401, nil) + post(environments[0], "connect", tokens[1], body, 401, nil) + var grant codex.ConnectResponse + post(environments[0], "connect", tokens[0], body, 200, &grant) + if err := s.DeleteSession(t.Context(), tenants[0], sessions[0]); err != nil { + t.Fatal(err) + } + post(environments[0], "connect", tokens[0], body, 404, nil) + harness, resp, err := websocket.DefaultDialer.DialContext(t.Context(), grant.URL, nil) + if harness != nil { + harness.Close() + } + if resp != nil { + defer resp.Body.Close() + } + if err == nil || resp == nil || resp.StatusCode != 404 { + t.Fatal("deleted owning Session accepted harness") + } + if _, err := s.GetEnvironment(t.Context(), tenants[1], environments[1]); err != nil { + t.Fatal("foreign Environment affected", err) + } +} diff --git a/services/agents-api/internal/store/harness_onboarding_test.go b/services/agents-api/internal/store/harness_onboarding_test.go new file mode 100644 index 000000000..340a82043 --- /dev/null +++ b/services/agents-api/internal/store/harness_onboarding_test.go @@ -0,0 +1,277 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http/httptest" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestThirdHarnessPublicOnboarding(t *testing.T) { + h := newDispatchHarness(t) + profile := engine.Profile{Placements: []string{"none"}, ValidateConfiguration: func(a v1.Agent, _ *v1.Environment, _ bool) error { + if a.Text.Verbosity != "medium" || a.Text.Format.Type != "text" || a.MultiAgent.Enabled || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || a.ServiceTier != "auto" { + return engine.ErrInvalidInput + } + return nil + }, ValidateTools: func(_ *v1.Environment, _ bool, f []proto.FunctionTool, m []proto.MCPHTTPServer) error { + if len(f)+len(m) > 0 { + return engine.ErrInvalidInput + } + return nil + }} + policy := execution.Policy{Engines: engine.NewCatalog(map[string]engine.Profile{"fixture_harness": profile})} + h.d.Policy = policy + // The fixture only supplies an adapter and registration to the real daemon router. + // Core sees its ordinary authenticated gateway connection and neutral frames. + started, write := startOnboardingPeer(t, h) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + stopped := make(chan error, 1) + go func() { stopped <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-stopped: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }() + token := uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "fixture_harness", api.WithExecution(worker), api.WithExecutionPolicy(policy)) + if err != nil { + t.Fatal(err) + } + request := func(method, path, body string, status int) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("OpenAI-Beta", "agents=v1") + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != status { + t.Fatalf("%s %s: %d %s", method, path, res.Code, res.Body) + } + return res + } + for _, fields := range []string{`,"text":{"verbosity":"high"}`, `,"tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]`} { + request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"`+fields+`},"environment":{"type":"none"}}`, 400) + } + res := request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 200) + var created struct { + ID string `json:"id"` + } + if err = json.Unmarshal(res.Body.Bytes(), &created); err != nil || created.ID == "" { + t.Fatal(res.Body, err) + } + h.session, err = h.s.GetSession(ctx, h.tenant, created.ID) + if err != nil { + t.Fatal(err) + } + first := awaitOnboardingPrompt(t, started) + if first.AgentKind != "fixture_harness" || first.AgentSessionID != "" { + t.Fatal(first) + } + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"finish"}]}]}]}`, 204) + waitTurn(t, h, first.RunID, store.TurnCompleted) + turn, err := h.s.GetTurn(ctx, h.tenant, created.ID, first.RunID) + if err != nil { + t.Fatal(err) + } + var result execution.Result + inputs, inputErr := h.s.ListTurnInputs(ctx, h.tenant, created.ID, first.RunID, 0, 100) + if inputErr != nil || len(inputs) != 2 { + t.Fatal(inputs, inputErr) + } + if err = json.Unmarshal(turn.Outcome, &result); err != nil || result.AppliedThrough != inputs[1].Sequence || result.Done.Content != "readyfinish" { + t.Fatal(string(turn.Outcome), err) + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, created.ID) + if err != nil || bound.NativeSessionID == "" { + t.Fatal(bound, err) + } + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"hold"}]}]}]}`, 204) + next := awaitOnboardingPrompt(t, started) + if next.RunID == first.RunID || next.AgentSessionID != bound.NativeSessionID || !next.StrictResume { + t.Fatal(next) + } + request("POST", "/v1/agents/sessions/"+created.ID+"/events", `{"events":[{"type":"agent.session.input.cancel"}]}`, 204) + waitTurn(t, h, next.RunID, store.TurnCancelled) + // A missing mandatory receipt capability must prevent claiming queued work. + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("fixture_harness") + info.Capabilities.DurableInputReceipts = false + // A separate unbound Session is used, without changing public handler behavior. + wire, _ := json.Marshal(info) + var changed proto.SupportedAgentKind + if err := json.Unmarshal(wire, &changed); err != nil { + t.Fatal(err) + } + update, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{changed}}) + if err := write(update); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(3 * time.Second); ; { + current, _, _ := peer.AgentKindStatus("fixture_harness") + if !current.Capabilities.DurableInputReceipts { + break + } + if time.Now().After(deadline) { + t.Fatal("capability update missing") + } + time.Sleep(10 * time.Millisecond) + } + res = request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 200) + if err = json.Unmarshal(res.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + select { + case p := <-started: + t.Fatalf("incapable runtime received work: %s", p.RunID) + case <-time.After(700 * time.Millisecond): + } + queued, err := h.s.GetSession(ctx, h.tenant, created.ID) + if err != nil || queued.LastTurn == nil || queued.LastTurn.Status != store.TurnQueued { + t.Fatal(queued, err) + } +} + +func awaitOnboardingPrompt(t *testing.T, c <-chan proto.PromptRequestPayload) proto.PromptRequestPayload { + t.Helper() + select { + case p := <-c: + return p + case <-time.After(10 * time.Second): + t.Fatal("fixture was not dispatched") + return proto.PromptRequestPayload{} + } +} + +func startOnboardingPeer(t *testing.T, h *dispatchHarness) (<-chan proto.PromptRequestPayload, func(proto.Envelope) error) { + t.Helper() + root, err := filepath.Abs("../../../..") + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "onboarding") + build := exec.Command(filepath.Join(goruntime.GOROOT(), "bin", "go"), "build", "-o", binary, "./apps/parsar-daemon/testdata/onboarding") + build.Dir = root + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build fixture: %v %s", err, out) + } + child := exec.Command(binary) + var stderr bytes.Buffer + child.Stderr = &stderr + in, err := child.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = child.Start(); err != nil { + t.Fatal(err) + } + started := make(chan proto.PromptRequestPayload, 4) + up := make(chan error, 1) + down := make(chan error, 1) + var writeMu sync.Mutex + write := func(e proto.Envelope) error { writeMu.Lock(); defer writeMu.Unlock(); return h.conn.WriteJSON(e) } + go func() { + dec := json.NewDecoder(out) + for { + var e proto.Envelope + if err := dec.Decode(&e); err != nil { + up <- err + return + } + if err := write(e); err != nil { + up <- err + return + } + } + }() + go func() { + enc := json.NewEncoder(in) + for { + var e proto.Envelope + if err := h.conn.ReadJSON(&e); err != nil { + down <- err + return + } + if e.Type == proto.TypePromptRequest { + var p proto.PromptRequestPayload + if err := e.DecodePayload(&p); err != nil { + down <- err + return + } + started <- p + } + if err := enc.Encode(e); err != nil { + down <- err + return + } + } + }() + t.Cleanup(func() { + _ = in.Close() + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case err := <-done: + if err != nil { + t.Errorf("fixture: %v %s", err, stderr.String()) + } + case <-time.After(5 * time.Second): + _ = child.Process.Kill() + <-done + t.Error("fixture failed to release") + } + _ = h.conn.Close() + <-down + if err := <-up; err != nil && err != io.EOF { + t.Log(fmt.Sprint("fixture transport closed: ", err)) + } + }) + deadline := time.Now().Add(5 * time.Second) + for { + peer, err := h.registry.LookupDevice(h.device.ID) + if err == nil { + _, found, known := peer.AgentKindStatus("fixture_harness") + if found && known { + return started, write + } + } + if time.Now().After(deadline) { + t.Fatal("fixture registration missing") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/services/agents-api/internal/store/initial_files.go b/services/agents-api/internal/store/initial_files.go new file mode 100644 index 000000000..61841fdce --- /dev/null +++ b/services/agents-api/internal/store/initial_files.go @@ -0,0 +1,221 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "io" + "path" + "strings" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const MaxInitialFileBytes = 50 << 20 + +// InitialFile keeps confidential input separate from ordinary Session configuration. +type InitialFile struct { + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + Data []byte `json:"data,omitempty"` +} + +type InitialFileMetadata struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + SizeBytes *int64 `json:"size_bytes,omitempty"` +} + +func ValidateInitialFiles(files []InitialFile) error { + if len(files) > 50 { + return ErrInvalidInput + } + total := 0 + seen := map[string]bool{} + for _, f := range files { + if !utf8.ValidString(f.Path) || strings.ContainsAny(f.Path, "\\\x00\r\n") || len(f.Path) > 4096 || !strings.HasPrefix(f.Path, "/workspace/") || path.Clean(f.Path) != f.Path || seen[f.Path] { + return ErrInvalidInput + } + seen[f.Path] = true + switch f.Type { + case "inline": + if f.FileID != "" || len(f.Data) > 5<<20 { + return ErrInvalidInput + } + total += len(f.Data) + case "file_id": + if f.FileID == "" || len(f.Data) != 0 { + return ErrInvalidInput + } + default: + return ErrInvalidInput + } + } + if total > 10<<20 { + return ErrInvalidInput + } + return nil +} + +func initialFileMetadata(files []InitialFile) []InitialFileMetadata { + result := make([]InitialFileMetadata, 0, len(files)) + for _, f := range files { + m := InitialFileMetadata{Type: f.Type, Path: f.Path, FileID: f.FileID} + if f.Type == "inline" { + size := int64(len(f.Data)) + m.SizeBytes = &size + } + result = append(result, m) + } + return result +} + +func (s *Store) sealTemplateFiles(tenant, id string, files []InitialFile) ([]byte, []byte, error) { + if err := ValidateInitialFiles(files); err != nil { + return nil, nil, err + } + metadata, err := json.Marshal(initialFileMetadata(files)) + if err != nil { + return nil, nil, err + } + if len(files) == 0 { + return metadata, nil, nil + } + input, err := json.Marshal(files) + if err != nil { + return nil, nil, err + } + encrypted, err := s.credentialCipher.SealEnvironmentFile(input, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "environment_template", OwnerID: id, FileID: "files"}) + return metadata, encrypted, err +} + +// ResolveEnvironmentTemplate reads one atomic snapshot; public reads need no decryption key. +func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { + lookup, err := deviceLookup(tenant, id) + if err != nil { + return EnvironmentTemplate{}, nil, ErrNotFound + } + row, err := s.queries.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills}, err) + if err != nil { + return value, nil, err + } + value.Initialization.Packages = value.Packages + canonicalTenant := uuid.UUID(lookup.TenantID.Bytes).String() + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "env", row.EnvContents, &value.Initialization.Env); err != nil { + return value, nil, err + } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { + return value, nil, err + } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "skills", row.SkillContents, &value.Initialization.Skills); err != nil { + return value, nil, err + } + if len(value.Skills) != len(value.Initialization.Skills) { + return value, nil, ErrInvalidInput + } + for i, metadata := range value.Skills { + if metadata != value.Initialization.Skills[i].Metadata { + return value, nil, ErrInvalidInput + } + } + if err = value.Initialization.Validate(); err != nil { + return value, nil, err + } + if len(row.FileContents) == 0 { + if len(value.Files) > 0 { + return value, nil, ErrInvalidInput + } + return value, nil, nil + } + plain, err := s.credentialCipher.OpenEnvironmentFile(row.FileContents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "environment_template", OwnerID: value.ID, FileID: "files"}) + if err != nil { + return value, nil, err + } + var files []InitialFile + if json.Unmarshal(plain, &files) != nil || ValidateInitialFiles(files) != nil { + return value, nil, ErrInvalidInput + } + return value, files, nil +} + +func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx, tenant string, session pgtype.UUID, files []InitialFile) ([]byte, error) { + if err := ValidateInitialFiles(files); err != nil { + return nil, err + } + tenantID, err := parseID(tenant) + if err != nil { + return nil, err + } + tenant = uuid.UUID(tenantID.Bytes).String() + metadata := initialFileMetadata(files) + for i, f := range files { + body := f.Data + if f.Type == "file_id" { + sourceTenant, sourceID, err := sourceFileIDs(tenant, f.FileID) + if err != nil { + return nil, err + } + source, err := q.LockInitialSourceFile(ctx, sqlc.LockInitialSourceFileParams{TenantID: sourceTenant, ID: sourceID}) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + err = consumeSourceFile(ctx, tx, source, func(source SourceFile, reader io.Reader) error { + if source.SizeBytes > MaxInitialFileBytes { + return ErrInvalidInput + } + var err error + body, err = io.ReadAll(io.LimitReader(reader, MaxInitialFileBytes+1)) + if err == nil && (len(body) > MaxInitialFileBytes || int64(len(body)) != source.SizeBytes) { + return ErrInvalidInput + } + return err + }) + if err != nil { + return nil, err + } + } + id := uuid.NewString() + size := int64(len(body)) + metadata[i].ID = id + metadata[i].SizeBytes = &size + encrypted, err := s.credentialCipher.SealEnvironmentFile(body, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "session", OwnerID: uuid.UUID(session.Bytes).String(), FileID: id}) + if err != nil { + return nil, err + } + fileID, _ := parseID(id) + if err := q.CreateInitialEnvironmentFile(ctx, sqlc.CreateInitialEnvironmentFileParams{ID: fileID, SessionID: session, Position: int32(i), Path: f.Path, SizeBytes: size, Contents: encrypted}); err != nil { + return nil, err + } + } + return json.Marshal(metadata) +} + +// ReadInitialEnvironmentFile decrypts only the next frozen file, bounding memory per installation. +func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (InitialFileMetadata, []byte, error) { + lookup, err := deviceLookup(tenant, session) + if err != nil { + return InitialFileMetadata{}, nil, err + } + row, err := s.queries.GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, SessionID: lookup.ID, Position: int32(position)}) + if err != nil { + return InitialFileMetadata{}, nil, err + } + id := uuid.UUID(row.ID.Bytes).String() + body, err := s.credentialCipher.OpenEnvironmentFile(row.Contents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "session", OwnerID: uuid.UUID(lookup.ID.Bytes).String(), FileID: id}) + if err == nil && int64(len(body)) != row.SizeBytes { + err = ErrInvalidInput + } + return InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, err +} diff --git a/services/agents-api/internal/store/initial_files_http_test.go b/services/agents-api/internal/store/initial_files_http_test.go new file mode 100644 index 000000000..56fcdff7f --- /dev/null +++ b/services/agents-api/internal/store/initial_files_http_test.go @@ -0,0 +1,71 @@ +package store_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant, token := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + // Exercise HTTP parsing and durable storage without starting a Runtime. + handler, err := api.NewHandler(s, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + for _, size := range []int{1 << 20, 5 << 20} { + data := bytes.Repeat([]byte{42}, size) + files := []map[string]any{{"type": "inline", "path": "/workspace/a", "data": base64.StdEncoding.EncodeToString(data)}} + if size == 5<<20 { + files = append(files, map[string]any{"type": "inline", "path": "/workspace/b", "data": base64.StdEncoding.EncodeToString(data)}) + } + body, err := json.Marshal(map[string]any{"agent": map[string]any{"model": "model"}, "environment": map[string]any{"type": "openai_hosted", "files": files}}) + if err != nil { + t.Fatal(err) + } + key, id := uuid.NewString(), "" + for range 2 { + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", key) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("size %d: HTTP %d: %s", size, w.Code, w.Body.String()) + } + var response struct { + ID string `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.ID == "" || (id != "" && response.ID != id) { + t.Fatal("creation retry did not preserve Session", err) + } + id = response.ID + } + for position := range files { + _, actual, err := s.ReadInitialEnvironmentFile(t.Context(), tenant, id, position) + if err != nil || !bytes.Equal(actual, data) { + t.Fatal("large HTTP snapshot differs", err) + } + } + } +} diff --git a/services/agents-api/internal/store/initial_files_test.go b/services/agents-api/internal/store/initial_files_test.go new file mode 100644 index 000000000..3fb2b870e --- /dev/null +++ b/services/agents-api/internal/store/initial_files_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestInitialFilesFrozenEncryptedIsolatedAndRetryable(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + canary := []byte("private-initial-file-canary\x00\xff") + upload, err := s.CreateSourceFile(t.Context(), tenant, func(w io.Writer) (SourceFileUpload, error) { + _, err := w.Write(canary) + return SourceFileUpload{Filename: "source.bin", Purpose: "user_data"}, err + }) + if err != nil { + t.Fatal(err) + } + files := []InitialFile{{Type: "inline", Path: "/workspace/a/data", Data: canary}, {Type: "file_id", Path: "/workspace/b", FileID: upload.ID}} + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetFiles: true, Files: files}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Files) != 2 { + t.Fatal("public read depends on secret key", err) + } + if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign template resolved", err) + } + if _, err := s.UpdateEnvironmentTemplate(t.Context(), strings.ToUpper(tenant), strings.ToUpper(template.ID), EnvironmentTemplateInput{SetFiles: true, Files: files}); err != nil { + t.Fatal("noncanonical update", err) + } + if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), strings.ToUpper(tenant), strings.ToUpper(template.ID)); err != nil { + t.Fatal("noncanonical resolution", err) + } + _, resolved, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !bytes.Equal(resolved[0].Data, canary) { + t.Fatal("template snapshot", err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: resolved} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(session.Configuration, canary) || bytes.Contains(session.Configuration, []byte(`"data"`)) { + t.Fatal("plaintext in Session configuration") + } + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetFiles: true}); err != nil { + t.Fatal(err) + } + if _, err := s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + if err := s.DeleteSourceFile(t.Context(), tenant, upload.ID); err != nil { + t.Fatal(err) + } + retry, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || retry.ID != session.ID { + t.Fatal("retry re-resolved deleted resources", err) + } + for position := range files { + metadata, body, err := s.ReadInitialEnvironmentFile(t.Context(), strings.ToUpper(tenant), strings.ToUpper(session.ID), position) + if err != nil || !bytes.Equal(body, canary) || metadata.ID == "" { + t.Fatal("frozen initial content", err) + } + if _, _, err := s.ReadInitialEnvironmentFile(t.Context(), foreign, session.ID, position); err == nil { + t.Fatal("foreign bytes disclosed") + } + var encrypted []byte + if err := pool.QueryRow(t.Context(), "SELECT contents FROM initial_environment_files WHERE id=$1", metadata.ID).Scan(&encrypted); err != nil || bytes.Contains(encrypted, canary) { + t.Fatal("unencrypted file storage", err) + } + } + changed := input + changed.InitialFiles = append([]InitialFile(nil), files...) + changed.InitialFiles[0].Data = []byte("changed") + if _, err := s.CreateSession(t.Context(), tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed bytes retried", err) + } + if _, err := s.GetSessionDevice(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("uninitialized environment exposed", err) + } +} diff --git a/services/agents-api/internal/store/input_batches_test.go b/services/agents-api/internal/store/input_batches_test.go new file mode 100644 index 000000000..b1051a52f --- /dev/null +++ b/services/agents-api/internal/store/input_batches_test.go @@ -0,0 +1,188 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" +) + +func messageInput(text string) Input { + payload, _ := json.Marshal(map[string]string{"text": text}) + return Input{Kind: "message", Payload: payload} +} + +func TestInputBatchesAreOrderedAndIdempotentAcrossConnections(t *testing.T) { + s, _ := testStore(t) + other, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + const count = 8 + batch := []Input{messageInput("first"), messageInput("second")} + for _, repeated := range []bool{true, false} { + var wg sync.WaitGroup + receipts := make(chan []InputReceipt, count) + errs := make(chan error, count) + for i := range count { + wg.Add(1) + go func() { + defer wg.Done() + st := s + if i%2 == 0 { + st = other + } + key := "same-batch" + if !repeated { + key = fmt.Sprintf("batch-%d", i) + } + got, err := st.SubmitInputs(ctx, tenant, session.ID, key, batch) + receipts <- got + errs <- err + }() + } + wg.Wait() + close(receipts) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + newBatches := 0 + for got := range receipts { + if len(got) != 2 || got[0].TurnID == "" || got[0].TurnID != got[1].TurnID || got[0].Sequence >= got[1].Sequence || got[0].Replayed != got[1].Replayed { + t.Fatalf("invalid batch receipt: %+v", got) + } + if !got[0].Replayed { + newBatches++ + } + } + want := count + if repeated { + want = 1 + } + if newBatches != want { + t.Fatalf("accepted %d batches, want %d", newBatches, want) + } + } + got, err := s.SubmitInputs(ctx, tenant, session.ID, "same-batch", batch) + if err != nil { + t.Fatal(err) + } + inputs, err := s.ListTurnInputs(ctx, tenant, session.ID, got[0].TurnID, 0, 100) + if err != nil || len(inputs) != 2*(count+1) { + t.Fatalf("inputs=%d err=%v", len(inputs), err) + } + for i, input := range inputs { + var payload map[string]string + if err := json.Unmarshal(input.Payload, &payload); err != nil { + t.Fatal(err) + } + want := "first" + if i%2 == 1 { + want = "second" + } + if payload["text"] != want { + t.Fatalf("batch interleaved at %d: %v", i, payload) + } + } +} + +func TestBatchRetriesCompareTheWholeRequestAndRetainTargets(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + cancel := Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} + batch := []Input{cancel, messageInput("one"), cancel, messageInput("two")} + first, err := s.SubmitInputs(ctx, tenant, session.ID, "mixed", batch) + if err != nil { + t.Fatal(err) + } + if first[0].TurnID != "" || first[1].TurnID == "" || first[1].TurnID != first[2].TurnID || first[1].TurnID == first[3].TurnID { + t.Fatalf("cancellation targets: %+v", first) + } + cancelled, err := s.GetTurn(ctx, tenant, session.ID, first[1].TurnID) + if err != nil || cancelled.Status != TurnCancelled { + t.Fatalf("cancelled turn=%+v err=%v", cancelled, err) + } + transition(t, s, tenant, session.ID, first[3].TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, first[3].TurnID, TurnInProgress, TurnCompleted) + next := submitMessage(t, s, tenant, session.ID, "next") + for _, changed := range [][]Input{batch[:3], append(append([]Input{}, batch...), cancel), {batch[0], batch[3], batch[2], batch[1]}} { + if _, err := s.SubmitInputs(ctx, tenant, session.ID, "mixed", changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed batch accepted: %v", err) + } + } + batch[1].Payload = json.RawMessage(`{ "text" : "one" }`) + pool.Close() + restarted, _ := testStore(t) + retry, err := restarted.SubmitInputs(ctx, tenant, session.ID, "mixed", batch) + for i := range first { + first[i].Replayed = true + } + if err != nil || !reflect.DeepEqual(retry, first) { + t.Fatalf("restart changed receipts: %+v, %v", retry, err) + } + current, err := restarted.GetTurn(ctx, tenant, session.ID, next.TurnID) + if err != nil || current.Status != TurnQueued || !current.CancelRequestedAt.IsZero() { + t.Fatalf("retry cancelled later work: %+v, %v", current, err) + } + otherTenant, _ := newTurnSession(t, restarted) + if _, err := restarted.SubmitInputs(ctx, otherTenant, session.ID, "mixed", batch); !errors.Is(err, ErrNotFound) { + t.Fatalf("batch retry escaped tenant: %v", err) + } +} + +func TestFailedBatchRollsBackEarlierCancellationAndInputs(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + initial := submitMessage(t, s, tenant, session.ID, "initial") + transition(t, s, tenant, session.ID, initial.TurnID, TurnQueued, TurnInProgress) + key := uuid.NewString() + constraint := "batch_failure_" + strings.ReplaceAll(key, "-", "") + // Inject a storage error on the second insert, after cancellation has run. + _, err := pool.Exec(ctx, "ALTER TABLE turn_inputs ADD CONSTRAINT "+constraint+" CHECK (idempotency_key <> '"+key+"' OR batch_position = 0)") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "ALTER TABLE turn_inputs DROP CONSTRAINT IF EXISTS "+constraint) }) + batch := []Input{{Kind: "cancel", Payload: json.RawMessage(`{}`)}, messageInput("after cancel")} + if got, err := s.SubmitInputs(ctx, tenant, session.ID, key, batch); err == nil || got != nil { + t.Fatalf("partial batch succeeded: %+v %v", got, err) + } + turn, err := s.GetTurn(ctx, tenant, session.ID, initial.TurnID) + if err != nil || turn.Status != TurnInProgress || !turn.CancelRequestedAt.IsZero() { + t.Fatalf("cancellation escaped rollback: %+v %v", turn, err) + } + inputs, err := s.ListTurnInputs(ctx, tenant, session.ID, initial.TurnID, 0, 100) + if err != nil || len(inputs) != 1 { + t.Fatalf("partial inputs survived: %v %v", inputs, err) + } + if _, err := pool.Exec(ctx, "ALTER TABLE turn_inputs DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + got, err := s.SubmitInputs(ctx, tenant, session.ID, key, batch) + if err != nil || len(got) != 2 || got[0].Replayed || got[1].Replayed { + t.Fatalf("retry after rollback: %+v %v", got, err) + } +} + +func TestInputBatchValidation(t *testing.T) { + for _, input := range [][]Input{ + nil, make([]Input, 65), {messageInput("ok"), {Kind: "unsupported", Payload: json.RawMessage(`{}`)}}, + {{Kind: "message"}}, {{Kind: "message", Payload: json.RawMessage(`[]`)}}, + {{Kind: "cancel", Payload: json.RawMessage(`{"target":"other"}`)}}, + {messageInput(strings.Repeat("x", 300*1024)), messageInput(strings.Repeat("y", 300*1024))}, + } { + if _, _, err := validateInputs(input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid batch accepted: %v", err) + } + } +} diff --git a/services/agents-api/internal/store/item_events.go b/services/agents-api/internal/store/item_events.go new file mode 100644 index 000000000..0029572a0 --- /dev/null +++ b/services/agents-api/internal/store/item_events.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "reflect" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" +) + +func recordItemChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, index pgtype.Int4, previous, item v1.Item, delta *string) error { + if reflect.DeepEqual(previous, item) { + return nil + } + // Function results are Session inputs, not AgentOutputItem variants. + if item.Type == "function_call_output" { + index.Valid = false + } + base := v1.SessionEvent{TurnID: item.TurnID} + if index.Valid { + base.OutputIndex = &index.Int32 + } + emit := func(kind string, event v1.SessionEvent) error { + event.Type = "agent.session.turn." + kind + return recordSessionChange(ctx, q, session, SessionChange{Event: event}) + } + textMessage := item.Type == "message" && item.Role == "assistant" && len(item.Content) == 1 && item.Content[0].Text != nil + if previous.ID == "" { + initial := item + if textMessage && delta != nil { + empty := "" + initial.Content = []v1.ItemContent{{Type: "output_text", Text: &empty}} + } + event := base + event.Item = &initial + if err := emit("item.added", event); err != nil { + return err + } + if textMessage { + zero := 0 + event = base + event.ItemID, event.ContentIndex, event.Part = item.ID, &zero, &initial.Content[0] + if err := emit("content_part.added", event); err != nil { + return err + } + } + } + if !index.Valid { + return nil + } + if item.Type == "command_execution" && delta != nil { + event := base + event.Type = "agent.output.command_execution_output.delta" + event.ItemID, event.Delta = item.ID, delta + return recordSessionChange(ctx, q, session, SessionChange{Event: event}) + } + if textMessage { + zero := 0 + event := base + event.ItemID, event.ContentIndex = item.ID, &zero + if delta != nil && *delta != "" { + event.Delta = delta + if err := emit("output_text.delta", event); err != nil { + return err + } + event.Delta = nil + } + if item.Status != "in_progress" { + event.Text = item.Content[0].Text + if err := emit("output_text.done", event); err != nil { + return err + } + event.Text, event.Part = nil, &item.Content[0] + if err := emit("content_part.done", event); err != nil { + return err + } + } + } + if item.Status != "in_progress" { + event := base + event.Item = &item + return emit("item.done", event) + } + return nil +} diff --git a/services/agents-api/internal/store/item_order_migration_test.go b/services/agents-api/internal/store/item_order_migration_test.go new file mode 100644 index 000000000..712c4b5b7 --- /dev/null +++ b/services/agents-api/internal/store/item_order_migration_test.go @@ -0,0 +1,161 @@ +package store_test + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestItemOrderMigrationPreservesIndexedHistory(t *testing.T) { + ctx := context.Background() + _, pool := store.NewTestStore(t) + schema := "item_order_" + uuid.New().String()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err = provider.UpTo(ctx, 10); err != nil { + t.Fatal(err) + } + session, turn, tenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + if _, err = db.ExecContext(ctx, "INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash) VALUES ($1,$2,'codex','legacy','legacy')", session, tenant); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, "INSERT INTO turns(id,session_id) VALUES ($1,$2)", turn, session); err != nil { + t.Fatal(err) + } + for i, id := range []string{ + "00000000-0000-0000-0000-000000000003", + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + } { + payload := `{"type":"function_call_output"}` + if i == 1 { + payload = `{"type":"message","role":"user"}` + } + if _, err = db.ExecContext(ctx, "INSERT INTO session_items(id,session_id,turn_id,created_at,position,payload) VALUES ($1,$2,$3,'2026-01-01',0,$4)", id, session, turn, payload); err != nil { + t.Fatal(err) + } + } + readIDs := func() []string { + t.Helper() + rows, err := db.QueryContext(ctx, "SELECT id FROM session_items ORDER BY created_at,position,id") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + if err = rows.Err(); err != nil { + t.Fatal(err) + } + return ids + } + want := readIDs() + if _, err = provider.UpTo(ctx, 11); err != nil { + t.Fatal(err) + } + if got := readIDs(); !reflect.DeepEqual(got, want) { + t.Fatalf("migration reordered history: %v; want %v", got, want) + } + for i, id := range want { + var position int + var output pgtype.Int4 + if err = db.QueryRowContext(ctx, "SELECT position,output_index FROM session_items WHERE id=$1", id).Scan(&position, &output); err != nil { + t.Fatal(err) + } + if position != i || output.Valid != (i > 0) || (output.Valid && int(output.Int32) != i-1) { + t.Fatalf("migrated item %d: position=%d output=%+v", i, position, output) + } + } + if _, err = provider.UpTo(ctx, 14); err != nil { + t.Fatal(err) + } + var before string + if err = db.QueryRowContext(ctx, "SELECT jsonb_agg(to_jsonb(i) ORDER BY id)::text FROM session_items i").Scan(&before); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, "UPDATE turns SET items_indexed=false WHERE id=$1", turn); err != nil { + t.Fatal(err) + } + if _, err = provider.Up(ctx); err == nil || !strings.Contains(err.Error(), "Unindexed Agents API history") { + t.Fatalf("unprepared upgrade: %v", err) + } + var indexed bool + if err = db.QueryRowContext(ctx, "SELECT items_indexed FROM turns WHERE id=$1", turn).Scan(&indexed); err != nil || indexed { + t.Fatal("failed migration changed preparation state", err) + } + // Simulate the previous release finishing its index before retrying the upgrade. + if _, err = db.ExecContext(ctx, "UPDATE turns SET items_indexed=true WHERE id=$1", turn); err != nil { + t.Fatal(err) + } + if _, err = provider.Up(ctx); err != nil { + t.Fatal(err) + } + var after string + if err = db.QueryRowContext(ctx, "SELECT jsonb_agg(to_jsonb(i) ORDER BY id)::text FROM session_items i").Scan(&after); err != nil || after != before { + t.Fatal("upgrade changed indexed history", err) + } + + poolConfig := pool.Config() + poolConfig.ConnConfig = cfg + migratedPool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + t.Fatal(err) + } + t.Cleanup(migratedPool.Close) + s := store.New(migratedPool) + if _, err = s.TransitionTurn(ctx, tenant, session, turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + if err = s.AppendTurnEvents(ctx, tenant, session, turn, 1, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { + t.Fatal(err) + } + addedID := items.Identity(turn, "message:after-upgrade") + var position, output int + if err = db.QueryRowContext(ctx, "SELECT position,output_index FROM session_items WHERE id=$1", addedID).Scan(&position, &output); err != nil || position != 3 || output != 2 { + t.Fatalf("post-upgrade append: position=%d output=%d err=%v", position, output, err) + } + want = append(want, addedID) + if _, err = provider.DownTo(ctx, 10); err != nil { + t.Fatal(err) + } + if got := readIDs(); !reflect.DeepEqual(got, want) { + t.Fatalf("downgrade reordered history: %v", got) + } + if _, err = provider.Up(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/item_order_test.go b/services/agents-api/internal/store/item_order_test.go new file mode 100644 index 000000000..2a9f9e9e9 --- /dev/null +++ b/services/agents-api/internal/store/item_order_test.go @@ -0,0 +1,147 @@ +package store_test + +import ( + "context" + "encoding/json" + "reflect" + "sort" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "ordered"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + page, err := s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 1 { + t.Fatal(page, err) + } + want := []string{page.Items[0].ID} + keys := []string{"first", "second", "third"} + // Oppose UUID order so a timestamp tie cannot accidentally pass this check. + sort.Slice(keys, func(i, j int) bool { + return items.Identity(input.TurnID, "message:"+keys[i]) > items.Identity(input.TurnID, "message:"+keys[j]) + }) + var batch []store.ExecutionEvent + for _, key := range keys { + payload, _ := json.Marshal(map[string]string{"id": key, "status": "in_progress"}) + batch = append(batch, store.ExecutionEvent{Kind: "output_message", Payload: payload}) + want = append(want, items.Identity(input.TurnID, "message:"+key)) + } + for range 2 { + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + if _, err = s.SubmitMessage(ctx, tenant, session.ID, "steer", json.RawMessage(`{"text":"continue"}`)); err != nil { + t.Fatal(err) + } + page, err = s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 5 { + t.Fatal(page, err) + } + want = append(want, page.Items[4].ID) + completion, _ := json.Marshal(map[string]string{"id": keys[0], "status": "completed", "text": "final"}) + batch = []store.ExecutionEvent{{Kind: "output_message", Payload: completion}, {Kind: "delta", Payload: json.RawMessage(`{"item_id":"last","delta":"partial"}`)}} + for range 2 { + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, batch); err != nil { + t.Fatal(err) + } + } + want = append(want, items.Identity(input.TurnID, "message:last")) + bad := []store.ExecutionEvent{ + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"rollback","delta":"discard"}`)}, + {Kind: "output_message", Payload: json.RawMessage(`{"id":"invalid","status":"invalid"}`)}, + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, bad); err == nil { + t.Fatal("invalid batch accepted") + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-rollback","delta":"retained"}`)}}); err != nil { + t.Fatal(err) + } + want = append(want, items.Identity(input.TurnID, "message:after-rollback")) + if _, err = pool.Exec(ctx, "UPDATE session_items SET created_at='2026-01-01' WHERE session_id=$1", session.ID); err != nil { + t.Fatal(err) + } + checkOrder := func() { + t.Helper() + for _, asc := range []bool{true, false} { + var got []string + cursor := "" + for { + page, err := store.New(pool).ListItems(ctx, tenant, session.ID, cursor, 2, asc) + if err != nil { + t.Fatal(err) + } + for _, item := range page.Items { + got = append(got, item.ID) + } + if !page.HasMore { + break + } + cursor = page.Items[len(page.Items)-1].ID + } + if !asc { + for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 { + got[i], got[j] = got[j], got[i] + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("observation order = %v; want %v", got, want) + } + } + } + checkOrder() + for position, id := range want { + var storedPosition int + var output pgtype.Int4 + if err = pool.QueryRow(ctx, "SELECT position, output_index FROM session_items WHERE id=$1", id).Scan(&storedPosition, &output); err != nil { + t.Fatal(err) + } + if storedPosition != position || output.Valid != (position != 0 && position != 4) { + t.Fatalf("item %d: position=%d output=%+v", position, storedPosition, output) + } + index := position - 1 + if position > 4 { + index-- + } + if output.Valid && int(output.Int32) != index { + t.Fatalf("output index = %d, want %d", output.Int32, index) + } + } + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + checkOrder() + next, err := s.SubmitMessage(ctx, tenant, session.ID, "next-turn", json.RawMessage(`{"text":"new turn"}`)) + if err != nil { + t.Fatal(err) + } + if _, err = s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, next.TurnID, 1, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"new","delta":"new turn"}`)}}); err != nil { + t.Fatal(err) + } + var index int + if err = pool.QueryRow(ctx, "SELECT output_index FROM session_items WHERE id=$1", items.Identity(next.TurnID, "message:new")).Scan(&index); err != nil || index != 0 { + t.Fatalf("new Turn output index=%d: %v", index, err) + } +} diff --git a/services/agents-api/internal/store/item_projection.go b/services/agents-api/internal/store/item_projection.go new file mode 100644 index 000000000..27f6808bd --- /dev/null +++ b/services/agents-api/internal/store/item_projection.go @@ -0,0 +1,122 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func projectItemSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, kind string, sequence int64, raw json.RawMessage, created pgtype.Timestamptz) error { + updates, err := items.Project(uuid.UUID(turn.Bytes).String(), kind, sequence, raw) + if err != nil { + return fmt.Errorf("project execution item: %w", err) + } + for _, update := range updates { + id, _ := parseID(update.Item.ID) + if update.LegacyFinal { + native, err := q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}) + if err != nil { + return err + } + if native { + continue + } + } + old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + var previous v1.Item + if err == nil { + if err = json.Unmarshal(old.Payload, &previous); err != nil { + return err + } + } + item, err := items.Merge(update, previous) + if err != nil { + return err + } + if err := restoreFunctionItemResult(ctx, q, session, turn, &item); err != nil { + return err + } + payload, err := json.Marshal(item) + if err != nil { + return err + } + stored, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: kind != "message" && item.Type != "function_call_output"}) + if err != nil { + return err + } + var delta *string + if kind == "delta" { + delta = update.Item.Content[0].Text + } else if kind == "command_output" { + delta = update.CommandOutputDelta + } + if err := recordItemChange(ctx, q, session, stored.OutputIndex, previous, item, delta); err != nil { + return err + } + } + return nil +} + +func indexInput(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, sequence int64) error { + row, err := q.ItemInputSource(ctx, sqlc.ItemInputSourceParams{SessionID: session, Sequence: sequence}) + if err != nil { + return err + } + if row.Kind != "message" { + return nil + } + return projectSource(ctx, q, session, row.TurnID, row.Kind, row.Sequence, row.Payload, row.CreatedAt) +} + +func indexEvents(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, first int32) error { + rows, err := q.ItemEventSources(ctx, sqlc.ItemEventSourcesParams{SessionID: session, TurnID: turn, Ordinal: first}) + if err != nil { + return err + } + for _, row := range rows { + if err = projectSource(ctx, q, session, turn, row.Kind, int64(row.Ordinal), row.Payload, row.CreatedAt); err != nil { + return err + } + } + return nil +} + +func restoreFunctionItemResult(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, item *v1.Item) error { + if item.Type != "function_call_output" { + return nil + } + result, err := q.FunctionItemResult(ctx, sqlc.FunctionItemResultParams{SessionID: session, TurnID: turn, CallID: item.CallID}) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if len(result) == 0 { + return nil + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(result, &fields); err != nil { + return err + } + // Native results may normalize content; public Items retain the saved submission. + item.Output, item.Error = nil, nil + if value, ok := fields["output"]; ok { + item.Output = value + } + if value, ok := fields["error"]; ok { + item.Error = value + } + return nil +} diff --git a/services/agents-api/internal/store/item_reads.go b/services/agents-api/internal/store/item_reads.go new file mode 100644 index 000000000..86c6e9c35 --- /dev/null +++ b/services/agents-api/internal/store/item_reads.go @@ -0,0 +1,60 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type ItemPage struct { + Items []v1.Item + HasMore bool +} + +func (s *Store) ListItems(ctx context.Context, tenantID, sessionID, cursor string, limit int, ascending bool) (ItemPage, error) { + if limit < 1 || limit > 100 { + return ItemPage{}, ErrInvalidInput + } + page := ItemPage{Items: make([]v1.Item, 0, limit)} + err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + p := sqlc.ListSessionItemsParams{SessionID: session, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} + if cursor != "" { + id, err := parseID(cursor) + if err != nil { + return err + } + row, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + p.AfterCreated = row.CreatedAt + p.AfterID = id + p.AfterPosition = row.Position + } + rows, err := q.ListSessionItems(ctx, p) + if err != nil { + return err + } + page.HasMore = len(rows) > limit + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + var item v1.Item + if err = json.Unmarshal(row.Payload, &item); err != nil { + return err + } + page.Items = append(page.Items, item) + } + return nil + }) + return page, err +} diff --git a/services/agents-api/internal/store/item_reads_test.go b/services/agents-api/internal/store/item_reads_test.go new file mode 100644 index 000000000..1406396f5 --- /dev/null +++ b/services/agents-api/internal/store/item_reads_test.go @@ -0,0 +1,215 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "items"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + batch := []store.ExecutionEvent{ + {Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"in_progress"}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"draft"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"cmd","stage":"before","observation":{"status":"in_progress","kind":"command","command":"exit 7"}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"cmd","stage":"after","observation":{"status":"failed","kind":"command","command":"exit 7","output":"expected failure","exit_code":7}}`)}, + {Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"corrected answer","phase":"final_answer"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mcp","stage":"after","observation":{"status":"completed","kind":"mcp","server":"reference","name":"lookup","arguments":{},"output":{"structuredContent":{"number":9007199254740993}}}}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"partial","delta":"unfinished"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"waiting","stage":"before","observation":{"status":"in_progress","kind":"command","command":"sleep 10"}}`)}, + {Kind: "done", Payload: json.RawMessage(`{"content":"corrected answer","metadata":{"agent_session_id":"PRIVATE"}}`)}, + } + for range 2 { + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + page, err := s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 6 { + t.Fatalf("wrong count: %+v", page) + } + if page.Items[4].Status != "in_progress" || page.Items[5].Status != "in_progress" { + t.Fatal(page.Items) + } + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence) + if err != nil { + t.Fatal(err) + } + reopened := store.New(pool) + page, err = reopened.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + want := []string{"completed", "completed", "failed", "completed", "incomplete", "incomplete"} + for i, item := range page.Items { + if item.Status != want[i] { + t.Fatalf("item %d: %+v", i, item) + } + } + if *page.Items[1].Content[0].Text != "corrected answer" || *page.Items[2].ExitCode != 7 { + t.Fatal(page.Items) + } + raw, _ := json.Marshal(page.Items) + if strings.Contains(string(raw), "PRIVATE") || !strings.Contains(string(raw), "9007199254740993") { + t.Fatal(string(raw)) + } + for _, asc := range []bool{true, false} { + var all []v1.Item + cursor := "" + for { + next, err := reopened.ListItems(ctx, tenant, session.ID, cursor, 2, asc) + if err != nil { + t.Fatal(err) + } + all = append(all, next.Items...) + if !next.HasMore { + break + } + cursor = next.Items[len(next.Items)-1].ID + } + for i, item := range all { + j := i + if !asc { + j = len(all) - 1 - i + } + if !reflect.DeepEqual(item, page.Items[j]) { + t.Fatal("pagination changed items") + } + } + } + other, _ := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "other"}) + for _, scope := range []struct{ tenant, session, cursor string }{{uuid.NewString(), session.ID, ""}, {tenant, other.ID, page.Items[0].ID}} { + if _, err = s.ListItems(ctx, scope.tenant, scope.session, scope.cursor, 20, true); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + } +} + +func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) { + ctx := context.Background() + s, _ := store.NewTestStore(t) + tenant := uuid.NewString() + session, _ := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy"}) + input, err := s.SubmitMessage(ctx, tenant, session.ID, "input", json.RawMessage(`{"text":"test"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + bad := []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"must roll back"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mismatch","stage":"after","observation":{"status":"completed","kind":"invalid"}}`)}} + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, bad); err == nil { + t.Fatal("invalid snapshot accepted") + } + events, err := s.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 0 { + t.Fatal(events, err) + } + page, err := s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 1 { + t.Fatal(page, err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnCompleted, Outcome: json.RawMessage(`{"done":{"content":"legacy answer","metadata":{"private":"SECRET"}}}`)}) + if err != nil { + t.Fatal(err) + } + current, err := s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(current.Items) != 2 || *current.Items[1].Content[0].Text != "legacy answer" { + t.Fatal(current, err) + } +} + +func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + for _, receiptOnly := range []bool{true, false} { + session, _ := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) + input, err := s.SubmitMessage(ctx, tenant, session.ID, "first", json.RawMessage(`{"text":"test"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + if receiptOnly { + err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, []store.ExecutionEvent{{Kind: "cancel_receipt", Payload: json.RawMessage(`{"applied":true,"outcome":{"content":"retained cancellation text"}}`)}}) + if err != nil { + t.Fatal(err) + } + } + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"done":{"content":"retained cancellation text"}}`), "", input.Sequence) + if err != nil { + t.Fatal(err) + } + page, err := store.New(pool).ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || *page.Items[1].Content[0].Text != "retained cancellation text" { + t.Fatal(page, err) + } + } +} + +func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "failed-items"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + batch := []store.ExecutionEvent{ + {Kind: "delta", Payload: json.RawMessage(`{"delta":"partial answer"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"open","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"open_page","url":"https://example.com"}}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"find","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"find_in_page","url":"https://example.com","pattern":"needle"}}}`)}, + {Kind: "error", Payload: json.RawMessage(`{"error":"provider failure"}`)}, + {Kind: "done", Payload: json.RawMessage(`{"content":"provider failure"}`)}, + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnFailed, json.RawMessage(`{"done":{"content":"provider failure"},"error_code":"engine_failed"}`), "", input.Sequence) + if err != nil { + t.Fatal(err) + } + page, err := store.New(pool).ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 4 { + t.Fatal(page, err) + } + if page.Items[1].Status != "incomplete" || *page.Items[1].Content[0].Text != "partial answer" || page.Items[2].Action.Type != "open_page" || page.Items[3].Action.Type != "find_in_page" { + t.Fatal(page) + } +} diff --git a/services/agents-api/internal/store/json_object.go b/services/agents-api/internal/store/json_object.go new file mode 100644 index 000000000..480186e2e --- /dev/null +++ b/services/agents-api/internal/store/json_object.go @@ -0,0 +1,28 @@ +package store + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return json.RawMessage(`{}`), nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var fields map[string]any + if err := decoder.Decode(&fields); err != nil || fields == nil { + return nil, fmt.Errorf("%w: value must be a JSON object", ErrInvalidInput) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("%w: value must contain exactly one object", ErrInvalidInput) + } + canonical, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("%w: invalid JSON object", ErrInvalidInput) + } + return canonical, nil +} diff --git a/services/agents-api/internal/store/local_artifact_export_test.go b/services/agents-api/internal/store/local_artifact_export_test.go new file mode 100644 index 000000000..f259d9643 --- /dev/null +++ b/services/agents-api/internal/store/local_artifact_export_test.go @@ -0,0 +1,81 @@ +package store_test + +import ( + "archive/tar" + "bytes" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func completeLocalArtifactExport(t *testing.T, h *dispatchHarness, worker *execution.Worker, environment store.Environment) { + t.Helper() + prepared := h.read(proto.TypeExecutionPrepare) + var request proto.ExecutionPreparePayload + if prepared.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.LocalEnvironment == nil || request.Configuration.LocalEnvironment.ID != environment.ID { + t.Fatal("export did not reuse the bound read-only preparation") + } + handle := acknowledgePreparation(h, prepared.ID) + h.write(prepared.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + begin := h.read(proto.TypeWorkspaceExport) + var export proto.WorkspaceExportPayload + if begin.DecodePayload(&export) != nil || export.Step != "begin" || export.Handle != handle || export.EnvironmentID != environment.ID { + t.Fatal("export lost preparation authority") + } + var data bytes.Buffer + w := tar.NewWriter(&data) + if err := w.WriteHeader(&tar.Header{Name: "outputs/result.bin", Size: 3, Mode: 0600, Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte{0, 255, 1}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + h.write(begin.ID, proto.TypeWorkspaceExportResult, proto.WorkspaceExportResultPayload{Outcome: "chunk", Data: data.Bytes()}) + next := h.read(proto.TypeWorkspaceExport) + if next.DecodePayload(&export) != nil || export.Step != "next" || export.Offset != int64(data.Len()) { + t.Fatal("export did not await native completion") + } + page, err := h.s.ListSessionArtifacts(t.Context(), h.tenant, h.session.ID, "", "", 20, false) + if err != nil || len(page.Artifacts) != 0 { + t.Fatal("capture published before native completion", err) + } + completeCaptureDirectoryRead(t, h, worker, environment) + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "during-artifact-capture", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"run after the completed native execution"}`)}}) + if err != nil || pending.State != store.EnvironmentInputPending || len(pending.Receipts) != 0 { + t.Fatalf("input during artifact capture was assigned to the finished executor: %+v %v", pending, err) + } + h.write(begin.ID, proto.TypeWorkspaceExportResult, proto.WorkspaceExportResultPayload{Outcome: "completed", Offset: export.Offset}) + release := h.read(proto.TypeExecutionRelease) + var close proto.ExecutionReleasePayload + if release.DecodePayload(&close) != nil || release.ID != prepared.ID || close.Handle != handle { + t.Fatal("export did not release its own read preparation") + } + h.write(prepared.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "released"}) +} + +func completeCaptureDirectoryRead(t *testing.T, h *dispatchHarness, worker *execution.Worker, environment store.Environment) { + t.Helper() + result := startDirectoryRead(t.Context(), worker, environment) + frame := h.read(proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || !proto.ValidWorkspaceReadPreparation(prepare.Configuration) || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID { + t.Fatal("directory read during capture lost read-only authority") + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + read := h.read(proto.TypeWorkspaceRead) + var request proto.WorkspaceReadPayload + if read.DecodePayload(&request) != nil || request.Handle != handle || request.RunID != "" || request.EnvironmentID != environment.ID { + t.Fatal("directory read during capture used a finished native Run") + } + completeDirectoryRead(t, h, frame.ID, read.ID, false, false) + if got := awaitDirectoryResult(t, result); got.err != nil || len(got.value.Entries) != 1 { + t.Fatal("directory read failed during paused output capture", got.err) + } +} diff --git a/services/agents-api/internal/store/local_environment_devices.go b/services/agents-api/internal/store/local_environment_devices.go new file mode 100644 index 000000000..bdeac5cf3 --- /dev/null +++ b/services/agents-api/internal/store/local_environment_devices.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// CreateEnvironmentDevice provisions one dedicated Runtime without widening an existing credential. +func (s *Store) CreateEnvironmentDevice(ctx context.Context, tenantID, environmentID, name, credentialHash string) (ExecutionDevice, error) { + environment, err := s.GetEnvironment(ctx, tenantID, environmentID) + if err != nil { + return ExecutionDevice{}, err + } + var configuration struct { + Type string `json:"type"` + } + if json.Unmarshal(environment.Configuration, &configuration) != nil || configuration.Type != "openai_hosted" { + return ExecutionDevice{}, ErrInvalidInput + } + lookup, err := deviceLookup(tenantID, environment.ID) + if err != nil { + return ExecutionDevice{}, err + } + params, err := newDeviceParams(lookup.TenantID, name, credentialHash) + if err != nil { + return ExecutionDevice{}, err + } + err = s.withPublicSession(ctx, tenantID, environment.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return createEnvironmentDevice(ctx, q, lookup, session, params) + }) + if err != nil { + return ExecutionDevice{}, err + } + return ExecutionDevice{ID: uuid.UUID(params.ID.Bytes).String(), Name: params.Name, EnvironmentID: environment.ID}, nil +} + +func createEnvironmentDevice(ctx context.Context, q *sqlc.Queries, lookup sqlc.GetDeviceParams, session pgtype.UUID, params sqlc.CreateDeviceParams) error { + _, err := q.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams{TenantID: lookup.TenantID, ID: session}) + if err == nil { + return ErrDeviceBindingConflict + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + id, err := q.CreateEnvironmentDevice(ctx, sqlc.CreateEnvironmentDeviceParams{ + ID: params.ID, TenantID: params.TenantID, Name: params.Name, + CredentialHash: params.CredentialHash, EnvironmentID: lookup.ID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrDeviceBindingConflict + } + if err != nil { + return err + } + _, err = q.BindSessionDevice(ctx, sqlc.BindSessionDeviceParams{TenantID: lookup.TenantID, ID: session, ID_2: id}) + return err +} diff --git a/services/agents-api/internal/store/local_environment_devices_test.go b/services/agents-api/internal/store/local_environment_devices_test.go new file mode 100644 index 000000000..38e6f420c --- /dev/null +++ b/services/agents-api/internal/store/local_environment_devices_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/google/uuid" +) + +func localEnvironment(t *testing.T, s *Store, tenant string) (Session, Environment) { + t.Helper() + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{ + Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`), + }) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + return session, environment +} + +func TestEnvironmentDeviceAuthorityAndLifecycle(t *testing.T) { + s, _ := testStore(t) + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + session, environment := localEnvironment(t, s, tenant) + sibling, _ := localEnvironment(t, s, tenant) + foreign, _ := localEnvironment(t, s, foreignTenant) + digest := device.HashCredential(uuid.NewString()) + if _, err := s.CreateEnvironmentDevice(t.Context(), foreignTenant, environment.ID, "foreign", digest); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign provisioning: %v", err) + } + bound, err := s.CreateEnvironmentDevice(t.Context(), tenant, environment.ID, "dedicated", digest) + if err != nil || bound.EnvironmentID != environment.ID { + t.Fatalf("provision: %+v %v", bound, err) + } + for _, other := range []Session{sibling, foreign} { + if err := s.BindSessionDevice(t.Context(), other.TenantID, other.ID, bound.ID); err == nil { + t.Fatal("dedicated credential bound to another Session") + } + } + devices, err := s.ListExecutionDevices(t.Context(), tenant) + if err != nil || len(devices) != 0 { + t.Fatalf("dedicated device entered general selection: %v %v", devices, err) + } + reopened, _ := testStore(t) + got, err := reopened.GetSessionDevice(t.Context(), tenant, session.ID) + if err != nil || got != bound { + t.Fatalf("durable exact binding: %+v %v", got, err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), bound.ID); err != nil || !ok { + t.Fatalf("valid credential unavailable: %v", err) + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), bound.ID); err != nil || ok { + t.Fatalf("deleted Environment still authenticates: %v", err) + } + status, err := s.TouchRuntimeHeartbeat(t.Context(), bound.ID) + if err != nil || !status.Deleted { + t.Fatalf("deleted Environment heartbeat: %+v %v", status, err) + } +} + +func TestEnvironmentDeviceProvisioningHasOneWinner(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + session, environment := localEnvironment(t, s, tenant) + var wg sync.WaitGroup + results := make(chan error, 6) + for range 6 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := s.CreateEnvironmentDevice(t.Context(), tenant, environment.ID, "runtime", device.HashCredential(uuid.NewString())) + results <- err + }() + } + wg.Wait() + close(results) + winners := 0 + for err := range results { + if err == nil { + winners++ + } else if !errors.Is(err, ErrDeviceBindingConflict) { + t.Fatal(err) + } + } + if winners != 1 { + t.Fatalf("provisioned %d devices", winners) + } + bound, err := s.GetSessionDevice(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if err := s.RevokeDevice(t.Context(), tenant, bound.ID); err != nil { + t.Fatal(err) + } + if _, err := s.CreateEnvironmentDevice(t.Context(), tenant, environment.ID, "replacement", device.HashCredential(uuid.NewString())); !errors.Is(err, ErrDeviceBindingConflict) { + t.Fatalf("silent placement replacement: %v", err) + } +} diff --git a/services/agents-api/internal/store/local_environment_file_write_test.go b/services/agents-api/internal/store/local_environment_file_write_test.go new file mode 100644 index 000000000..a852c8227 --- /dev/null +++ b/services/agents-api/internal/store/local_environment_file_write_test.go @@ -0,0 +1,127 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type localWriteResult struct { + size int64 + err error +} + +func startLocalWrite(ctx context.Context, w *execution.Worker, e store.Environment) <-chan localWriteResult { + done := make(chan localWriteResult, 1) + go func() { + size, err := w.WriteEnvironmentFile(ctx, e, "input", []byte("abc")) + done <- localWriteResult{size, err} + }() + return done +} + +func awaitLocalWrite(t *testing.T, done <-chan localWriteResult) localWriteResult { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(5 * time.Second): + t.Fatal("write did not return") + return localWriteResult{} + } +} + +func TestLocalEnvironmentFileWriteOwnsMutationBeforeDispatch(t *testing.T) { + h, w, environment := localWorker(t, true, false) + foreign := environment + foreign.TenantID = uuid.NewString() + if _, err := w.WriteEnvironmentFile(t.Context(), foreign, "input", nil); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign upload", err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := startLocalWrite(ctx, w, environment) + begin := h.read(proto.TypeWorkspaceWrite) + var request proto.WorkspaceWritePayload + if begin.DecodePayload(&request) != nil || request.Step != "begin" || request.EnvironmentID != environment.ID || request.SessionID != h.session.ID || request.Path != "input" || request.SizeBytes != 3 { + t.Fatal("upload identity changed") + } + intent, err := h.s.GetEnvironmentFileWrite(t.Context(), h.tenant, environment.ID, begin.ID) + if err != nil || intent.State != "pending" || intent.Identity.DeviceID != h.device.ID { + t.Fatal("dispatch preceded durable ownership", intent, err) + } + if _, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "concurrent", []store.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal("upload admitted concurrent execution", err) + } + cancel() + if got := awaitLocalWrite(t, done); !errors.Is(got.err, execution.ErrExecutionUnavailable) { + t.Fatal("detached observer", got.err) + } + h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "ready"}) + chunk := h.read(proto.TypeWorkspaceWrite) + if chunk.ID != begin.ID || chunk.DecodePayload(&request) != nil || request.Step != "chunk" || string(request.Data) != "abc" { + t.Fatal("body changed") + } + h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "received", Offset: 3}) + commit := h.read(proto.TypeWorkspaceWrite) + if commit.ID != begin.ID || commit.DecodePayload(&request) != nil || request.Step != "commit" { + t.Fatal("commit changed") + } + h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: 3}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "detached durable commit", func() bool { + got, e := h.s.GetEnvironmentFileWrite(t.Context(), h.tenant, environment.ID, begin.ID) + return e == nil && got.State == "committed" + }) + session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.LastTurn != nil { + t.Fatal("upload created model execution", err) + } +} + +func TestLocalEnvironmentFileWriteLostReceiptRemainsPending(t *testing.T) { + h, w, environment := localWorker(t, true, false) + done := startLocalWrite(t.Context(), w, environment) + begin := h.read(proto.TypeWorkspaceWrite) + if err := h.conn.Close(); err != nil { + t.Fatal(err) + } + if result := awaitLocalWrite(t, done); !errors.Is(result.err, execution.ErrExecutionUnavailable) { + t.Fatal(result.err) + } + intent, err := h.s.GetEnvironmentFileWrite(t.Context(), h.tenant, environment.ID, begin.ID) + if err != nil || intent.State != "pending" { + t.Fatal("disconnect guessed rejection", intent, err) + } + if _, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "after-loss", []store.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal("unknown upload admitted execution", err) + } +} + +func TestLocalEnvironmentFileWriteKnownRejectionReleasesMutation(t *testing.T) { + h, w, environment := localWorker(t, true, false) + for range 2 { + done := startLocalWrite(t.Context(), w, environment) + begin := h.read(proto.TypeWorkspaceWrite) + h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "resource_unavailable"}) + if result := awaitLocalWrite(t, done); !errors.Is(result.err, execution.ErrExecutionUnavailable) { + t.Fatal(result.err) + } + intent, err := h.s.GetEnvironmentFileWrite(t.Context(), h.tenant, environment.ID, begin.ID) + if err != nil || intent.State != "rejected" { + t.Fatal("rejection did not settle", intent, err) + } + } +} + +func TestLocalEnvironmentFileWriteRejectsUnscopedDevice(t *testing.T) { + _, w, environment := localWorker(t, false, false) + if _, err := w.WriteEnvironmentFile(t.Context(), environment, "input", nil); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("unscoped writer selected", err) + } +} diff --git a/services/agents-api/internal/store/local_environment_worker_test.go b/services/agents-api/internal/store/local_environment_worker_test.go new file mode 100644 index 000000000..ed6a3b481 --- /dev/null +++ b/services/agents-api/internal/store/local_environment_worker_test.go @@ -0,0 +1,161 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func localWorker(t *testing.T, scoped, execute bool) (*dispatchHarness, *execution.Worker, store.Environment) { + t.Helper() + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`), scoped) + environment, err := h.s.GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) + if err != nil { + t.Fatal(err) + } + caps := proto.AgentKindCapabilities{LocalEnvironment: true, Preparation: true, WorkspaceReadPreparation: true} + if execute { + caps.WorkspaceOutputExport = true + caps.Streaming, caps.Steering, caps.DurableTurns, caps.DurableInputReceipts = true, true, true, true + caps.WebSearchControl, caps.TextVerbosity, caps.ExecutionControls = true, true, true + caps.SubagentControl, caps.ToolObservations = true, true + } else { + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + t.Error("directory read requested model credentials") + return nil, errors.New("model unavailable") + } + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "local capability", func() bool { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + return false + } + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.LocalEnvironment + }) + w, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- w.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("local worker did not stop") + } + }) + return h, w, environment +} + +func TestLocalEnvironmentWorkerDirectoryUsesExactAuthorityWithoutModel(t *testing.T) { + h, w, environment := localWorker(t, true, false) + foreign := environment + foreign.TenantID = uuid.NewString() + if _, err := w.ReadEnvironmentDirectory(t.Context(), foreign, "reports"); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign read admitted", err) + } + result := startDirectoryRead(t.Context(), w, environment) + frame := h.read(proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || !proto.ValidWorkspaceReadPreparation(prepare.Configuration) || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID || prepare.Configuration.RemoteEnvironment != nil { + t.Fatal("local read did not preserve its exact identity") + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + read := h.read(proto.TypeWorkspaceRead) + var input proto.WorkspaceReadPayload + if read.DecodePayload(&input) != nil || input.EnvironmentID != environment.ID || input.Handle != handle || input.RunID != "" { + t.Fatal("local directory owner changed") + } + completeDirectoryRead(t, h, frame.ID, read.ID, false, false) + if got := awaitDirectoryResult(t, result); got.err != nil || len(got.value.Entries) != 1 { + t.Fatal("local directory failed", got.err) + } + session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.LastTurn != nil || session.EnvironmentInputActivity != nil { + t.Fatal("local read admitted execution", err) + } +} + +func TestLocalEnvironmentWorkerRejectsGeneralDeviceDespiteCapability(t *testing.T) { + h, w, environment := localWorker(t, false, false) + if _, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("general device used as local authority", err) + } + other, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "unassigned", Configuration: h.session.Configuration}) + if err != nil { + t.Fatal(err) + } + unassigned, err := h.s.GetSessionEnvironment(t.Context(), h.tenant, other.ID) + if err != nil { + t.Fatal(err) + } + if _, err := w.ReadEnvironmentDirectory(t.Context(), unassigned, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("unassigned environment selected general device", err) + } + if _, err := h.s.GetSessionDevice(t.Context(), h.tenant, other.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("read persisted an unauthorized placement", err) + } +} + +func TestLocalEnvironmentWorkerSchedulesPreparationWithoutRemoteResolver(t *testing.T) { + h, worker, environment := localWorker(t, true, true) + reservation, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "local-input", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + if err != nil { + t.Fatal(err) + } + // The scheduler's scan interval is five seconds. + _ = h.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + var frame proto.Envelope + for frame.Type != proto.TypeExecutionPrepare { + if h.conn.ReadJSON(&frame) != nil { + t.Fatal("local reservation was not scheduled") + } + } + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID || prepare.Configuration.RemoteEnvironment != nil || prepare.Configuration.WorkDir != "" { + t.Fatal("local preparation lost identity") + } + before, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || before.LastTurn != nil { + t.Fatal("preparation admitted execution before readiness", err) + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + startFrame := h.read(proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if startFrame.DecodePayload(&start) != nil || start.Handle != handle || start.RunID == "" || start.Prompt != "first" { + t.Fatal("local Start changed reservation identity") + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "complete", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "local-native-history"}}) + completeLocalArtifactExport(t, h, worker, environment) + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "local completion", func() bool { + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) + return err == nil && turn.Status == store.TurnCompleted + }) + settled, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, reservation.ID) + if err != nil || settled.State != store.EnvironmentInputAdmitted || len(settled.Receipts) != 1 { + t.Fatal("local reservation did not settle", err) + } + bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) + if err != nil || bound.Device.EnvironmentID != environment.ID || bound.NativeSessionID != "local-native-history" { + t.Fatal("local native identity was not retained", err) + } + artifacts, err := h.s.ListSessionArtifacts(t.Context(), h.tenant, h.session.ID, environment.ID, "", 20, false) + if err != nil || len(artifacts.Artifacts) != 1 || artifacts.Artifacts[0].Path != "/workspace/outputs/result.bin" || artifacts.Artifacts[0].TurnID != start.RunID || artifacts.Artifacts[0].SizeBytes != 3 { + t.Fatalf("completed turn did not publish output: %+v %v", artifacts, err) + } +} diff --git a/services/agents-api/internal/store/mcode_public_native_test.go b/services/agents-api/internal/store/mcode_public_native_test.go new file mode 100644 index 000000000..80fbabc4a --- /dev/null +++ b/services/agents-api/internal/store/mcode_public_native_test.go @@ -0,0 +1,202 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// This opt-in fixture never supplies model responses. The provider options must +// name a real API; private operator files are deliberately outside the repository. +func TestNativeMCodePublicExecution(t *testing.T) { + python, binary, root, optionsFile := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), os.Getenv("PARSAR_NATIVE_DAEMON_BIN"), os.Getenv("PARSAR_NATIVE_PROOF_DIR"), os.Getenv("PARSAR_MCODE_REAL_OPTIONS") + if python == "" || binary == "" || root == "" || optionsFile == "" { + t.Skip("native daemon, fixed SDK, private real-model options and proof directory required") + } + raw, err := os.ReadFile(optionsFile) + if err != nil { + t.Fatal(err) + } + var options map[string]any + if json.Unmarshal(raw, &options) != nil { + t.Fatal("invalid private options") + } + model, _ := options["model"].(string) + if model == "" { + t.Fatal("real model required") + } + h := newDispatchHarness(t) + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { return options, nil } + home, err := os.MkdirTemp(root, "mcode-public-") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 12*time.Minute) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + stopped := make(chan error, 1) + go func() { stopped <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-stopped: + case <-time.After(20 * time.Second): + t.Error("worker did not stop") + } + }() + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, + {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "mcode", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + stop := startMCodeDaemon(t, h, home, binary) + defer func() { stop() }() + evidence := filepath.Join(home, "public.json") + run := func(stage string) { + command := exec.CommandContext(ctx, python, "../../tests/official_mcode_native.py", server.URL, token, foreign, model, stage, evidence) + if log, err := command.CombinedOutput(); err != nil { + data, _ := os.ReadFile(evidence) + var identity struct { + Session string `json:"session"` + } + _ = json.Unmarshal(data, &identity) + if page, e := h.s.ListTurns(ctx, h.tenant, identity.Session, "", 100, true); e == nil { + diagnostic, _ := json.Marshal(page) + text := string(diagnostic) + if provider, ok := options["mcode_provider"].(map[string]any); ok { + if opts, ok := provider["options"].(map[string]any); ok { + if key, ok := opts["apiKey"].(string); ok && key != "" { + text = strings.ReplaceAll(text, key, "[REDACTED]") + } + } + } + _ = os.WriteFile(filepath.Join(home, "failed-turns.json"), []byte(text), 0600) + } + t.Fatalf("public mcode %s failed: %v %s; evidence %s", stage, err, log, home) + } + } + run("initial") + data, err := os.ReadFile(evidence) + if err != nil { + t.Fatal(err) + } + var proof struct { + Session string `json:"session"` + FirstTurn string `json:"first_turn"` + } + if json.Unmarshal(data, &proof) != nil { + t.Fatal("invalid evidence") + } + turn, err := h.s.GetTurn(ctx, h.tenant, proof.Session, proof.FirstTurn) + if err != nil { + t.Fatal(err) + } + inputs, err := h.s.ListTurnInputs(ctx, h.tenant, proof.Session, proof.FirstTurn, 0, 100) + if err != nil || len(inputs) != 2 { + t.Fatal("steering input not in same turn", err) + } + var outcome execution.Result + if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.AppliedThrough != inputs[1].Sequence { + t.Fatal("native applied receipt missing") + } + before, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID == "" { + t.Fatal("native binding missing", err) + } + stop() + stop = startMCodeDaemon(t, h, home, binary) + run("resume") + after, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID != after.NativeSessionID { + t.Fatal("native history changed", err) + } + if err := os.WriteFile(filepath.Join(home, "native-session-id"), []byte(after.NativeSessionID), 0600); err != nil { + t.Fatal(err) + } + t.Logf("Real mcode common-contract acceptance passed: %s", home) +} + +func startMCodeDaemon(t *testing.T, h *dispatchHarness, home, binary string) func() { + t.Helper() + if h.conn != nil { + _ = h.conn.Close() + } + profile := filepath.Join(home, "parsar-daemon", "execution") + if err := os.MkdirAll(profile, 0700); err != nil { + t.Fatal(err) + } + auth, _ := json.Marshal(map[string]string{"server_url": h.url + "/api/v1", "runtime_id": h.device.ID, "runner_credential": h.credential, "device_name": "mcode native proof"}) + if err := os.WriteFile(filepath.Join(profile, "auth.json"), auth, 0600); err != nil { + t.Fatal(err) + } + log, err := os.OpenFile(filepath.Join(home, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + old, _ := h.registry.LookupDevice(h.device.ID) + cmd := exec.Command(binary, "connect", "--profile", "execution") + cmd.Env = append(os.Environ(), "PARSAR_HOME="+home, "PARSAR_MCODE_AGENTS_API=1") + cmd.Stdout, cmd.Stderr = log, log + if err = cmd.Start(); err != nil { + log.Close() + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + stop := func() { + select { + case <-done: + return + default: + } + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + } + _ = log.Close() + } + t.Cleanup(stop) + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + if peer, err := h.registry.LookupDevice(h.device.ID); err == nil && peer != old { + if info, found, known := peer.AgentKindStatus("mcode"); found && known && info.Available && info.Capabilities.EnvironmentNone { + return stop + } + } + select { + case <-done: + t.Fatalf("daemon exited; evidence %s", home) + default: + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("native mcode daemon not ready; evidence %s", home) + return stop +} diff --git a/services/agents-api/internal/store/mcp_credentials.go b/services/agents-api/internal/store/mcp_credentials.go new file mode 100644 index 000000000..93e9a7762 --- /dev/null +++ b/services/agents-api/internal/store/mcp_credentials.go @@ -0,0 +1,137 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type MCPCredentialRequest struct { + ServerLabel, ServerURL string + CredentialID *string +} + +// MCPCredentialBinding freezes a non-secret selection, including anonymous +// servers. It is private execution configuration, not the public MCP tool shape. +type MCPCredentialBinding struct { + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url"` + VaultID string `json:"vault_id,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + AuthType string `json:"auth_type,omitempty"` +} + +func attachedVaultIDs(ids []string) ([]pgtype.UUID, error) { + result := make([]pgtype.UUID, 0, len(ids)) + seen := map[pgtype.UUID]bool{} + for _, raw := range ids { + id, err := parseID(raw) + if err != nil { + return nil, ErrNotFound + } + if !seen[id] { + result = append(result, id) + seen[id] = true + } + } + return result, nil +} + +// ResolveMCPCredentials reads metadata only. Resource changes after this read do +// not reselect credentials for an accepted Session or its creation retries. +func (s *Store) ResolveMCPCredentials(ctx context.Context, tenantID string, vaultIDs []string, requests []MCPCredentialRequest) ([]MCPCredentialBinding, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + vaults, err := attachedVaultIDs(vaultIDs) + if err != nil { + return nil, err + } + owned, err := s.queries.GetAttachedVaultIDs(ctx, sqlc.GetAttachedVaultIDsParams{TenantID: tenant, VaultIds: vaults}) + if err != nil { + return nil, errors.New("cannot resolve attached Vaults") + } + if len(owned) != len(vaults) { + return nil, ErrNotFound + } + bindings := make([]MCPCredentialBinding, 0, len(requests)) + for _, request := range requests { + if request.ServerLabel == "" || request.ServerURL == "" { + return nil, ErrInvalidInput + } + var id pgtype.UUID + if request.CredentialID != nil { + id, err = parseID(*request.CredentialID) + if err != nil { + return nil, ErrNotFound + } + } + rows, err := s.queries.FindMCPStaticCredentials(ctx, sqlc.FindMCPStaticCredentialsParams{ + TenantID: tenant, VaultIds: vaults, McpServerUrl: request.ServerURL, CredentialID: id, + }) + if err != nil { + return nil, errors.New("cannot resolve MCP credential") + } + if len(rows) == 0 && request.CredentialID != nil { + return nil, ErrNotFound + } + if len(rows) > 1 { + return nil, ErrInvalidInput + } + binding := MCPCredentialBinding{ServerLabel: request.ServerLabel, ServerURL: request.ServerURL} + if len(rows) == 1 { + binding.VaultID, binding.CredentialID = uuid.UUID(rows[0].VaultID.Bytes).String(), uuid.UUID(rows[0].ID.Bytes).String() + binding.AuthType = rows[0].AuthType + } + bindings = append(bindings, binding) + } + return bindings, nil +} + +// MCPBearerToken is execution-only: recheck the complete frozen authorization +// before decrypting. Never persist or log its result, or downgrade failure to an +// anonymous request. Public resource queries do not select ciphertext. +func (s *Store) MCPBearerToken(ctx context.Context, tenantID string, vaultIDs []string, binding MCPCredentialBinding) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", ErrNotFound + } + vaults, err := attachedVaultIDs(vaultIDs) + if err != nil { + return "", err + } + vault, err := parseID(binding.VaultID) + if err != nil { + return "", ErrNotFound + } + id, err := parseID(binding.CredentialID) + if err != nil || binding.AuthType != "static_bearer" || binding.ServerURL == "" { + return "", ErrNotFound + } + ciphertext, err := s.queries.GetMCPStaticCredentialCiphertext(ctx, sqlc.GetMCPStaticCredentialCiphertextParams{ + TenantID: tenant, VaultIds: vaults, VaultID: vault, CredentialID: id, McpServerUrl: binding.ServerURL, + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", errors.New("cannot read MCP credential") + } + if s.credentialCipher == nil { + return "", ErrCredentialStorageUnavailable + } + plaintext, err := s.credentialCipher.Open(ciphertext, credentialcrypto.Binding{ + TenantID: uuid.UUID(tenant.Bytes).String(), VaultID: uuid.UUID(vault.Bytes).String(), CredentialID: uuid.UUID(id.Bytes).String(), + AuthType: binding.AuthType, Destination: binding.ServerURL, + }) + if err != nil { + return "", errors.New("MCP credential decryption failed") + } + return string(plaintext), nil +} diff --git a/services/agents-api/internal/store/mcp_credentials_test.go b/services/agents-api/internal/store/mcp_credentials_test.go new file mode 100644 index 000000000..4d1328642 --- /dev/null +++ b/services/agents-api/internal/store/mcp_credentials_test.go @@ -0,0 +1,124 @@ +package store + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var vaults []Vault + for _, owner := range []string{tenant, tenant, foreign} { + vault, err := s.CreateVault(t.Context(), owner, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + vaults = append(vaults, vault) + } + token := " \t" + uuid.NewString() + "雪\n" + destination := "https://mcp.example/tools" + create := func(vault Vault) Credential { + t.Helper() + value, err := s.CreateStaticCredential(t.Context(), vault.TenantID, vault.ID, CreateStaticCredentialInput{Name: "private", MCPServerURL: destination, Token: token}) + if err != nil { + t.Fatal(err) + } + return value + } + first, foreignCredential := create(vaults[0]), create(vaults[2]) + attached := []string{vaults[0].ID, vaults[1].ID} + requests := []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination}, {ServerLabel: "anonymous", ServerURL: "https://anonymous.example/mcp"}} + bindings, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) + if err != nil || len(bindings) != 2 || bindings[0].CredentialID != first.ID || bindings[0].AuthType != "static_bearer" || bindings[1].CredentialID != "" { + t.Fatal("metadata selection or frozen anonymous decision differs", err) + } + encoded, err := json.Marshal(bindings) + if err != nil || bytes.Contains(encoded, []byte(token)) || strings.Contains(string(encoded), "ciphertext") { + t.Fatal("private binding contains secret material") + } + second := create(vaults[1]) + if _, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests); !errors.Is(err, ErrInvalidInput) { + t.Fatal("ambiguous selection was admitted") + } + requests[0].CredentialID = &second.ID + explicit, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) + if err != nil || explicit[0].CredentialID != second.ID || requests[1].CredentialID != nil { + t.Fatal("explicit selection did not disambiguate", err) + } + for _, tc := range []struct { + owner string + vaults []string + id, url string + }{ + {tenant, attached, foreignCredential.ID, destination}, {foreign, attached, first.ID, destination}, + {tenant, []string{vaults[1].ID}, first.ID, destination}, {tenant, attached, first.ID, destination + "/other"}, + {tenant, []string{vaults[0].ID, vaults[2].ID}, first.ID, destination}, {tenant, []string{uuid.NewString()}, first.ID, destination}, + } { + _, err := public.ResolveMCPCredentials(t.Context(), tc.owner, tc.vaults, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) + if !errors.Is(err, ErrNotFound) { + t.Fatal("unowned, unattached or wrong-destination selection was admitted") + } + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]) + if err != nil || got != token { + t.Fatal("frozen selection or opaque bytes changed across restart", err) + } + if got, err := public.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); !errors.Is(err, ErrCredentialStorageUnavailable) || got != "" { + t.Fatal("missing key did not fail execution closed") + } + key[0] ^= 1 + wrong, _ := credentialcrypto.New(key) + if got, err := NewWithCredentialCipher(pool, wrong).MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" || strings.Contains(err.Error(), token) { + t.Fatal("wrong key leaked or decrypted a credential") + } + for _, mutate := range []func(*MCPCredentialBinding){ + func(b *MCPCredentialBinding) { b.VaultID = vaults[1].ID }, + func(b *MCPCredentialBinding) { b.CredentialID = foreignCredential.ID }, + func(b *MCPCredentialBinding) { b.ServerURL += "/other" }, + func(b *MCPCredentialBinding) { b.AuthType = "other" }, + } { + binding := bindings[0] + mutate(&binding) + if got, err := s.MCPBearerToken(t.Context(), tenant, attached, binding); !errors.Is(err, ErrNotFound) || got != "" { + t.Fatal("substituted frozen authorization was decrypted") + } + } + for _, scope := range []struct { + owner string + vaults []string + }{{foreign, attached}, {tenant, []string{vaults[1].ID}}} { + if got, err := s.MCPBearerToken(t.Context(), scope.owner, scope.vaults, bindings[0]); !errors.Is(err, ErrNotFound) || got != "" { + t.Fatal("tenant or attachment authorization was bypassed") + } + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext, 15, get_byte(token_ciphertext,15) # 1) WHERE id=$1", first.ID); err != nil { + t.Fatal(err) + } + if got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" { + t.Fatal("tampered ciphertext decrypted") + } + if _, err := public.GetCredential(t.Context(), tenant, first.VaultID, first.ID); err != nil { + t.Fatal("safe metadata lookup depended on ciphertext", err) + } +} diff --git a/services/agents-api/internal/store/native_daemon_test.go b/services/agents-api/internal/store/native_daemon_test.go new file mode 100644 index 000000000..05cbd8729 --- /dev/null +++ b/services/agents-api/internal/store/native_daemon_test.go @@ -0,0 +1,84 @@ +package store_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func nativeDispatchHarness(t *testing.T) (*dispatchHarness, context.Context, string) { + t.Helper() + return nativeDispatchHarnessWithTimeout(t, 120*time.Second) +} + +func nativeDispatchHarnessWithTimeout(t *testing.T, timeout time.Duration) (*dispatchHarness, context.Context, string) { + t.Helper() + binary, root := os.Getenv("PARSAR_NATIVE_DAEMON_BIN"), os.Getenv("PARSAR_NATIVE_PROOF_DIR") + if binary == "" || root == "" { + t.Skip("explicit native daemon binary and evidence directory required") + } + h := newDispatchHarness(t) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + t.Cleanup(cancel) + home, err := os.MkdirTemp(root, "execution-native-") + if err != nil { + t.Fatal(err) + } + startNativeDispatchDaemon(t, h, home, binary) + return h, ctx, home +} + +func startNativeDispatchDaemon(t *testing.T, h *dispatchHarness, home, binary string) { + t.Helper() + oldPeer, _ := h.registry.LookupDevice(h.device.ID) + if h.conn != nil { + _ = h.conn.Close() + } + profile := filepath.Join(home, "parsar-daemon", "execution") + if err := os.MkdirAll(profile, 0700); err != nil { + t.Fatal(err) + } + auth, _ := json.Marshal(map[string]string{"server_url": h.url + "/api/v1", "runtime_id": h.device.ID, "runner_credential": h.credential, "device_name": "native proof"}) + if err := os.WriteFile(filepath.Join(profile, "auth.json"), auth, 0600); err != nil { + t.Fatal(err) + } + daemonLog, err := os.Create(filepath.Join(home, "daemon.log")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = daemonLog.Close() }) + cmd := exec.Command(binary, "connect", "--profile", "execution") + cmd.Env = append(os.Environ(), "PARSAR_HOME="+home) + cmd.Stdout, cmd.Stderr = daemonLog, daemonLog + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + stopped := make(chan error, 1) + go func() { stopped <- cmd.Wait() }() + t.Cleanup(func() { + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-stopped: + case <-time.After(8 * time.Second): + _ = cmd.Process.Kill() + <-stopped + } + }) + deadline := time.Now().Add(20 * time.Second) + for { + peer, e := h.registry.LookupDevice(h.device.ID) + if e == nil && peer != oldPeer { + if info, found, known := peer.AgentKindStatus("codex"); known && found && info.Available && info.Capabilities.EnvironmentNone { + break + } + } + if time.Now().After(deadline) { + t.Fatalf("native daemon not ready; logs %s", home) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/services/agents-api/internal/store/native_daemon_workspace_directory_test.go b/services/agents-api/internal/store/native_daemon_workspace_directory_test.go new file mode 100644 index 000000000..50ec5fd6f --- /dev/null +++ b/services/agents-api/internal/store/native_daemon_workspace_directory_test.go @@ -0,0 +1,61 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func (a *nativeHarnessArtifact) observeDaemonDirectories(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string, retained bool) { + t.Helper() + target := proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: a.handle, MaxEntries: proto.WorkspaceDirectoryMaxEntries} + if a.runID != "" { + target.Handle, target.RunID = "", a.runID + } + for _, limit := range []int{proto.WorkspaceDirectoryMaxEntries, 1} { + target.MaxEntries = limit + result, err := a.peer.ListWorkspaceDirectory(ctx, target) + if err != nil || result.Outcome != "completed" || !result.CloseAcknowledged || + !proto.ValidWorkspaceDirectory(result.Directory, limit) || result.Directory.Truncated != (limit == 1) { + t.Fatal("daemon native directory read differs", phase, limit, err, result.Outcome, result.ErrorCode) + } + if limit > 1 { + files := make(map[string]int64) + for _, entry := range result.Directory.Entries { + if entry.Kind == "file" && entry.SizeBytes != nil { + files[entry.Name] = *entry.SizeBytes + } + } + want := map[string]int64{"bounded-read.bin": int64(len(nativeHarnessReadBinary())), "bounded-empty.bin": 0} + if retained { + want["retained.txt"] = int64(len("remote-file-content\n")) + } + for name, size := range want { + if got, ok := files[name]; !ok || got != size { + t.Fatal("daemon directory omitted native file metadata", phase, name, got, size) + } + } + } + observations, _ := a.proof["daemon_directory_observations"].([]map[string]any) + a.proof["daemon_directory_observations"] = append(observations, map[string]any{ + "phase": phase, "owner": owner, "handle": target.Handle, "run_id": target.RunID, + "max_entries": limit, "directory": result.Directory, "close_acknowledged": result.CloseAcknowledged, + }) + } + for _, check := range []struct{ environment, path, code string }{ + {uuid.NewString(), "", "resource_unavailable"}, + {a.environment, "missing-" + uuid.NewString(), "not_found"}, + {a.environment, "../outside", "invalid_request"}, + } { + target.EnvironmentID, target.Path = check.environment, check.path + result, err := a.peer.ListWorkspaceDirectory(ctx, target) + if err != nil || result.Outcome != "rejected" || result.ErrorCode != check.code || result.Directory != nil || len(result.Data) != 0 { + t.Fatal("daemon directory rejection differs", phase, err, result.Outcome, result.ErrorCode) + } + } + if a.current(t) != owner { + t.Fatal("daemon directory read replaced the native execution owner") + } +} diff --git a/services/agents-api/internal/store/native_daemon_workspace_read_test.go b/services/agents-api/internal/store/native_daemon_workspace_read_test.go new file mode 100644 index 000000000..fae4df61f --- /dev/null +++ b/services/agents-api/internal/store/native_daemon_workspace_read_test.go @@ -0,0 +1,62 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func (a *nativeHarnessArtifact) observeDaemonReads(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string, retained bool) { + t.Helper() + target := proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: a.handle, MaxBytes: proto.WorkspaceReadMaxBytes} + if a.runID != "" { + target.Handle, target.RunID = "", a.runID + } + cases := []struct { + path string + data []byte + }{ + {"bounded-read.bin", nativeHarnessReadBinary()}, + {"bounded-empty.bin", []byte{}}, + } + if retained { + cases = append(cases, struct { + path string + data []byte + }{"retained.txt", []byte("remote-file-content\n")}) + } + for _, check := range cases { + target.Path = check.path + result, err := a.peer.ReadWorkspaceFile(ctx, target) + want := check.data[:min(len(check.data), target.MaxBytes)] + if err != nil || result.Outcome != "completed" || !result.CloseAcknowledged || + !bytes.Equal(result.Data, want) || result.Truncated != (len(check.data) > target.MaxBytes) { + t.Fatal("daemon native workspace read differs", phase, check.path, err, result.Outcome, result.ErrorCode) + } + digest := sha256.Sum256(result.Data) + observations, _ := a.proof["daemon_read_observations"].([]map[string]any) + a.proof["daemon_read_observations"] = append(observations, map[string]any{ + "phase": phase, "path": check.path, "owner": owner, "handle": target.Handle, "run_id": target.RunID, + "bytes": len(result.Data), "sha256": hex.EncodeToString(digest[:]), "truncated": result.Truncated, "close_acknowledged": true, + }) + } + for _, check := range []struct{ environment, path, code string }{ + {uuid.NewString(), "bounded-read.bin", "resource_unavailable"}, + {a.environment, "missing-" + uuid.NewString(), "not_found"}, + } { + target.EnvironmentID, target.Path = check.environment, check.path + result, err := a.peer.ReadWorkspaceFile(ctx, target) + if err != nil || result.Outcome != "rejected" || result.ErrorCode != check.code || len(result.Data) != 0 { + t.Fatal("daemon read rejection differs", phase, err, result.Outcome, result.ErrorCode) + } + } + if a.current(t) != owner { + t.Fatal("daemon workspace read replaced the native execution owner") + } + a.observeDaemonDirectories(t, ctx, owner, phase, retained) +} diff --git a/services/agents-api/internal/store/native_environment_adapter_helpers_test.go b/services/agents-api/internal/store/native_environment_adapter_helpers_test.go new file mode 100644 index 000000000..1bc307014 --- /dev/null +++ b/services/agents-api/internal/store/native_environment_adapter_helpers_test.go @@ -0,0 +1,311 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func prepareDaemonRemoteWorkspace(t *testing.T, root, instruction string) string { + t.Helper() + for _, name := range []string{"harness", "executor/codex", "workspace"} { + if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + workspace := filepath.Join(root, "workspace") + files := map[string]string{ + "harness/AGENTS.md": "End every response with WRONG_LOCAL_INSTRUCTIONS.\n", + "workspace/AGENTS.md": "For each test command, end your final response with " + instruction + ".\n", + "workspace/placement.sh": `#!/bin/sh +set -eu +phase="$1" +pwd > "$phase.cwd" +printf '%s\n' "$phase" >> execution-count +printf 'remote-stdout:%s\n' "$phase" +printf 'remote-stderr:%s\n' "$phase" >&2 +for name in CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY MINIMAX_VALIDATION_KEY; do + eval 'value=${'"$name"'-}' + test -z "$value" || { printf '%s\n' "$name" >> credential-failure; exit 23; } +done +printf 'remote-file-content\n' > retained.txt +exit 7 +`, + "workspace/long.sh": `#!/bin/sh +set -eu +printf '%s\n' "$$" > "$1.pid" +printf 'started\n' > "$1.started" +while :; do date +%s > "$1.heartbeat"; sleep 1; done +`, + } + for name, content := range files { + mode := os.FileMode(0600) + if strings.HasSuffix(name, ".sh") { + mode = 0700 + } + if err := os.WriteFile(filepath.Join(root, name), []byte(content), mode); err != nil { + t.Fatal(err) + } + } + return workspace +} + +func startDaemonRemoteExecutor(t *testing.T, ctx context.Context, root, local, remote, binary, image, registryURL, environment string, credential store.IssuedExecutorCredential) string { + t.Helper() + if launcher := os.Getenv("PARSAR_EXECUTOR_LAUNCHER"); launcher != "" { + return startDaemonLauncherExecutor(t, ctx, root, local, remote, binary, image, registryURL, environment, credential, launcher) + } + container := "parsar-daemon-environment-" + uuid.NewString() + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = exec.CommandContext(cleanup, "docker", "rm", "-f", container).Run() + }) + args := []string{"run", "--detach", "--name", container, "--network", "host", "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--env", "CODEX_API_KEY", "--env", "HOME=/executor", "--env", "CODEX_HOME=/executor/codex", "--env", "RUST_LOG=off", "--env", "NO_PROXY=127.0.0.1,localhost", "--workdir", remote, + "--mount", "type=bind,src=" + binary + ",dst=/usr/local/bin/codex,readonly", "--mount", "type=bind,src=" + filepath.Join(root, "executor") + ",dst=/executor", "--mount", "type=bind,src=" + local + ",dst=" + remote, + "--entrypoint", "/usr/local/bin/codex", image, "exec-server", "--remote", registryURL, "--environment-id", environment} + command := exec.CommandContext(ctx, "docker", args...) + command.Env = append(os.Environ(), "CODEX_API_KEY="+credential.Token) + if err := command.Run(); err != nil { + t.Fatal("native executor container failed to start", err) + } + return container +} + +func awaitDaemonRemoteCondition(t *testing.T, ctx context.Context, timeout time.Duration, label string, ready func() bool) { + t.Helper() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for { + if ready() { + return + } + select { + case <-ctx.Done(): + t.Fatal(label, "context expired") + case <-deadline.C: + t.Fatal(label, "timed out") + case <-tick.C: + } + } +} + +func daemonRemotePrompt(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { + return daemonRemotePromptWithStart(t, ctx, peer, req, cancelWhen, nil) +} + +func daemonRemotePromptWithStart(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool, start func(string) error) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { + t.Helper() + run := uuid.NewString() + req.RunID = run + sub, err := peer.SubscribeDurable(run) + if err != nil { + t.Fatal(err) + } + defer peer.Unsubscribe(run) + if start != nil { + if err = start(run); err != nil { + t.Fatal(err) + } + } else { + envelope, err := proto.NewEnvelope(proto.TypePromptRequest, run, req) + if err != nil { + t.Fatal(err) + } + if err = peer.Send(ctx, envelope); err != nil { + t.Fatal(err) + } + } + var events []proto.Envelope + var done proto.DonePayload + cancelID := "" + type cancelResult struct { + ack proto.InteractionDecisionAckPayload + err error + } + cancelReply := make(chan cancelResult, 1) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + t.Fatal("remote daemon prompt timed out") + case <-tick.C: + if cancelWhen != nil && cancelID == "" && cancelWhen() { + cancelID = uuid.NewString() + request, e := proto.NewEnvelope(proto.TypePromptCancel, run, proto.PromptCancelPayload{DeliveryID: cancelID}) + if e != nil { + t.Fatal(e) + } + go func(deliveryID string) { + ackCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + ack, err := peer.SendAndWaitInteractionAck(ackCtx, request, deliveryID) + cancelReply <- cancelResult{ack: ack, err: err} + }(cancelID) + } + case result := <-cancelReply: + if result.err != nil { + t.Fatal("remote cancellation receipt failed", result.err) + } + if result.ack.Outcome != nil { + done = *result.ack.Outcome + } + return done, events, &result.ack + case event, ok := <-sub.Events: + if !ok { + t.Fatal("remote subscription closed", sub.Err()) + } + events = append(events, event) + if event.Type == proto.TypeDone { + if err = event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + if cancelWhen == nil { + return done, events, nil + } + if cancelID == "" { + t.Fatal("remote Turn ended before cancellation") + } + } + } + } +} + +func daemonRemoteHasError(events []proto.Envelope) bool { + for _, event := range events { + if event.Type == proto.TypeError { + return true + } + } + return false +} + +func assertDaemonRemoteCommand(t *testing.T, events []proto.Envelope, phase, workspace string) { + t.Helper() + for _, event := range events { + if event.Type != proto.TypeToolCall { + continue + } + var tool proto.ToolCallPayload + if event.DecodePayload(&tool) != nil { + t.Fatal("invalid tool observation") + } + observation := tool.Observation + if tool.Stage == "after" && observation != nil && observation.Kind == "command" && observation.ExitCode != nil && *observation.ExitCode == 7 && observation.Cwd != nil && *observation.Cwd == workspace && strings.Contains(string(observation.Output), "remote-stdout:"+phase) && strings.Contains(string(observation.Output), "remote-stderr:"+phase) { + return + } + } + t.Fatal("missing actual remote command stdout/stderr/exit/cwd observation") +} + +func awaitDaemonRemoteExit(t *testing.T, ctx context.Context, container, workspace string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(workspace, "cancel.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 1 { + t.Fatal("invalid owned command PID") + } + awaitDaemonRemoteCondition(t, ctx, 60*time.Second, "owned remote process exit", func() bool { + result, err := exec.CommandContext(ctx, "docker", "exec", container, "sh", "-c", `if kill -0 "$1" 2>/dev/null; then printf alive; else printf gone; fi`, "--", strconv.Itoa(pid)).Output() + return err == nil && string(result) == "gone" + }) + before, err := os.ReadFile(filepath.Join(workspace, "cancel.heartbeat")) + if err != nil { + t.Fatal(err) + } + select { + case <-ctx.Done(): + t.Fatal("heartbeat check context expired") + case <-time.After(1200 * time.Millisecond): + } + after, err := os.ReadFile(filepath.Join(workspace, "cancel.heartbeat")) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("cancelled command heartbeat continued") + } + state, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.Running}}", container).Output() + if err != nil || strings.TrimSpace(string(state)) != "true" { + t.Fatal("executor container was stopped instead of its command") + } +} + +func assertDaemonRemoteSecrets(t *testing.T, root, stateKey, provider, executor, harness, device string) { + t.Helper() + configRoot := filepath.Join(root, "parsar-daemon", "agent-sessions") + string(os.PathSeparator) + profile := filepath.Join(root, "parsar-daemon", "execution", "auth.json") + if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + // Native executable symlinks refer to paths inside the executor container. + // Scan persisted regular files without following executable links. + if !entry.Type().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(harness)) { + t.Errorf("harness credential persisted in %s", path) + } + if bytes.Contains(data, []byte(executor)) { + info, e := entry.Info() + if os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || path != filepath.Join(root, "executor", "credential.json") || e != nil || info.Mode().Perm() != 0600 { + t.Errorf("executor credential outside its private provisioned file: %s", path) + } + } + if bytes.Contains(data, []byte(device)) && path != profile { + t.Errorf("device credential outside its profile: %s", path) + } + if bytes.Contains(data, []byte(provider)) { + info, e := entry.Info() + if !strings.HasPrefix(path, configRoot) || filepath.Base(path) != "config.toml" || e != nil || info.Mode().Perm() != 0600 { + t.Errorf("provider credential outside private native config: %s", path) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + config := filepath.Join(configRoot, stateKey, "config.toml") + if _, err := os.Stat(config); err != nil { + t.Fatal("expected private provider config missing") + } +} + +func persistDaemonRemoteProof(t *testing.T, root string, proof map[string]any, secrets []string) { + t.Helper() + data, err := json.MarshalIndent(proof, "", " ") + if err != nil { + t.Error(err) + return + } + for _, secret := range secrets { + if secret != "" && bytes.Contains(data, []byte(secret)) { + t.Error("credential appeared in observed response evidence") + data = bytes.ReplaceAll(data, []byte(secret), []byte("[REDACTED]")) + } + } + if err = os.WriteFile(filepath.Join(root, "remote-adapter-proof.json"), data, 0600); err != nil { + t.Error(err) + } +} diff --git a/services/agents-api/internal/store/native_environment_adapter_test.go b/services/agents-api/internal/store/native_environment_adapter_test.go new file mode 100644 index 000000000..b99152505 --- /dev/null +++ b/services/agents-api/internal/store/native_environment_adapter_test.go @@ -0,0 +1,295 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeDaemonRemoteEnvironment(t *testing.T) { + testNativeDaemonRemoteEnvironment(t, false) +} + +func TestNativeDaemonPreparedRemoteEnvironment(t *testing.T) { + testNativeDaemonRemoteEnvironment(t, true) +} + +func testNativeDaemonRemoteEnvironment(t *testing.T, prepared bool) { + testNativeDaemonRemoteEnvironmentWithArtifact(t, prepared, "") +} + +func testNativeDaemonRemoteEnvironmentWithArtifact(t *testing.T, prepared bool, artifactPath string) { + binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + keyFile := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") + if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" { + t.Skip("pinned native binary, local executor image and real provider key file required") + } + version, err := exec.Command(binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native Codex 0.153.4 required") + } + keyBytes, err := os.ReadFile(keyFile) + if err != nil || strings.TrimSpace(string(keyBytes)) == "" { + t.Fatal("real model credential unavailable") + } + key := strings.TrimSpace(string(keyBytes)) + t.Setenv("PARSAR_CODEX_BIN", binary) + var artifact *nativeHarnessArtifact + if artifactPath != "" { + artifact = newNativeHarnessArtifact(t, binary, artifactPath) + t.Setenv("PARSAR_CODEX_HARNESS_BIN", artifactPath) + } + h, ctx, root := nativeDispatchHarnessWithTimeout(t, 8*time.Minute) + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + t.Fatal(err) + } + info, found, known := peer.AgentKindStatus("codex") + if !known || !found || !info.Available || !info.Capabilities.RemoteEnvironment { + t.Fatal("real heartbeat omitted remote capability") + } + prompt := daemonRemotePrompt + if prepared { + if !info.Capabilities.Preparation { + t.Fatal("real heartbeat omitted preparation capability") + } + prompt = daemonPreparedRemotePrompt + } + // These credentials do not exist when the authenticated daemon starts. + principal := store.FixtureExecutorPrincipal(t, h.s, h.tenant) + workspace := "/parsar-daemon-remote-" + uuid.NewString() + configuration, err := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + if err != nil { + t.Fatal(err) + } + session, err := h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-adapter", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := h.s.GetSessionEnvironment(ctx, h.tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if artifact != nil { + artifact.bind(t, environment.ID, workspace) + } + lease, err := h.s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + credential, err := h.s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: h.s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, h.tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer releaseHarness() + + observation := &relayObservation{} + handler := registry.Handler() + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: r.URL.Path}, r) + }) + server.Start() + defer func() { registry.Close(); server.Close() }() + memory, instruction := uuid.NewString(), "REMOTE_"+uuid.NewString() + local := prepareDaemonRemoteWorkspace(t, root, instruction) + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, server.URL, environment.ID, credential) + if artifact != nil { + artifact.container = container + artifact.installDirectoryHelper(t, ctx) + } + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor registration", func() bool { + connected, e := registry.Connected(ctx, h.tenant, environment.ID) + return e == nil && connected + }) + req := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "remote-" + session.ID, WorkDir: filepath.Join(root, "harness"), ReleaseOnCompletion: true, StrictResume: true, DisableSubagents: true, ObserveMessages: true, ObserveToolObservations: true, + AgentOptions: map[string]any{"model": "MiniMax-M3", "web_search": "disabled", "system_prompt": "Follow the user instructions and use the native shell for requested commands.", "codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, + RemoteEnvironment: &proto.RemoteEnvironment{ID: environment.ID, WorkspaceDirectory: workspace, ConnectionURL: server.URL, ConnectionToken: harnessToken}} + proof := map[string]any{"scope": "authenticated daemon adapter; public Environment admission and dispatcher remain pending", "native_version": string(version), "environment_id": environment.ID, "remote_workspace": workspace, "events": []proto.Envelope{}} + proof["prepared_execution"] = prepared + phase := "rejected" + if artifact != nil { + proof["private_harness_artifact"] = artifact.proof + prompt = func(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { + return daemonPreparedRemotePromptWithReady(t, ctx, peer, req, cancelWhen, func(handle string) bool { + artifact.handle, artifact.runID, artifact.peer = handle, "", peer + artifact.observeReady(t, ctx, phase, local) + return true + }, func(run string) { artifact.runID = run }) + } + } + defer persistDaemonRemoteProof(t, root, proof, []string{key, credential.Token, harnessToken, h.credential}) + bad := req + bad.AgentStateKey += "-rejected" + badBinding := *req.RemoteEnvironment + badBinding.ConnectionToken = "invalid-test-token" + bad.RemoteEnvironment = &badBinding + bad.Prompt = "This must fail before a model Turn." + _, rejected, _ := prompt(t, ctx, peer, bad, nil) + if !daemonRemoteHasError(rejected) && !(prepared && daemonRemotePreparationFailed(rejected)) { + t.Fatal("invalid transient authorization accepted") + } + proof["invalid_authorization_rejected"] = true + proof["invalid_authorization_events"] = rejected + for index, currentPhase := range []string{"first", "resumed"} { + phase = currentPhase + observation.mu.Lock() + before := observation.executors + observation.mu.Unlock() + req.Prompt = "Run the exact command `./placement.sh " + phase + "` once with the native shell. The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." + if index == 0 { + req.Prompt += " Remember this memory value: " + memory + "." + } else { + req.Prompt += " Recall the memory value from the first Turn and read retained.txt." + } + done, events, _ := prompt(t, ctx, peer, req, nil) + proof[phase] = map[string]any{"done": done, "events": events} + if daemonRemoteHasError(events) || !strings.Contains(done.Content, memory) || !strings.Contains(done.Content, instruction) || strings.Contains(done.Content, "WRONG_LOCAL_INSTRUCTIONS") { + t.Fatal("remote instructions or cold native memory not observed; inspect private proof") + } + native, ok := done.Metadata[proto.DoneMetaAgentSessionID].(string) + if !ok || native == "" || (index == 1 && native != req.AgentSessionID) { + t.Fatal("native continuation identity changed") + } + req.AgentSessionID = native + assertDaemonRemoteCommand(t, events, phase, workspace) + if data, e := os.ReadFile(filepath.Join(local, phase+".cwd")); e != nil || strings.TrimSpace(string(data)) != workspace { + t.Fatal("command used a different workspace") + } + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after harness release", func() bool { + observation.mu.Lock() + reconnected := observation.executors > before + observation.mu.Unlock() + connected, e := registry.Connected(ctx, h.tenant, environment.ID) + return reconnected && e == nil && connected + }) + if artifact != nil && index == 0 { + observation.mu.Lock() + beforeRead := observation.executors + observation.mu.Unlock() + artifact.observeReadPreparation(t, ctx, peer, req, root) + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after temporary read", func() bool { + observation.mu.Lock() + reconnected := observation.executors > beforeRead + observation.mu.Unlock() + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + return reconnected && err == nil && connected + }) + } + } + count, err := os.ReadFile(filepath.Join(local, "execution-count")) + if err != nil || string(count) != "first\nresumed\n" { + t.Fatal("remote command was omitted or repeated") + } + if data, e := os.ReadFile(filepath.Join(local, "retained.txt")); e != nil || string(data) != "remote-file-content\n" { + t.Fatal("remote file did not persist") + } + req.Prompt = "Run the exact command `./long.sh cancel` using the native shell. It deliberately runs until cancelled. Keep waiting or polling; do not finish this Turn or produce a final answer while it is running." + phase = "cancel" + observation.mu.Lock() + beforeCancel := observation.executors + observation.mu.Unlock() + cancelAt := time.Time{} + _, events, ack := prompt(t, ctx, peer, req, func() bool { + _, e := os.Stat(filepath.Join(local, "cancel.heartbeat")) + if e == nil && cancelAt.IsZero() { + if artifact != nil { + artifact.observeActive(t, ctx, local) + } + cancelAt = time.Now() + return true + } + return false + }) + proof["cancel_events"] = events + proof["cancel_receipt"] = ack + if cancelAt.IsZero() || ack == nil || !ack.Applied || ack.ErrorCode != "" { + t.Fatal("native command was not cancelled through the daemon") + } + awaitDaemonRemoteExit(t, ctx, container, local) + proof["cancel_to_observed_exit_seconds"] = time.Since(cancelAt).Seconds() + if artifact != nil { + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "artifact executor reconnect after cancellation", func() bool { + observation.mu.Lock() + reconnected := observation.executors > beforeCancel + observation.mu.Unlock() + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + return reconnected && err == nil && connected + }) + observation.mu.Lock() + beforeRelease := observation.executors + observation.mu.Unlock() + _, released, _ := daemonPreparedRemotePromptWithReady(t, ctx, peer, req, nil, func(handle string) bool { + artifact.handle, artifact.runID, artifact.peer = handle, "", peer + artifact.observeReady(t, ctx, "after_cancel", local) + return false + }, nil) + if len(released) == 0 || daemonRemotePreparationFailed(released) { + t.Fatal("post-cancel artifact preparation failed") + } + proof["after_cancel_preparation_events"] = released + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "artifact executor reconnect after unused release", func() bool { + observation.mu.Lock() + reconnected := observation.executors > beforeRelease + observation.mu.Unlock() + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + return reconnected && err == nil && connected + }) + artifact.assertReleased(t, ctx) + } + if peer.IsClosed() { + t.Fatal("daemon disconnected during cancellation acceptance") + } + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + if err != nil || !connected { + t.Fatal("registry/executor stopped during cancellation acceptance") + } + if _, err := os.Stat(workspace); !os.IsNotExist(err) { + t.Fatal("remote path was created on the harness host") + } + if _, err := os.Stat(filepath.Join(local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("command inherited credential variables") + } + assertDaemonRemoteSecrets(t, root, req.AgentStateKey, key, credential.Token, harnessToken, h.credential) + if strings.Contains(string(session.Configuration), harnessToken) { + t.Fatal("connection credential reached stored configuration") + } + releaseHarness() + revoked := req + revoked.AgentStateKey += "-revoked" + revoked.AgentSessionID = "" + revoked.Prompt = "This must fail before a model Turn." + _, revokedEvents, _ := prompt(t, ctx, peer, revoked, nil) + if !daemonRemoteHasError(revokedEvents) && !(prepared && daemonRemotePreparationFailed(revokedEvents)) { + t.Fatal("released harness credential still authorized native preparation") + } + proof["dynamic_harness_credential"] = true + proof["released_harness_rejected"] = true + proof["status"] = "daemon_adapter_verified_public_integration_pending" + proof["separate_executor_launcher"] = os.Getenv("PARSAR_EXECUTOR_LAUNCHER") != "" + proof["native_thread_id"] = req.AgentSessionID + proof["harness_token_only_from_typed_prompt"] = true + t.Log("real-provider daemon remote execution evidence", root) +} diff --git a/services/agents-api/internal/store/native_environment_test.go b/services/agents-api/internal/store/native_environment_test.go new file mode 100644 index 000000000..6158a54d8 --- /dev/null +++ b/services/agents-api/internal/store/native_environment_test.go @@ -0,0 +1,145 @@ +package store_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativeNoExecutionEnvironment(t *testing.T) { + h, ctx, home := nativeDispatchHarness(t) + var err error + config, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "gpt-5.5", "instructions": "Keep this instruction."}, "environment": map[string]string{"type": "none"}}) + h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-session", Configuration: config}) + if err != nil { + t.Fatal(err) + } + if err = h.s.BindSessionDevice(ctx, h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + + var requests atomic.Int32 + marker := filepath.Join(home, "must-not-exist") + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if body["model"] == "custom-provider-model" && !strings.Contains(fmt.Sprint(body["input"]), "DEFAULT-VERBOSITY") { + t.Error("unsupported verbosity reached model execution") + } + assertNativeSubagentsDisabled(t, body) + for _, value := range body["tools"].([]any) { + tool := value.(map[string]any) + if kind, _ := tool["type"].(string); strings.HasPrefix(kind, "web_search") { + t.Error("undeclared web search reached the model") + } + } + + encoded, _ := json.Marshal(body) + expected := "medium" + for _, level := range []string{"low", "high"} { + if strings.Contains(string(encoded), "TEXT-VERBOSITY:"+level) { + expected = level + } + } + textConfig, _ := body["text"].(map[string]any) + if body["model"] == "custom-provider-model" { + if _, present := textConfig["verbosity"]; present { + t.Error("native default sent an unsupported verbosity override") + } + } else if textConfig["verbosity"] != expected { + t.Errorf("effective verbosity = %v, want %s", textConfig["verbosity"], expected) + } + n := requests.Add(1) + raw, _ := json.MarshalIndent(body, "", " ") + _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("model-request-%d.json", n)), raw, 0600) + if strings.Contains(string(raw), "PUBLIC-CANCEL") { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"cancel_response\",\"status\":\"in_progress\",\"output\":[]}}\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + return + } + var item map[string]any + if n == 1 { + args, _ := json.Marshal(map[string]string{"cmd": "touch " + marker}) + item = map[string]any{"id": "fc_forbidden", "type": "function_call", "call_id": "call_forbidden", "name": "exec_command", "arguments": string(args), "status": "completed"} + } else { + item = map[string]any{"id": fmt.Sprintf("message_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "NO-ENVIRONMENT-OK", "annotations": []any{}}}} + } + w.Header().Set("Content-Type", "text/event-stream") + send := func(kind string, data map[string]any) { + data["type"] = kind + b, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, b) + w.(http.Flusher).Flush() + } + send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("response_%d", n), "status": "in_progress", "output": []any{}}}) + if item["type"] == "message" { + initial := map[string]any{"id": item["id"], "type": "message", "role": "assistant", "phase": "final_answer", "status": "in_progress", "content": []any{}} + send("response.output_item.added", map[string]any{"output_index": 0, "item": initial}) + send("response.content_part.added", map[string]any{"output_index": 0, "content_index": 0, "item_id": item["id"], "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}}}) + for _, fragment := range []string{"NO-", "ENVIRONMENT-", "OK"} { + send("response.output_text.delta", map[string]any{"output_index": 0, "content_index": 0, "item_id": item["id"], "delta": fragment}) + } + time.Sleep(time.Second) + } else { + send("response.output_item.added", map[string]any{"output_index": 0, "item": item}) + } + send("response.output_item.done", map[string]any{"output_index": 0, "item": item}) + send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("response_%d", n), "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": "gpt-5.5", "output": []any{item}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 3, "total_tokens": 13, "input_tokens_details": map[string]any{"cached_tokens": 4}, "output_tokens_details": map[string]any{"reasoning_tokens": 2}}}}) + })) + defer model.Close() + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"enable_features": []any{"multi_agent", "multi_agent_v2"}, "model_verbosity": "high", "web_search": "live", "codex_provider": map[string]any{"base_url": model.URL + "/v1", "bearer_token": "synthetic-test-token"}, "env": map[string]any{"CODEX_EXEC_SERVER_URL": "ws://127.0.0.1:1"}}, nil + } + first := h.message("first", "Return an answer.") + h.finished(h.run(ctx, first.TurnID), store.TurnCompleted) + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID == "" { + t.Fatal(bound, err) + } + second := h.message("second", "Continue the same conversation.") + h.finished(h.run(ctx, second.TurnID), store.TurnCompleted) + again, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || again.NativeSessionID != bound.NativeSessionID { + t.Fatal(again, err) + } + if requests.Load() != 3 { + t.Fatalf("expected rejected command and two answers; requests=%d; evidence %s", requests.Load(), home) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("forbidden command may have executed: %v", err) + } + page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + answers := 0 + for _, item := range page.Items { + if item.Role == "assistant" && item.Status == "completed" && len(item.Content) > 0 && item.Content[0].Text != nil && *item.Content[0].Text == "NO-ENVIRONMENT-OK" { + answers++ + } + } + if answers != 2 { + t.Fatal(page) + } + verifyNativePublicExecution(t, h, ctx, home) + t.Logf("Native environment none: command rejected, caller override ignored, two Turns resumed and recovered. Evidence: %s", home) +} diff --git a/services/agents-api/internal/store/native_executor_directory_test.go b/services/agents-api/internal/store/native_executor_directory_test.go new file mode 100644 index 000000000..718afd723 --- /dev/null +++ b/services/agents-api/internal/store/native_executor_directory_test.go @@ -0,0 +1,108 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeExecutorDirectoryHelper(t *testing.T) { + probe, helper, proof := os.Getenv("PARSAR_DIRECTORY_PROBE"), os.Getenv("PARSAR_DIRECTORY_HELPER"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + if probe == "" || helper == "" || proof == "" { + t.Skip("private directory qualification binaries required") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + root, err := os.MkdirTemp(proof, "native-directory-") + if err != nil { + t.Fatal(err) + } + local, workspace := filepath.Join(root, "workspace"), "/scoped-directory-"+uuid.NewString() + for _, name := range []string{"workspace/sub", "workspace/empty", "executor/codex", "harness"} { + if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(local, "retained.txt"), []byte("retained"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc", filepath.Join(local, "a")); err != nil { + t.Fatal(err) + } + for i := 0; i < 5000; i++ { + if err := os.WriteFile(filepath.Join(local, "sub", strconv.Itoa(i)), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + } + s, _ := store.NewTestStore(t) + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + configuration, _ := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "directory-primitive", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = registry.Handler() + server.Start() + defer func() { registry.Close(); server.Close() }() + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), server.URL, environment.ID, credential) + t.Cleanup(func() { _ = os.Remove(filepath.Join(root, "executor", "credential.json")) }) + if err := exec.CommandContext(ctx, "docker", "cp", helper, container+":/usr/local/bin/scoped-directory").Run(); err != nil { + t.Fatal("install qualification helper", err) + } + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor ready", func() bool { + connected, e := registry.Connected(ctx, tenant, environment.ID) + return e == nil && connected + }) + token, release, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer release() + command := exec.CommandContext(ctx, probe) + command.Dir = filepath.Join(root, "harness") + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "harness"), "PARSAR_NATIVE_ENV_PROOF=" + root, "PARSAR_DIRECTORY_WORKSPACE=" + workspace, "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + token, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + output, err := command.CombinedOutput() + if err != nil { + message := strings.ReplaceAll(string(output), token, "[redacted]") + message = strings.ReplaceAll(message, credential.Token, "[redacted]") + t.Fatalf("native directory probe: %v: %s", err, message) + } + data, err := os.ReadFile(filepath.Join(root, "directory-native.json")) + if err != nil { + t.Fatal(err) + } + if !json.Valid(data) { + t.Fatal("invalid evidence") + } + t.Log("native directory evidence", root) +} diff --git a/services/agents-api/internal/store/native_harness_artifact_test.go b/services/agents-api/internal/store/native_harness_artifact_test.go new file mode 100644 index 000000000..1dfb270ba --- /dev/null +++ b/services/agents-api/internal/store/native_harness_artifact_test.go @@ -0,0 +1,257 @@ +package store_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/google/uuid" +) + +func TestNativeDaemonHarnessArtifact(t *testing.T) { + artifact := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT") + if artifact == "" { + t.Skip("explicit final private harness artifact required") + } + helper := os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") + if !filepath.IsAbs(helper) { + t.Skip("explicit directory helper artifact required") + } + t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/usr/local/bin/agents-api-codex-directory") + testNativeDaemonRemoteEnvironmentWithArtifact(t, true, artifact) +} + +type nativeHarnessArtifact struct { + peer *gateway.Session + handle, runID string + root string + environment string + configuration map[string]string + proof map[string]any + owners map[int]bool + readyOwners []nativeHarnessOwner + container string +} + +type nativeHarnessOwner struct { + PID int `json:"pid"` + IPCRoot string `json:"ipc_root"` + ArtifactSHA256 string `json:"artifact_sha256"` +} + +func newNativeHarnessArtifact(t *testing.T, native, artifact string) *nativeHarnessArtifact { + t.Helper() + if !filepath.IsAbs(native) || !filepath.IsAbs(artifact) { + t.Fatal("artifact and native helper paths must be absolute") + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + state, err := filepath.EvalSymlinks(filepath.Join(home, ".parsar")) + if err != nil { + t.Fatal(err) + } + configuration := map[string]string{"native": native, "artifact": artifact, "root": state} + configuration["artifact_sha256"] = nativeHarnessFileHash(t, artifact) + proof := map[string]any{ + "artifact": artifact, "artifact_sha256": configuration["artifact_sha256"], + "native_helper": native, "native_helper_sha256": nativeHarnessFileHash(t, native), + "execution_caller": "existing daemon Codex adapter and Go JSONRPCClient; no launch wrapper", + "preflight": "stock helper discovery; opt-in artifact selected by the native adapter", + "metadata_observations": []map[string]any{}, + "read_observations": []map[string]any{}, + "read_error_observations": []map[string]any{}, + } + return &nativeHarnessArtifact{root: state, configuration: configuration, proof: proof, owners: make(map[int]bool)} +} + +func (a *nativeHarnessArtifact) bind(t *testing.T, environment, workspace string) { + t.Helper() + a.environment = environment + a.configuration["workspace"] = workspace +} + +func (a *nativeHarnessArtifact) current(t *testing.T) nativeHarnessOwner { + t.Helper() + artifact, err := os.Stat(a.configuration["artifact"]) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir("/proc") + if err != nil { + t.Fatal(err) + } + var owners []nativeHarnessOwner + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid <= 1 { + continue + } + process := filepath.Join("/proc", entry.Name()) + executable, err := os.Stat(filepath.Join(process, "exe")) + if err != nil || !os.SameFile(executable, artifact) { + continue + } + data, err := os.ReadFile(filepath.Join(process, "environ")) + if err != nil { + continue + } + var environment, workspace, root string + for _, value := range strings.Split(string(data), "\x00") { + switch { + case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_ENVIRONMENT="): + environment = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_ENVIRONMENT=") + case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_WORKSPACE="): + workspace = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_WORKSPACE=") + case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_IPC_ROOT="): + root = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_IPC_ROOT=") + } + } + if environment != a.environment { + continue + } + if workspace != a.configuration["workspace"] || filepath.Dir(filepath.Dir(root)) != a.root || !strings.HasPrefix(filepath.Base(filepath.Dir(root)), "ch-") { + t.Fatal("adapter private binding differs from the requested Environment") + } + digest := nativeHarnessFileHash(t, filepath.Join(process, "exe")) + if digest != a.configuration["artifact_sha256"] { + t.Fatal("executing artifact identity differs") + } + owners = append(owners, nativeHarnessOwner{PID: pid, IPCRoot: root, ArtifactSHA256: digest}) + } + if len(owners) != 1 { + t.Fatal("expected one actual adapter-owned artifact process", len(owners)) + } + return owners[0] +} + +func (a *nativeHarnessArtifact) observeReady(t *testing.T, ctx context.Context, phase, local string) { + t.Helper() + owner := a.current(t) + if a.owners[owner.PID] { + t.Fatal("fresh preparation reused an earlier harness process") + } + a.owners[owner.PID] = true + a.readyOwners = append(a.readyOwners, owner) + a.proof["distinct_ready_owners"] = len(a.owners) + a.metadata(t, ctx, owner, "ready_"+phase, "placement.sh", local) + if phase == "first" { + a.expectError(t, ctx, owner, a.environment, "retained.txt", "not_found") + } else { + a.metadata(t, ctx, owner, "ready_"+phase, "retained.txt", local) + } + a.expectError(t, ctx, owner, uuid.NewString(), "placement.sh", "wrong_environment") + a.expectError(t, ctx, owner, a.environment, "missing-"+uuid.NewString(), "not_found") + if phase == "first" { + seedNativeHarnessReadFiles(t, local) + } + a.observeReads(t, ctx, owner, "ready_"+phase, local, phase != "first") +} + +func (a *nativeHarnessArtifact) observeActive(t *testing.T, ctx context.Context, local string) { + t.Helper() + owner := a.current(t) + if !a.owners[owner.PID] { + t.Fatal("active metadata did not use the prepared owner") + } + a.metadata(t, ctx, owner, "active_cancel", "retained.txt", local) + a.metadata(t, ctx, owner, "active_cancel", "cancel.started", local) + a.observeReads(t, ctx, owner, "active_cancel", local, true) +} + +func (a *nativeHarnessArtifact) assertReleased(t *testing.T, ctx context.Context) { + t.Helper() + for _, owner := range a.readyOwners { + awaitDaemonRemoteCondition(t, ctx, 10*time.Second, "released metadata endpoint closure", func() bool { + conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "unix", filepath.Join(owner.IPCRoot, "files.sock")) + if err == nil { + _ = conn.Close() + return false + } + return errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) + }) + } + a.proof["released_metadata_endpoints_closed"] = true +} + +func (a *nativeHarnessArtifact) metadata(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, path, local string) { + t.Helper() + response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": path}, 16*1024) + var metadata struct { + Size int64 `json:"size"` + IsFile bool `json:"is_file"` + IsSymlink bool `json:"is_symlink"` + } + info, err := os.Stat(filepath.Join(local, path)) + if err != nil || json.Unmarshal(response.Metadata, &metadata) != nil || response.Error != "" || len(response.Read) != 0 || !metadata.IsFile || metadata.IsSymlink || metadata.Size != info.Size() { + t.Fatal("artifact metadata differs from independently observed remote file", phase, path) + } + observations := a.proof["metadata_observations"].([]map[string]any) + a.proof["metadata_observations"] = append(observations, map[string]any{"phase": phase, "path": path, "owner": owner, "metadata": response.Metadata}) +} + +func (a *nativeHarnessArtifact) expectError(t *testing.T, ctx context.Context, owner nativeHarnessOwner, environment, path, expected string) { + t.Helper() + response := a.request(t, ctx, owner, map[string]any{"environment_id": environment, "path": path}, 16*1024) + if response.Error != expected || len(response.Metadata) != 0 || len(response.Read) != 0 { + t.Fatal("private artifact metadata error differs", expected, response.Error) + } + a.proof[expected+"_verified"] = true +} + +type nativeHarnessMetadataResponse struct { + Metadata json.RawMessage `json:"metadata"` + Read json.RawMessage `json:"read"` + Directory json.RawMessage `json:"directory"` + Error string `json:"error"` +} + +func (a *nativeHarnessArtifact) request(t *testing.T, ctx context.Context, owner nativeHarnessOwner, request map[string]any, responseLimit int64) nativeHarnessMetadataResponse { + t.Helper() + requestCtx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(requestCtx, "unix", filepath.Join(owner.IPCRoot, "files.sock")) + if err != nil { + t.Fatal("private artifact file connection failed", err) + } + defer conn.Close() + deadline, _ := requestCtx.Deadline() + if err = conn.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + if err = json.NewEncoder(conn).Encode(request); err != nil { + t.Fatal(err) + } + var response nativeHarnessMetadataResponse + if err = json.NewDecoder(io.LimitReader(conn, responseLimit)).Decode(&response); err != nil { + t.Fatal("private artifact file response failed", err) + } + return response +} + +func nativeHarnessFileHash(t *testing.T, path string) string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + t.Fatal(err) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/services/agents-api/internal/store/native_harness_directory_test.go b/services/agents-api/internal/store/native_harness_directory_test.go new file mode 100644 index 000000000..2a3919e91 --- /dev/null +++ b/services/agents-api/internal/store/native_harness_directory_test.go @@ -0,0 +1,106 @@ +package store_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func (a *nativeHarnessArtifact) observeDirectories(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, local string, retained bool) { + t.Helper() + for _, name := range []string{"directory-fixture", "directory-empty"} { + if err := os.MkdirAll(filepath.Join(local, name), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(local, "directory-fixture", "child.bin"), []byte{0, 1, 255}, 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(local, "directory-outside") + outside := "/tmp/parsar-directory-isolation-" + a.environment + if err := exec.CommandContext(ctx, "docker", "exec", a.container, "mkdir", "-p", outside+"/child").Run(); err != nil { + t.Fatal("outside directory fixture was not created", err) + } + if err := exec.CommandContext(ctx, "docker", "exec", a.container, "test", "-d", outside+"/child").Run(); err != nil { + t.Fatal("outside directory fixture does not exist", err) + } + if err := os.Symlink(outside, link); err != nil && !os.IsExist(err) { + t.Fatal(err) + } + type directoryResult struct { + Entries []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Size *int64 `json:"size_bytes"` + } `json:"entries"` + Truncated bool `json:"truncated"` + } + for _, check := range []struct { + path string + limit int + required map[string]int64 + truncated bool + }{ + {"", 4096, map[string]int64{"bounded-read.bin": int64(len(nativeHarnessReadBinary())), "bounded-empty.bin": 0}, false}, + {"directory-fixture", 16, map[string]int64{"child.bin": 3}, false}, + {"directory-empty", 16, map[string]int64{}, false}, + {"", 1, nil, true}, + } { + response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": check.path, "operation": "list_directory", "max_entries": check.limit}, 8<<20) + var directory directoryResult + if response.Error != "" || len(response.Read) != 0 || len(response.Metadata) != 0 || json.Unmarshal(response.Directory, &directory) != nil || directory.Entries == nil || len(directory.Entries) > check.limit || directory.Truncated != check.truncated { + t.Fatal("native directory observation failed", phase, check.path, response.Error, string(response.Directory)) + } + files := make(map[string]int64) + for _, entry := range directory.Entries { + if filepath.Base(entry.Name) != entry.Name { + t.Fatal("directory entry escaped", entry.Name) + } + if entry.Kind == "file" && entry.Size != nil { + files[entry.Name] = *entry.Size + } + } + if check.path == "" && !check.truncated && retained { + check.required["retained.txt"] = int64(len("remote-file-content\n")) + } + for name, size := range check.required { + if got, ok := files[name]; !ok || got != size { + t.Fatal("directory metadata mismatch", phase, name, got, size) + } + } + if check.path == "directory-empty" && len(directory.Entries) != 0 { + t.Fatal("empty directory changed") + } + observations, _ := a.proof["directory_observations"].([]map[string]any) + a.proof["directory_observations"] = append(observations, map[string]any{"phase": phase, "path": check.path, "owner": owner, "result": response.Directory}) + } + for _, path := range []string{"../outside", "/etc", "directory-outside", "directory-outside/child"} { + response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": path, "operation": "list_directory", "max_entries": 16}, 16<<10) + if response.Error == "" || len(response.Directory) != 0 { + t.Fatal("directory isolation admitted outside view", path) + } + } + if a.current(t) != owner { + t.Fatal("directory operation replaced native execution owner") + } +} + +func (a *nativeHarnessArtifact) installDirectoryHelper(t *testing.T, ctx context.Context) { + t.Helper() + helper := os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") + if helper == "" { + return + } + installed := "/usr/local/bin/agents-api-codex-directory" + if err := exec.CommandContext(ctx, "docker", "cp", helper, a.container+":"+installed).Run(); err != nil { + t.Fatal("install directory helper", err) + } + if err := exec.CommandContext(ctx, "docker", "exec", a.container, "chmod", "0555", installed).Run(); err != nil { + t.Fatal("protect directory helper", err) + } + a.proof["directory_helper_sha256"] = nativeHarnessFileHash(t, helper) + a.proof["directory_helper_installed"] = installed +} diff --git a/services/agents-api/internal/store/native_harness_read_test.go b/services/agents-api/internal/store/native_harness_read_test.go new file mode 100644 index 000000000..822141c15 --- /dev/null +++ b/services/agents-api/internal/store/native_harness_read_test.go @@ -0,0 +1,133 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +const nativeHarnessReadMaximum = 8 * 1024 * 1024 + +func nativeHarnessReadBinary() []byte { + data := make([]byte, 2*1024*1024+37) + for index := range data { + data[index] = byte((index*31 + index/251) % 256) + } + return data +} + +func seedNativeHarnessReadFiles(t *testing.T, local string) { + t.Helper() + for path, data := range map[string][]byte{ + "bounded-read.bin": nativeHarnessReadBinary(), + "bounded-empty.bin": {}, + } { + if err := os.WriteFile(filepath.Join(local, path), data, 0600); err != nil { + t.Fatal(err) + } + } +} + +func (a *nativeHarnessArtifact) observeReads(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, local string, retained bool) { + t.Helper() + if len(a.readyOwners) == 0 || owner != a.readyOwners[len(a.readyOwners)-1] { + t.Fatal("native reads did not use the current prepared owner", phase) + } + binary := nativeHarnessReadBinary() + type readCase struct { + name string + path string + maxBytes int + data []byte + } + cases := []readCase{ + {"full", "bounded-read.bin", nativeHarnessReadMaximum, binary}, + {"exact_bound", "bounded-read.bin", len(binary), binary}, + {"truncated", "bounded-read.bin", 1024*1024 + 17, binary}, + {"minimum_bound", "bounded-read.bin", 1, binary}, + {"empty", "bounded-empty.bin", 1, []byte{}}, + } + if retained { + cases = append(cases, readCase{"native_retained", "retained.txt", 1024, []byte("remote-file-content\n")}) + } + for _, check := range cases { + localData, err := os.ReadFile(filepath.Join(local, check.path)) + if err != nil || !bytes.Equal(localData, check.data) { + t.Fatal("native read backing file differs from expected bytes", phase, check.name) + } + response := a.request(t, ctx, owner, map[string]any{ + "environment_id": a.environment, "path": check.path, "operation": "read", "max_bytes": check.maxBytes, + }, int64(base64.StdEncoding.EncodedLen(check.maxBytes)+1024)) + var read struct { + DataBase64 *string `json:"data_base64"` + Truncated *bool `json:"truncated"` + CloseAcknowledged bool `json:"close_acknowledged"` + } + if response.Error != "" || len(response.Metadata) != 0 || json.Unmarshal(response.Read, &read) != nil || read.DataBase64 == nil || read.Truncated == nil || !read.CloseAcknowledged { + t.Fatal("private native read omitted a valid result or acknowledged close", phase, check.name, response.Error) + } + actual, err := base64.StdEncoding.Strict().DecodeString(*read.DataBase64) + expected := check.data[:min(len(check.data), check.maxBytes)] + truncated := len(check.data) > check.maxBytes + if err != nil || !bytes.Equal(actual, expected) || *read.Truncated != truncated { + t.Fatal("private native read bytes or truncation differ", phase, check.name) + } + digest, sourceDigest := sha256.Sum256(actual), sha256.Sum256(check.data) + observations := a.proof["read_observations"].([]map[string]any) + a.proof["read_observations"] = append(observations, map[string]any{ + "phase": phase, "case": check.name, "path": check.path, "owner": owner, + "source_size": len(check.data), "source_sha256": hex.EncodeToString(sourceDigest[:]), + "max_bytes": check.maxBytes, "bytes_read": len(actual), "sha256": hex.EncodeToString(digest[:]), + "truncated": *read.Truncated, "close_acknowledged": read.CloseAcknowledged, + }) + } + a.observeReadErrors(t, ctx, owner, phase) + a.observeDaemonReads(t, ctx, owner, phase, retained) + a.observeDirectories(t, ctx, owner, phase, local, retained) + if a.current(t) != owner { + t.Fatal("native read phase replaced its prepared execution owner", phase) + } +} + +func (a *nativeHarnessArtifact) observeReadErrors(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string) { + t.Helper() + cases := []struct { + name string + environment string + path string + bound any + omitBound bool + expected string + }{ + {"wrong_identity", uuid.NewString(), "bounded-read.bin", 1, false, "wrong_environment"}, + {"missing_file", a.environment, "missing-" + uuid.NewString(), 1, false, "not_found"}, + {"zero_bound", a.environment, "bounded-read.bin", 0, false, "invalid_request"}, + {"negative_bound", a.environment, "bounded-read.bin", -1, false, "invalid_request"}, + {"oversized_bound", a.environment, "bounded-read.bin", nativeHarnessReadMaximum + 1, false, "invalid_request"}, + {"missing_bound", a.environment, "bounded-read.bin", nil, true, "invalid_request"}, + {"null_bound", a.environment, "bounded-read.bin", nil, false, "invalid_request"}, + {"fractional_bound", a.environment, "bounded-read.bin", 1.5, false, "invalid_request"}, + } + for _, check := range cases { + request := map[string]any{"environment_id": check.environment, "path": check.path, "operation": "read"} + if !check.omitBound { + request["max_bytes"] = check.bound + } + response := a.request(t, ctx, owner, request, 16*1024) + if response.Error != check.expected || len(response.Metadata) != 0 || len(response.Read) != 0 { + t.Fatal("private native read error differs", phase, check.name, response.Error) + } + observations := a.proof["read_error_observations"].([]map[string]any) + a.proof["read_error_observations"] = append(observations, map[string]any{ + "phase": phase, "case": check.name, "owner": owner, "error": response.Error, + }) + } +} diff --git a/services/agents-api/internal/store/native_harness_write_test.go b/services/agents-api/internal/store/native_harness_write_test.go new file mode 100644 index 000000000..448b11def --- /dev/null +++ b/services/agents-api/internal/store/native_harness_write_test.go @@ -0,0 +1,123 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeHarnessFileWrite(t *testing.T) { + artifact, helper, proof := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT"), os.Getenv("PARSAR_WRITE_HELPER_ARTIFACT"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + if artifact == "" || helper == "" || proof == "" { + t.Skip("private harness and file installer artifacts required") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + root, err := os.MkdirTemp(proof, "native-write-") + if err != nil { + t.Fatal(err) + } + local, remote := filepath.Join(root, "environment"), "/write-environment-"+uuid.NewString() + workspace := remote + "/workspace" + for _, name := range []string{"environment/workspace", "environment/staging", "executor/codex", "harness"} { + if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + s, _ := store.NewTestStore(t) + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + configuration, _ := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "private-write", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = registry.Handler() + server.Start() + defer func() { registry.Close(); server.Close() }() + container := startDaemonRemoteExecutor(t, ctx, root, local, remote, os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), server.URL, environment.ID, credential) + t.Cleanup(func() { _ = os.Remove(filepath.Join(root, "executor", "credential.json")) }) + if err := exec.CommandContext(ctx, "docker", "cp", helper, container+":/usr/local/bin/agents-api-codex-write").Run(); err != nil { + t.Fatal("install qualification helper", err) + } + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor ready", func() bool { + connected, e := registry.Connected(ctx, tenant, environment.ID) + return e == nil && connected + }) + token, release, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer release() + probe, err := filepath.Abs("../../tests/native/write/probe.py") + if err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + ipc, err := os.MkdirTemp(filepath.Join(home, ".parsar"), "write-probe-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(ipc) + command := exec.CommandContext(ctx, "python3", probe) + command.Dir = filepath.Join(root, "harness") + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, + "CODEX_HOME=" + filepath.Join(root, "harness"), + "PARSAR_NATIVE_ENV_PROOF=" + root, "PARSAR_WRITE_LOCAL=" + local, + "PARSAR_CODEX_HARNESS_ARTIFACT=" + artifact, + "PARSAR_CODEX_HARNESS_NATIVE=" + os.Getenv("PARSAR_CODEX_BINARY"), + "PARSAR_CODEX_HARNESS_ENVIRONMENT=" + environment.ID, + "PARSAR_CODEX_HARNESS_WORKSPACE=" + workspace, + "PARSAR_CODEX_HARNESS_STAGING=" + remote + "/staging", + "PARSAR_CODEX_HARNESS_WRITE_HELPER=/usr/local/bin/agents-api-codex-write", + "PARSAR_CODEX_HARNESS_IPC_ROOT=" + filepath.Join(ipc, "native"), + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + token, + "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + output, err := command.CombinedOutput() + if err != nil { + message := strings.ReplaceAll(string(output), token, "[redacted]") + message = strings.ReplaceAll(message, credential.Token, "[redacted]") + t.Fatalf("native write probe: %v: %s", err, message) + } + data, err := os.ReadFile(filepath.Join(root, "write-native.json")) + if err != nil { + t.Fatal(err) + } + if !json.Valid(data) { + t.Fatal("invalid evidence") + } + t.Log("native write evidence", root) +} diff --git a/services/agents-api/internal/store/native_placement_test.go b/services/agents-api/internal/store/native_placement_test.go new file mode 100644 index 000000000..2b4221c43 --- /dev/null +++ b/services/agents-api/internal/store/native_placement_test.go @@ -0,0 +1,116 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeAppServerRemoteModelPlacement(t *testing.T) { + binary, proof := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + image, key := os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") + if binary == "" || proof == "" || image == "" || key == "" { + t.Skip("pinned native Codex executable, private evidence directory, executor image and real model key file required") + } + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + defer cancel() + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + workspace := "/parsar-remote-" + uuid.NewString() + configuration, err := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + if err != nil { + t.Fatal(err) + } + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-placement", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + executorToken := credential.Token + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer releaseHarness() + + server.Config.Handler = registry.Handler() + server.Start() + defer func() { registry.Close(); server.Close() }() + runtime, err := os.MkdirTemp(proof, "native-placement-") + if err != nil { + t.Fatal(err) + } + script, err := filepath.Abs("../../tests/native/environment_model_probe.py") + if err != nil { + t.Fatal(err) + } + env := []string{"PATH=" + os.Getenv("PATH"), "PARSAR_CODEX_BINARY=" + binary, "PARSAR_PLACEMENT_ROOT=" + runtime, + "PARSAR_PLACEMENT_EXECUTOR_IMAGE=" + image, "PARSAR_PLACEMENT_MODEL_KEY_FILE=" + key, + "PARSAR_PLACEMENT_EXECUTOR_TOKEN=" + executorToken, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, + "NO_PROXY=127.0.0.1,localhost"} + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { + if value := os.Getenv(name); value != "" { + env = append(env, name+"="+value) + } + } + container := "parsar-placement-" + uuid.NewString() + env = append(env, "PARSAR_PLACEMENT_WORKSPACE="+workspace, "PARSAR_PLACEMENT_CONTAINER="+container) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = exec.CommandContext(cleanup, "docker", "rm", "-f", container).Run() + }) + process := startRelayProcess(t, ctx, runtime, env, "python3", script) + select { + case <-process.done: + if process.err != nil { + t.Fatal("real-model native placement failed; inspect private evidence", runtime, process.err) + } + case <-ctx.Done(): + t.Fatal("real-model native placement timed out", runtime) + } + data, err := os.ReadFile(filepath.Join(runtime, "proof.json")) + if err != nil { + t.Fatal(err) + } + var result struct { + Report struct { + Status string `json:"status"` + } `json:"report"` + } + if err = json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if result.Report.Status != "characterized_with_blockers" { + t.Fatal("native placement was not characterized", runtime) + } + t.Log("real-provider native app-server remote placement evidence", runtime) +} diff --git a/services/agents-api/internal/store/native_preparation_helpers_test.go b/services/agents-api/internal/store/native_preparation_helpers_test.go new file mode 100644 index 000000000..04b266fd5 --- /dev/null +++ b/services/agents-api/internal/store/native_preparation_helpers_test.go @@ -0,0 +1,106 @@ +package store_test + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func daemonPreparedRemotePrompt(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { + return daemonPreparedRemotePromptWithReady(t, ctx, peer, req, cancelWhen, nil, nil) +} + +func daemonPreparedRemotePromptWithReady(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool, onReady func(string) bool, onStarted func(string)) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { + t.Helper() + request := uuid.NewString() + sub, err := peer.SubscribePreparation(request) + if err != nil { + t.Fatal(err) + } + defer peer.UnsubscribePreparation(request) + configuration := req + configuration.RunID, configuration.Prompt = "", "" + env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, request, proto.ExecutionPreparePayload{Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + if err = peer.Send(ctx, env); err != nil { + t.Fatal(err) + } + var observations []proto.Envelope + await := func(state string) proto.PreparationStatusPayload { + t.Helper() + for { + select { + case <-ctx.Done(): + t.Fatal("real daemon preparation timed out") + case event, ok := <-sub.Events: + if !ok { + t.Fatal("preparation closed before requested state", state, sub.Err()) + } + observations = append(observations, event) + var status proto.PreparationStatusPayload + if event.DecodePayload(&status) != nil { + t.Fatal("invalid preparation status") + } + if status.State == state || status.State == "failed" || status.State == "rejected" || status.State == "expired" { + return status + } + } + } + } + ready := await("ready") + if ready.State != "ready" { + return proto.DonePayload{}, observations, nil + } + if ready.Handle == "" || ready.Revision < 2 || ready.RunID != "" { + t.Fatal("invalid pre-Turn ready identity") + } + if onReady != nil && !onReady(ready.Handle) { + env, err := proto.NewEnvelope(proto.TypeExecutionRelease, request, proto.ExecutionReleasePayload{Handle: ready.Handle}) + if err != nil { + t.Fatal(err) + } + if err = peer.Send(ctx, env); err != nil { + t.Fatal(err) + } + released := await("released") + if released.State != "released" || released.Handle != ready.Handle || released.Revision <= ready.Revision { + t.Fatal("unused native preparation release failed") + } + return proto.DonePayload{}, observations, nil + } + start := func(run string) error { + env, err := proto.NewEnvelope(proto.TypeExecutionStart, request, proto.ExecutionStartPayload{Handle: ready.Handle, RunID: run, Prompt: req.Prompt}) + if err != nil { + return err + } + if err = peer.Send(ctx, env); err != nil { + return err + } + started := await("started") + if started.State != "started" || started.Handle != ready.Handle || started.RunID != run || started.Revision <= ready.Revision { + return errors.New("prepared native transfer failed") + } + if onStarted != nil { + onStarted(run) + } + return nil + } + done, events, ack := daemonRemotePromptWithStart(t, ctx, peer, req, cancelWhen, start) + return done, append(observations, events...), ack +} + +func daemonRemotePreparationFailed(events []proto.Envelope) bool { + for _, event := range events { + var status proto.PreparationStatusPayload + if event.Type == proto.TypePreparationStatus && event.DecodePayload(&status) == nil && status.State == "failed" { + return true + } + } + return false +} diff --git a/services/agents-api/internal/store/native_public_execution_test.go b/services/agents-api/internal/store/native_public_execution_test.go new file mode 100644 index 000000000..90309c654 --- /dev/null +++ b/services/agents-api/internal/store/native_public_execution_test.go @@ -0,0 +1,58 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/google/uuid" +) + +func verifyNativePublicExecution(t *testing.T, h *dispatchHarness, parent context.Context, evidence string) { + t.Helper() + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Log("Public SDK proof requires PARSAR_OFFICIAL_SDK_PYTHON") + return + } + ctx, cancel := context.WithCancel(parent) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }() + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + command := exec.CommandContext(ctx, python, "../../tests/official_execution.py", server.URL, token, foreign, filepath.Join(evidence, "public-execution.json")) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("official SDK native execution: %v\n%s", err, output) + } + t.Logf("Official SDK public execution, retry identity, isolation, result recovery and native cancellation passed: %s", evidence) +} diff --git a/services/agents-api/internal/store/native_raw_files_cancel_test.go b/services/agents-api/internal/store/native_raw_files_cancel_test.go new file mode 100644 index 000000000..c6c962262 --- /dev/null +++ b/services/agents-api/internal/store/native_raw_files_cancel_test.go @@ -0,0 +1,172 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +type rawFilesCancellationProof struct { + Interrupt struct { + RequestID int `json:"request_id"` + Response json.RawMessage `json:"response"` + } `json:"interrupt"` + TurnCompleted json.RawMessage `json:"turn_completed"` + CommandStarted json.RawMessage `json:"command_started"` + CommandCompleted json.RawMessage `json:"command_completed"` + BackgroundBefore json.RawMessage `json:"background_before"` + Termination *struct { + RequestID int `json:"request_id"` + ProcessID string `json:"process_id"` + Response json.RawMessage `json:"response"` + } `json:"termination"` + BackgroundAfter json.RawMessage `json:"background_after"` + FilesAfter struct { + BinaryVerified bool `json:"binary_verified"` + MarkerVerified bool `json:"marker_verified"` + } `json:"files_after"` +} + +type rawFilesRecoveryProof struct { + NativeTurns json.RawMessage `json:"native_turns"` + FilesVerified bool `json:"files_verified"` +} + +func TestNativeRawEnvironmentFilesCancellation(t *testing.T) { + testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ + probeVariable: "PARSAR_RAW_FILES_PROBE", status: "raw_native_files_characterized", + transport: "raw_unix_socket", withCancellation: true, + limitations: "Private first/cancel/fresh Files composition only. Native typed observations may omit or delay interrupted command results. Observed PID exit and stable heartbeat do not establish all-descendant OS quiescence. Public Files, ownership/admission and unbounded native-client backpressure remain open.", + }) +} + +func assertRawFilesCancelActive(t *testing.T, ctx context.Context, container, local string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(local, "cancel.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 1 { + t.Fatal("invalid active command PID") + } + command := exec.CommandContext(ctx, "docker", "exec", container, "sh", "-c", `kill -0 "$1"`, "--", strconv.Itoa(pid)) + if err := command.Run(); err != nil { + t.Fatal("owned remote command is not active", err) + } + before, err := os.ReadFile(filepath.Join(local, "cancel.heartbeat")) + if err != nil { + t.Fatal(err) + } + awaitDaemonRemoteCondition(t, ctx, 5*time.Second, "active cancel heartbeat", func() bool { + after, err := os.ReadFile(filepath.Join(local, "cancel.heartbeat")) + return err == nil && len(after) > 0 && !bytes.Equal(before, after) + }) +} + +func assertRawFilesCancellation(t *testing.T, proof sharedFilesProbeProof, workspace, local string) { + t.Helper() + cancel := proof.Cancellation + if cancel == nil || proof.TurnStartedCount != 1 || proof.TurnCompletedCount != 1 || proof.CommandStartedCount != 1 || proof.CommandCompletedCount < 0 || proof.CommandCompletedCount > 1 { + t.Fatal("cancel proof lacks actual native lifecycle observations") + } + var response map[string]json.RawMessage + if json.Unmarshal(cancel.Interrupt.Response, &response) != nil || response == nil || len(response) != 0 || cancel.Interrupt.RequestID != 3 { + t.Fatal("native interrupt acknowledgement is missing") + } + var ended struct { + ThreadID string `json:"threadId"` + Turn struct{ ID, Status string } `json:"turn"` + } + if json.Unmarshal(cancel.TurnCompleted, &ended) != nil || ended.ThreadID != proof.NativeThreadID || ended.Turn.ID != proof.NativeTurnID || ended.Turn.Status != "interrupted" { + t.Fatal("native cancellation was not independently observed") + } + var started, completed struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item struct { + Type, ID, Command, Cwd string + ProcessID string `json:"processId"` + } `json:"item"` + } + if json.Unmarshal(cancel.CommandStarted, &started) != nil || started.ThreadID != proof.NativeThreadID || started.TurnID != proof.NativeTurnID || started.Item.Type != "commandExecution" || started.Item.ID == "" || started.Item.ProcessID == "" || started.Item.Cwd != workspace || !strings.Contains(started.Item.Command, "./shared-gate.sh cancel") { + t.Fatal("cancel target lacks its native command identity") + } + if proof.CommandCompletedCount == 0 { + if string(cancel.CommandCompleted) != "null" { + t.Fatal("missing interrupted command result was invented") + } + } else if json.Unmarshal(cancel.CommandCompleted, &completed) != nil || completed.ThreadID != started.ThreadID || completed.TurnID != started.TurnID || completed.Item.ID != started.Item.ID || (completed.Item.ProcessID != "" && completed.Item.ProcessID != started.Item.ProcessID) { + t.Fatal("interrupted command result changed native identity") + } + var before, after struct { + Data []struct { + ItemID, ProcessID, Command, Cwd string + } `json:"data"` + NextCursor *string `json:"nextCursor"` + } + if json.Unmarshal(cancel.BackgroundBefore, &before) != nil || json.Unmarshal(cancel.BackgroundAfter, &after) != nil || before.NextCursor != nil || after.NextCursor != nil || len(before.Data) > 1 || len(after.Data) != 0 { + t.Fatal("bounded cancellation terminal inventory is incomplete") + } + if len(before.Data) == 0 { + if cancel.Termination != nil { + t.Fatal("absent native command was terminated again") + } + } else { + target := before.Data[0] + var receipt struct{ Terminated bool } + // Native hook text and rendered argv may differ; the Rust fixture checks + // both with the existing shell parser. Preserve both original bodies here. + if target.ItemID != started.Item.ID || target.ProcessID != started.Item.ProcessID || target.Command == "" || target.Cwd != workspace || cancel.Termination == nil || cancel.Termination.RequestID != 5 || cancel.Termination.ProcessID != target.ProcessID || json.Unmarshal(cancel.Termination.Response, &receipt) != nil || !receipt.Terminated { + t.Fatal("native termination lacks exact ownership and acknowledgement") + } + } + files := proof.Files + if !cancel.FilesAfter.BinaryVerified || !cancel.FilesAfter.MarkerVerified || !files.ActiveBinaryVerified || files.ActiveHeartbeat == "" || files.BinaryBytes != 128*1024 || files.MetadataSize != 128*1024 || files.BinarySHA256 != sharedFilesHash(t, filepath.Join(local, "shared-binary.bin")) { + t.Fatal("typed Files were not retained across native cancellation") + } + if !slices.Contains(files.ActiveDirectoryNames, "shared-binary.bin") || !slices.Contains(files.DirectoryNames, "cancel-marker.txt") || !slices.Contains(files.DirectoryNames, "shared-binary.bin") { + t.Fatal("typed directory observations omitted retained files") + } + marker, err := os.ReadFile(filepath.Join(local, "cancel-marker.txt")) + if err != nil || string(marker) != proof.Marker+"\n" { + t.Fatal("post-cancel typed file differs on the executor", err) + } + for _, name := range []string{"cancel.release", "cancel-artifact.txt"} { + if _, err := os.Stat(filepath.Join(local, name)); !os.IsNotExist(err) { + t.Fatal("cancelled gate was released or produced a completed artifact", name) + } + } +} + +func assertRawFilesRecovery(t *testing.T, proof, cancelled sharedFilesProbeProof, local string) { + t.Helper() + if proof.CancellationRecovery == nil || !proof.CancellationRecovery.FilesVerified { + t.Fatal("cold native cancellation recovery was not verified") + } + var page struct { + Data []struct{ ID, Status string } `json:"data"` + NextCursor *string `json:"nextCursor"` + } + if json.Unmarshal(proof.CancellationRecovery.NativeTurns, &page) != nil || page.NextCursor != nil || len(page.Data) != 2 { + t.Fatal("cold native history omitted a prior Turn") + } + found := 0 + for _, turn := range page.Data { + if turn.ID == cancelled.NativeTurnID && turn.Status == "interrupted" { + found++ + } + } + marker, err := os.ReadFile(filepath.Join(local, "cancel-marker.txt")) + if found != 1 || err != nil || string(marker) != cancelled.Marker+"\n" { + t.Fatal("cold native history or post-cancel file changed") + } +} diff --git a/services/agents-api/internal/store/native_recovery_test.go b/services/agents-api/internal/store/native_recovery_test.go new file mode 100644 index 000000000..611c0cd05 --- /dev/null +++ b/services/agents-api/internal/store/native_recovery_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "errors" + "testing" +) + +func TestSessionExecutionBindingRetainsStartedExecutionRequirement(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + foreign, _ := newTurnSession(t, s) + device, _ := registerTestDevice(t, s, tenant) + if err := s.BindSessionDevice(t.Context(), tenant, session.ID, device.ID); err != nil { + t.Fatal(err) + } + assertStarted := func(st *Store, want bool) { + t.Helper() + bound, err := st.GetSessionExecutionBinding(t.Context(), tenant, session.ID) + if err != nil || bound.HasStartedTurn != want || bound.NativeSessionID != "" { + t.Fatalf("binding=%+v err=%v", bound, err) + } + if _, err := st.GetSessionExecutionBinding(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign binding", err) + } + } + assertStarted(s, false) + first := submitMessage(t, s, tenant, session.ID, "first") + assertStarted(s, false) + transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + assertStarted(s, true) + transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnFailed) + pool.Close() + restarted, _ := testStore(t) + assertStarted(restarted, true) + submitMessage(t, restarted, tenant, session.ID, "next") + assertStarted(restarted, true) +} diff --git a/services/agents-api/internal/store/native_relay_test.go b/services/agents-api/internal/store/native_relay_test.go new file mode 100644 index 000000000..a353686d7 --- /dev/null +++ b/services/agents-api/internal/store/native_relay_test.go @@ -0,0 +1,248 @@ +package store_test + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type relayObservation struct { + mu sync.Mutex + harness net.Conn + harnesses int + executors int + connects int + validations int +} + +type relayResponse struct { + http.ResponseWriter + observation *relayObservation + path string +} + +func (w *relayResponse) WriteHeader(status int) { + if status == http.StatusOK { + w.observation.mu.Lock() + if strings.HasSuffix(w.path, "/connect") { + w.observation.connects++ + } + if strings.HasSuffix(w.path, "/validate") { + w.observation.validations++ + } + w.observation.mu.Unlock() + } + w.ResponseWriter.WriteHeader(status) +} +func (w *relayResponse) Hijack() (net.Conn, *bufio.ReadWriter, error) { + conn, buffer, err := w.ResponseWriter.(http.Hijacker).Hijack() + if err == nil { + w.observation.mu.Lock() + if strings.Contains(w.path, "/harness/") { + w.observation.harness = conn + w.observation.harnesses++ + } else { + w.observation.executors++ + } + w.observation.mu.Unlock() + } + return conn, buffer, err +} + +type relayProcess struct { + command *exec.Cmd + done chan struct{} + err error +} + +func startRelayProcess(t *testing.T, ctx context.Context, directory string, env []string, binary string, args ...string) *relayProcess { + t.Helper() + command := exec.CommandContext(ctx, binary, args...) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.WaitDelay = 5 * time.Second + command.Dir, command.Env = directory, env + command.Stdout, command.Stderr = io.Discard, io.Discard + if err := command.Start(); err != nil { + t.Fatal(err) + } + process := &relayProcess{command: command, done: make(chan struct{})} + go func() { process.err = command.Wait(); close(process.done) }() + t.Cleanup(func() { + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + select { + case <-process.done: + case <-time.After(5 * time.Second): + t.Error("owned native process did not exit") + } + }) + return process +} +func (p *relayProcess) wait(t *testing.T) { + t.Helper() + select { + case <-p.done: + if p.err != nil { + t.Fatal("native relay probe failed", p.err) + } + case <-time.After(100 * time.Second): + t.Fatal("native relay probe timed out") + } +} + +func TestNativeHarnessRelayPostgreSQLAndProcessRecovery(t *testing.T) { + probe, binary, proof := os.Getenv("PARSAR_NATIVE_RELAY_PROBE"), os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + if probe == "" || binary == "" || proof == "" { + t.Skip("pinned native relay probe, Codex binary and private evidence directory required") + } + s, _ := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 150*time.Second) + defer cancel() + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-relay", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + executorToken := credential.Token + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer releaseHarness() + + observation := &relayObservation{} + handler := registry.Handler() + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: req.URL.Path}, req) + }) + server.Start() + defer func() { registry.Close(); server.Close() }() + version, err := exec.CommandContext(ctx, binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("pinned Codex0.153.4 required") + } + runtime, err := os.MkdirTemp(proof, "native-relay-") + if err != nil { + t.Fatal(err) + } + for _, sub := range []string{"codex", "workspace"} { + if err := os.Mkdir(filepath.Join(runtime, sub), 0700); err != nil { + t.Fatal(err) + } + } + executorEnv := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "CODEX_HOME=" + filepath.Join(runtime, "codex"), "CODEX_API_KEY=" + executorToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + startRelayProcess(t, ctx, runtime, executorEnv, binary, "exec-server", "--remote", server.URL, "--environment-id", environment.ID) + until := time.Now().Add(20 * time.Second) + for { + connected, err := registry.Connected(ctx, tenant, environment.ID) + if err == nil && connected { + break + } + if time.Now().After(until) { + t.Fatal("native executor not connected") + } + time.Sleep(20 * time.Millisecond) + } + harnessEnv := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "PARSAR_NATIVE_ENV_PROOF=" + runtime, "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + first := startRelayProcess(t, ctx, runtime, harnessEnv, probe, "first") + until = time.Now().Add(50 * time.Second) + for { + if _, err := os.Stat(filepath.Join(runtime, "ready-to-disconnect")); err == nil { + break + } + select { + case <-first.done: + t.Fatal("native probe ended before recovery checkpoint", first.err) + default: + } + if time.Now().After(until) { + t.Fatal("native probe did not reach recovery checkpoint") + } + time.Sleep(20 * time.Millisecond) + } + observation.mu.Lock() + if observation.harnesses != 1 || observation.executors != 1 || observation.connects != 2 || observation.validations != 1 || observation.harness == nil { + observation.mu.Unlock() + t.Fatal("principal concurrency or same-key refresh replaced the active native pair") + } + connection := observation.harness + observation.mu.Unlock() + if err := connection.Close(); err != nil { + t.Fatal(err) + } + first.wait(t) + observation.mu.Lock() + if observation.harnesses != 2 || observation.executors != 2 || observation.validations != 2 { + observation.mu.Unlock() + t.Fatal("both native peers did not reconnect exactly once during controlled recovery") + } + observation.mu.Unlock() + // Wait for the executor's reconnect after the first harness process releases its pair. + until = time.Now().Add(20 * time.Second) + for { + observation.mu.Lock() + executors := observation.executors + observation.mu.Unlock() + if executors >= 3 { + break + } + if time.Now().After(until) { + t.Fatal("executor not ready for fresh harness") + } + time.Sleep(20 * time.Millisecond) + } + fresh := startRelayProcess(t, ctx, runtime, harnessEnv, probe, "fresh") + fresh.wait(t) + firstProof, err := os.ReadFile(filepath.Join(runtime, "first.json")) + if err != nil { + t.Fatal(err) + } + freshProof, err := os.ReadFile(filepath.Join(runtime, "fresh.json")) + if err != nil { + t.Fatal(err) + } + report := map[string]any{"native_executor": "codex 0.153.4", "native_source": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", "real_postgresql": true, "first": json.RawMessage(firstProof), "fresh": json.RawMessage(freshProof), "model_calls": 0, "limitations": []string{"Internal registry/relay workflow, not public Environment admission or model execution", "One independent harness connection per Environment", "One acknowledged-start process survived one bounded transport outage; arbitrary replay and durable crash restoration are not established"}} + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtime, "proof.json"), data, 0600); err != nil { + t.Fatal(err) + } + t.Log("native commands, shared concurrency, 128KiB file, refresh, paired reconnect, one retained process and fresh file retention verified") +} diff --git a/services/agents-api/internal/store/native_shared_files_test.go b/services/agents-api/internal/store/native_shared_files_test.go new file mode 100644 index 000000000..fe04062db --- /dev/null +++ b/services/agents-api/internal/store/native_shared_files_test.go @@ -0,0 +1,477 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type sharedFilesProbeProof struct { + Status string `json:"status"` + Transport string `json:"transport"` + Phase string `json:"phase"` + NativeThreadID string `json:"native_thread_id"` + NativeTurnID string `json:"native_turn_id"` + Marker string `json:"marker"` + HistoryValue string `json:"history_value"` + Answer string `json:"answer"` + TurnStartedCount int `json:"turn_started_count"` + TurnCompletedCount int `json:"turn_completed_count"` + CommandStartedCount int `json:"command_started_count"` + CommandCompletedCount int `json:"command_completed_count"` + ShutdownCompleted bool `json:"shutdown_completed"` + Cancellation *rawFilesCancellationProof `json:"cancellation,omitempty"` + CancellationRecovery *rawFilesRecoveryProof `json:"cancellation_recovery,omitempty"` + Command struct { + ID string `json:"id"` + Command string `json:"command"` + Cwd string `json:"cwd"` + AggregatedOutput string `json:"aggregated_output"` + ExitCode int `json:"exit_code"` + Started bool `json:"started"` + Completed bool `json:"completed"` + } `json:"command"` + Files struct { + BinaryBytes int `json:"binary_bytes"` + BinarySHA256 string `json:"binary_sha256"` + MetadataSize int `json:"metadata_size"` + DirectoryNames []string `json:"directory_names"` + ActiveHeartbeat string `json:"active_heartbeat"` + ActiveBinaryVerified bool `json:"active_binary_verified"` + ActiveDirectoryNames []string `json:"active_directory_names"` + Artifact string `json:"artifact"` + } `json:"files"` +} + +type sharedFilesProfile struct { + probeVariable string + status string + transport string + limitations string + withCancellation bool +} + +func TestNativeSharedEnvironmentFiles(t *testing.T) { + testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ + probeVariable: "PARSAR_SHARED_FILES_PROBE", status: "shared_native_files_characterized_with_blockers", + transport: "in_process", + limitations: "The upstream event queue may drop nonrequired events without Lagged. Principal counters/answers/effects characterize this bounded workload, not a lossless production transport, public protocol compatibility, workspace confinement or OS quiescence.", + }) +} + +func TestNativeRawEnvironmentFiles(t *testing.T) { + testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ + probeVariable: "PARSAR_RAW_FILES_PROBE", status: "raw_native_files_characterized", + transport: "raw_unix_socket", + limitations: "The native remote client has an internal unbounded event queue. This finite workflow does not qualify production backpressure, complete output, public Files, idle ownership, authorization/fencing or cancellation.", + }) +} + +func testNativeSharedEnvironmentFiles(t *testing.T, profile sharedFilesProfile) { + probe, binary := os.Getenv(profile.probeVariable), os.Getenv("PARSAR_CODEX_BINARY") + proofDirectory, image := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + keyFile, launcher := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE"), os.Getenv("PARSAR_EXECUTOR_LAUNCHER") + if probe == "" || binary == "" || proofDirectory == "" || image == "" || keyFile == "" || launcher == "" { + t.Skip("built shared-files probe, pinned native installation/launcher/image, private proof and real provider key required") + } + if !strings.HasPrefix(image, "sha256:") { + t.Fatal("pin the preloaded executor image") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + t.Cleanup(cancel) + version, err := exec.CommandContext(ctx, binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native Codex 0.153.4 required") + } + keyBytes, err := os.ReadFile(keyFile) + if err != nil || strings.TrimSpace(string(keyBytes)) == "" { + t.Fatal("real provider credential unavailable") + } + key := strings.TrimSpace(string(keyBytes)) + callerHome, err := os.UserHomeDir() + if err != nil || !filepath.IsAbs(callerHome) || !filepath.IsAbs(proofDirectory) { + t.Fatal("absolute caller HOME and proof directory required") + } + stateRoot, err := filepath.EvalSymlinks(filepath.Join(callerHome, ".parsar")) + if err != nil { + t.Fatal("resolve caller state directory", err) + } + proofDirectory, err = filepath.EvalSymlinks(proofDirectory) + if err != nil { + t.Fatal("resolve proof directory", err) + } + relative, err := filepath.Rel(stateRoot, proofDirectory) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + t.Fatal("proof directory must resolve under caller ~/.parsar") + } + root, err := os.MkdirTemp(proofDirectory, "shared-files-") + if err != nil { + t.Fatal(err) + } + t.Log("shared native filesystem evidence", root) + instruction := "REMOTE_" + uuid.NewString() + local := prepareDaemonRemoteWorkspace(t, root, instruction) + workspace := "/parsar-shared-files-" + uuid.NewString() + if err := os.WriteFile(filepath.Join(local, "shared-gate.sh"), []byte(sharedFilesGate), 0700); err != nil { + t.Fatal(err) + } + + s, _ := store.NewTestStore(t) + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + if err := lease.Close(cleanup); err != nil { + t.Error("execution lease cleanup failed", err) + } + }) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + configuration, err := json.Marshal(map[string]any{"environment": map[string]any{ + "type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}, + }}) + if err != nil { + t.Fatal(err) + } + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{ + Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "shared-files", Configuration: configuration, + }) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{ + Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, + ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String(), + }) + if err != nil { + t.Fatal(err) + } + observation := &relayObservation{} + handler := registry.Handler() + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: req.URL.Path}, req) + }) + server.Start() + t.Cleanup(func() { registry.Close(); server.Close() }) + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, server.URL, environment.ID, credential) + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "caller executor connection", func() bool { + connected, err := registry.Connected(ctx, tenant, environment.ID) + return err == nil && connected + }) + proof := map[string]any{ + "scope": "actual PostgreSQL/registry and native shared filesystem; private Session setup, no public file endpoint or daemon cutover", + "environment_id": environment.ID, "session_id": session.ID, "remote_workspace": workspace, + "native_version": strings.TrimSpace(string(version)), "phases": map[string]sharedFilesProbeProof{}, + "relay_observations": map[string]map[string]int{}, + "limitations": profile.limitations, + "transport": profile.transport, + } + secrets := []string{key, credential.Token} + defer func() { persistDaemonRemoteProof(t, root, proof, secrets) }() + var previous sharedFilesProbeProof + processIDs := []int{} + phases := []string{"first", "fresh"} + if profile.withCancellation { + phases = []string{"first", "cancel", "fresh"} + } + for index, phase := range phases { + owner, stopOwner := context.WithCancel(ctx) + t.Cleanup(stopOwner) + harnessToken, releaseHarness, err := registry.IssueHarnessCredential(owner, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(releaseHarness) + secrets = append(secrets, harnessToken) + probeEnvironment := []string{ + "PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "harness"), "CODEX_HOME=" + filepath.Join(root, "harness", "codex"), + "PARSAR_SHARED_FILES_ROOT=" + root, "PARSAR_SHARED_FILES_WORKSPACE=" + workspace, + "PARSAR_SHARED_FILES_CALLER_HOME=" + callerHome, "TMPDIR=" + root, + "PARSAR_PLACEMENT_MODEL_KEY_FILE=" + keyFile, "PARSAR_PROBE_MODEL_KEY=" + key, "PARSAR_CODEX_BINARY=" + binary, + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off", + } + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { + if value := os.Getenv(name); value != "" { + probeEnvironment = append(probeEnvironment, name+"="+value) + } + } + process := startPublicNativeProcess(t, ctx, root, phase, probeEnvironment, probe, phase) + processIDs = append(processIDs, process.command.Process.Pid) + t.Cleanup(func() { + select { + case <-process.done: + return + default: + } + _ = os.WriteFile(filepath.Join(local, phase+".release"), []byte("cleanup\n"), 0600) + _ = os.WriteFile(filepath.Join(root, phase+"-active-observed"), []byte("cleanup\n"), 0600) + _ = os.WriteFile(filepath.Join(root, phase+"-release"), []byte("cleanup\n"), 0600) + }) + awaitSharedFilesSignal(t, ctx, process, filepath.Join(local, phase+".heartbeat"), 180*time.Second) + if _, err := os.Stat(filepath.Join(local, phase+".release")); !os.IsNotExist(err) { + t.Fatal("native gate released before independent active observation") + } + proof["relay_observations"].(map[string]map[string]int)[phase+"_active"] = assertSharedFilesPair(t, observation, index+1) + if phase == "cancel" { + assertRawFilesCancelActive(t, ctx, container, local) + } + writeSharedFilesSignal(t, filepath.Join(root, phase+"-active-observed")) + awaitSharedFilesSignal(t, ctx, process, filepath.Join(root, phase+"-ready.json"), 180*time.Second) + checkpoint := readSharedFilesProof(t, filepath.Join(root, phase+"-ready.json")) + assertSharedFilesProof(t, checkpoint, phase, workspace, local, profile) + if phase == "cancel" { + awaitDaemonRemoteExit(t, ctx, container, local) + proof["cancel_process_exit_observed"] = true + } + proof["relay_observations"].(map[string]map[string]int)[phase+"_completed"] = assertSharedFilesPair(t, observation, index+1) + writeSharedFilesSignal(t, filepath.Join(root, phase+"-release")) + select { + case <-process.done: + if process.err != nil { + t.Fatal("shared native probe failed; inspect private phase evidence", phase, process.err) + } + case <-time.After(50 * time.Second): + t.Fatal("shared native owner did not shut down within the fixture bound") + } + result := readSharedFilesProof(t, filepath.Join(root, phase+".json")) + assertSharedFilesProof(t, result, phase, workspace, local, profile) + if phase != "cancel" && !strings.Contains(result.Answer, instruction) { + t.Fatal("native model did not use executor-side instructions") + } + if !result.ShutdownCompleted || result.NativeThreadID != checkpoint.NativeThreadID || result.NativeTurnID != checkpoint.NativeTurnID { + t.Fatal("shutdown proof changed the completed native identity") + } + if index > 0 && (result.NativeThreadID != previous.NativeThreadID || result.NativeTurnID == previous.NativeTurnID || result.HistoryValue != previous.HistoryValue || result.Marker == previous.Marker) { + t.Fatal("fresh native process did not preserve history and distinct Turn/marker identities") + } + if phase == "fresh" && profile.withCancellation { + assertRawFilesRecovery(t, result, previous, local) + } else if result.CancellationRecovery != nil { + t.Fatal("ordinary Files phase unexpectedly reports cancellation recovery") + } + proof["phases"].(map[string]sharedFilesProbeProof)[phase] = result + previous = result + releaseHarness() + stopOwner() + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after shared owner release", func() bool { + observation.mu.Lock() + executors, harnesses := observation.executors, observation.harnesses + observation.mu.Unlock() + connected, err := registry.Connected(ctx, tenant, environment.ID) + return executors == index+2 && harnesses == index+1 && err == nil && connected + }) + } + for _, name := range []string{"shared-gate-count", "execution-count"} { + data, err := os.ReadFile(filepath.Join(local, name)) + expected := "first\nfresh\n" + if name == "shared-gate-count" { + expected = strings.Join(phases, "\n") + "\n" + } + if err != nil || string(data) != expected { + t.Fatal("native command was omitted or repeated", name, err) + } + } + if _, err := os.Stat(workspace); !os.IsNotExist(err) { + t.Fatal("executor-only workspace appeared on the harness host") + } + if _, err := os.Stat(filepath.Join(local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("model command inherited a private credential") + } + seenProcesses := map[int]bool{} + for _, pid := range processIDs { + if seenProcesses[pid] { + t.Fatal("cold continuation did not use a fresh probe process") + } + seenProcesses[pid] = true + } + var evidenceFiles []string + for _, phase := range phases { + evidenceFiles = append(evidenceFiles, phase+".log", phase+".json") + } + for _, name := range evidenceFiles { + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + for _, secret := range secrets { + if bytes.Contains(data, []byte(secret)) { + t.Fatal("private credential appeared in probe evidence", name) + } + } + } + proof["probe_process_ids"] = processIDs + proof["probe_sha256"] = sharedFilesHash(t, probe) + proof["native_sha256"] = sharedFilesHash(t, binary) + proof["launcher_sha256"] = sharedFilesHash(t, launcher) + proof["status"] = profile.status +} + +const sharedFilesGate = `#!/bin/sh +set -eu +phase="$1" +case "$phase" in first|cancel|fresh) ;; *) exit 95 ;; esac +for name in PARSAR_PROBE_MODEL_KEY PARSAR_PLACEMENT_MODEL_KEY_FILE CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY MINIMAX_VALIDATION_KEY; do + eval 'value=${'"$name"'-}' + test -z "$value" || { printf '%s\n' "$name" >> credential-failure; exit 23; } +done +pwd > "$phase.cwd" +printf '%s\n' "$phase" >> shared-gate-count +if [ "$phase" = cancel ]; then printf '%s\n' "$$" > cancel.pid; fi +remaining=120 +while [ ! -f "$phase.release" ]; do + test "$remaining" -gt 0 || { printf 'fixture gate timed out\n' >&2; exit 94; } + date +%s > "$phase.heartbeat" + remaining=$((remaining - 1)) + sleep 1 +done +cat "$phase-marker.txt" +cp "$phase-marker.txt" "$phase-artifact.txt" +exec ./placement.sh "$phase" +` + +func awaitSharedFilesSignal(t *testing.T, ctx context.Context, process *relayProcess, path string, timeout time.Duration) { + t.Helper() + awaitDaemonRemoteCondition(t, ctx, timeout, "shared native checkpoint "+filepath.Base(path), func() bool { + select { + case <-process.done: + t.Fatal("shared native probe ended before checkpoint; inspect private phase log", process.err) + default: + } + _, err := os.Stat(path) + return err == nil + }) +} + +func writeSharedFilesSignal(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("continue\n"), 0600); err != nil { + t.Fatal(err) + } +} + +func readSharedFilesProof(t *testing.T, path string) sharedFilesProbeProof { + t.Helper() + data, err := os.ReadFile(path) + var proof sharedFilesProbeProof + if err != nil || json.Unmarshal(data, &proof) != nil { + t.Fatal("invalid shared filesystem proof", path, err) + } + return proof +} + +func assertSharedFilesPair(t *testing.T, observation *relayObservation, pairs int) map[string]int { + t.Helper() + observation.mu.Lock() + defer observation.mu.Unlock() + if observation.harnesses != pairs || observation.executors != pairs || observation.validations != pairs || observation.harness == nil { + t.Fatal("filesystem/model work replaced or duplicated the authorized native pair") + } + return map[string]int{"harnesses": observation.harnesses, "executors": observation.executors, "validations": observation.validations, "connects": observation.connects} +} + +func assertSharedFilesProof(t *testing.T, proof sharedFilesProbeProof, phase, workspace, local string, profile sharedFilesProfile) { + t.Helper() + if proof.Status != profile.status || proof.Phase != phase || proof.NativeThreadID == "" || proof.NativeTurnID == "" || proof.Marker == "" || proof.HistoryValue == "" || proof.Marker == proof.HistoryValue { + t.Fatal("shared filesystem proof lacks its native identities or random evidence") + } + if profile.transport == "raw_unix_socket" && proof.Transport != profile.transport { + t.Fatal("raw probe did not identify its qualified transport") + } + if phase == "cancel" { + assertRawFilesCancellation(t, proof, workspace, local) + return + } + if proof.Cancellation != nil { + t.Fatal("ordinary Files phase unexpectedly reports cancellation") + } + if proof.TurnStartedCount != 1 || proof.TurnCompletedCount != 1 || proof.CommandStartedCount != 1 || proof.CommandCompletedCount != 1 { + t.Fatal("native probe omitted or repeated principal lifecycle observations") + } + command := proof.Command + if command.ID == "" || !command.Started || !command.Completed || command.ExitCode != 7 || command.Cwd != workspace || !strings.Contains(command.Command, "./shared-gate.sh "+phase) || !strings.Contains(command.AggregatedOutput, proof.Marker) || !strings.Contains(command.AggregatedOutput, "remote-stdout:"+phase) || !strings.Contains(command.AggregatedOutput, "remote-stderr:"+phase) { + t.Fatal("actual remote command evidence is incomplete") + } + if !strings.Contains(proof.Answer, proof.Marker) || !strings.Contains(proof.Answer, proof.HistoryValue) || strings.Contains(proof.Answer, "WRONG_LOCAL_INSTRUCTIONS") { + t.Fatal("model did not use direct filesystem input or retained native history") + } + files := proof.Files + binary, err := os.Stat(filepath.Join(local, "shared-binary.bin")) + if err != nil || binary.Size() != 128*1024 { + t.Fatal("independent binary file size differs", err) + } + if files.BinaryBytes != 128*1024 || files.MetadataSize != 128*1024 || files.BinarySHA256 != sharedFilesHash(t, filepath.Join(local, "shared-binary.bin")) || files.ActiveHeartbeat == "" || files.Artifact != proof.Marker+"\n" { + t.Fatal("direct filesystem bytes, metadata or active observation is incomplete") + } + activeBinaryListed := false + for _, name := range files.ActiveDirectoryNames { + activeBinaryListed = activeBinaryListed || name == "shared-binary.bin" + } + if !files.ActiveBinaryVerified || !activeBinaryListed { + t.Fatal("active native binary/hash/metadata or directory observation is incomplete") + } + for _, expected := range []string{"shared-binary.bin", phase + "-marker.txt", phase + "-artifact.txt"} { + found := false + for _, name := range files.DirectoryNames { + found = found || name == expected + } + if !found { + t.Fatal("native directory observation omitted an actual file", expected) + } + } + for name, expected := range map[string]string{phase + "-marker.txt": proof.Marker + "\n", phase + "-artifact.txt": files.Artifact, phase + ".cwd": workspace + "\n"} { + data, err := os.ReadFile(filepath.Join(local, name)) + if err != nil || string(data) != expected { + t.Fatal("independent executor filesystem evidence differs", name, err) + } + } + entries, err := os.ReadDir(local) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !entry.Type().IsRegular() { + continue + } + data, err := os.ReadFile(filepath.Join(local, entry.Name())) + if err != nil || bytes.Contains(data, []byte(proof.HistoryValue)) { + t.Fatal("history-only value reached an executor file", err) + } + } +} + +func sharedFilesHash(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} diff --git a/services/agents-api/internal/store/native_workspace_preparation_test.go b/services/agents-api/internal/store/native_workspace_preparation_test.go new file mode 100644 index 000000000..944d2c207 --- /dev/null +++ b/services/agents-api/internal/store/native_workspace_preparation_test.go @@ -0,0 +1,86 @@ +package store_test + +import ( + "context" + "io/fs" + "maps" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (a *nativeHarnessArtifact) observeReadPreparation(t *testing.T, ctx context.Context, peer *gateway.Session, request proto.PromptRequestPayload, root string) { + t.Helper() + info, found, known := peer.AgentKindStatus(request.AgentKind) + if !known || !found || !info.Capabilities.WorkspaceReadPreparation { + t.Fatal("native daemon omitted read preparation capability") + } + stable := filepath.Join(root, "parsar-daemon", "agent-sessions", request.AgentStateKey) + before := nativeReadStateHashes(t, stable) + read := proto.PromptRequestPayload{AgentKind: request.AgentKind, AgentStateKey: request.AgentStateKey, + RemoteEnvironment: request.RemoteEnvironment, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + var owner nativeHarnessOwner + var state string + _, events, _ := daemonPreparedRemotePromptWithReady(t, ctx, peer, read, nil, func(handle string) bool { + owner = a.current(t) + var err error + state, err = os.Readlink(filepath.Join("/proc", strconv.Itoa(owner.PID), "cwd")) + if err != nil || !strings.HasPrefix(state, filepath.Join(root, "parsar-daemon", "workspace-read")+"/") { + t.Fatal("native reader did not use owned temporary state") + } + result, err := peer.ListWorkspaceDirectory(ctx, proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: handle, MaxEntries: proto.WorkspaceDirectoryMaxEntries}) + if err != nil || result.Outcome != "completed" || result.Directory == nil || result.Directory.Truncated { + t.Fatal("temporary native directory read failed", err, result.Outcome, result.ErrorCode) + } + found := false + for _, entry := range result.Directory.Entries { + if entry.Name == "retained.txt" && entry.Kind == "file" && entry.SizeBytes != nil && *entry.SizeBytes == int64(len("remote-file-content\n")) { + found = true + } + } + if !found { + t.Fatal("read preparation omitted the real-model generated file") + } + return false + }, nil) + if owner.PID == 0 || state == "" || daemonRemotePreparationFailed(events) { + t.Fatal("temporary native preparation did not complete") + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatal("release acknowledged before temporary state removal", err) + } + if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(owner.PID))); !os.IsNotExist(err) { + t.Fatal("release acknowledged before native process exit", err) + } + if !maps.Equal(before, nativeReadStateHashes(t, stable)) { + t.Fatal("temporary read changed original configuration or native history") + } + a.proof["read_only_preparation"] = map[string]any{"owner": owner, "released": true, "temporary_state_removed": true, "stable_state_unchanged": true, "real_model_file_observed": true} +} + +func nativeReadStateHashes(t *testing.T, root string) map[string]string { + t.Helper() + result := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type().IsRegular() { + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + result[relative] = nativeHarnessFileHash(t, path) + } + return nil + }) + if err != nil || len(result) == 0 { + t.Fatal("cannot observe existing native state", err) + } + return result +} diff --git a/services/agents-api/internal/store/no_environment_test.go b/services/agents-api/internal/store/no_environment_test.go new file mode 100644 index 000000000..7417db345 --- /dev/null +++ b/services/agents-api/internal/store/no_environment_test.go @@ -0,0 +1,29 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNoEnvironmentRejectsUnadvertisedDeviceBeforeClaim(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + var err error + h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "none", Configuration: []byte(`{"agent":{"model":"test-model"},"environment":{"type":"none"}}`)}) + if err != nil { + t.Fatal(err) + } + if err = h.s.BindSessionDevice(ctx, h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + input := h.message("first", "Answer") + if _, err = h.d.Run(ctx, h.tenant, h.session.ID, input.TurnID); err == nil { + t.Fatal("unsupported environment admitted") + } + turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } +} diff --git a/services/agents-api/internal/store/prepared_dispatch_failure_test.go b/services/agents-api/internal/store/prepared_dispatch_failure_test.go new file mode 100644 index 000000000..874a70e52 --- /dev/null +++ b/services/agents-api/internal/store/prepared_dispatch_failure_test.go @@ -0,0 +1,187 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { + for _, action := range []string{"cancel", "expire", "delete", "prepare-failure", "disconnect"} { + t.Run(action, func(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + _, pool := store.NewTestStore(t) + result := runPreparedDispatch(h, t.Context(), pending) + frame := h.read(proto.TypeExecutionPrepare) + handle := acknowledgePreparation(h, frame.ID) + switch action { + case "cancel": + if _, err := h.s.CancelEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID); err != nil { + t.Fatal(err) + } + case "expire": + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + case "delete": + if err := h.s.DeleteSession(t.Context(), h.tenant, h.session.ID); err != nil { + t.Fatal(err) + } + case "prepare-failure": + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed", ErrorCode: "preparation_failed"}) + case "disconnect": + _ = h.conn.Close() + } + got := awaitPreparedDispatch(t, result) + if got.run.Turn.ID != "" || released.Load() != 1 { + t.Fatal("unready preparation admitted work or retained credentials", got, released.Load()) + } + if action == "cancel" || action == "expire" { + want := store.EnvironmentInputCancelled + if action == "expire" { + want = store.EnvironmentInputExpired + } + if got.err != nil || got.run.Reservation.State != want { + t.Fatal("terminal reservation outcome changed", got) + } + } else if got.err == nil { + t.Fatal("preparation failure was hidden") + } + if action == "delete" && !errors.Is(got.err, store.ErrNotFound) { + t.Fatal("deleted reservation remained accessible", got.err) + } + assertEnvironmentExpiryHasNoHistory(t, pool, h.session.ID) + if action == "prepare-failure" || action == "disconnect" { + stored, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, pending.ID) + if err != nil || stored.State != store.EnvironmentInputPending || !stored.Deadline.Equal(pending.Deadline) { + t.Fatal("preparation failure changed the pending identity", stored, err) + } + } + }) + } +} + +func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *testing.T) { + for _, action := range []string{"reject", "cancel", "cancel-no-outcome"} { + t.Run(action, func(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + result := runPreparedDispatch(h, t.Context(), pending) + frame := h.read(proto.TypeExecutionPrepare) + handle := acknowledgePreparation(h, frame.ID) + start := readyPreparedDispatch(t, h, frame.ID, handle) + if action == "reject" { + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: proto.TypeExecutionStart, ErrorCode: "preparation_not_ready"}) + } else { + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "starting", RunID: start.RunID}) + if _, err := h.s.RequestCancel(t.Context(), h.tenant, h.session.ID, "cancel-start"); err != nil { + t.Fatal(err) + } + frame := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + if frame.ID != start.RunID || frame.DecodePayload(&cancel) != nil || cancel.DeliveryID == "" { + t.Fatal("pending Start cancellation lost Run ownership", frame.ID, cancel) + } + ack := proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Content: "stopped"}} + if action == "cancel-no-outcome" { + // The current daemon can cancel a starting owner before a Session supplies an outcome. + ack.Outcome = nil + } + h.write(start.RunID, proto.TypeInteractionDecisionAck, ack) + } + got := awaitPreparedDispatch(t, result) + want := store.TurnFailed + if action == "cancel" { + want = store.TurnCancelled + } + if got.err != nil || got.run.Turn.Status != want || got.run.Turn.ID != start.RunID || released.Load() != 1 { + t.Fatal("Start control did not settle through ordinary completion", got, released.Load()) + } + if action == "cancel-no-outcome" { + var outcome struct { + ErrorCode string `json:"error_code"` + } + if json.Unmarshal(got.run.Turn.Outcome, &outcome) != nil || outcome.ErrorCode != "cancel_outcome_unavailable" { + t.Fatal("missing native cancellation outcome was treated as complete", got.run.Turn.Status) + } + } + }) + } +} + +func TestPreparedDispatchRejectsPooledWriterBeforePreparation(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + h.d.Store = h.s + got, err := h.d.RunEnvironmentInput(context.Background(), h.tenant, h.session.ID, pending.ID) + if err == nil || got.Turn.ID != "" || released.Load() != 0 { + t.Fatal("pooled writer reached native preparation", got, err) + } +} + +func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { + for _, withOutcome := range []bool{false, true} { + name := "no-outcome" + if withOutcome { + name = "with-outcome" + } + t.Run(name, func(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + result := runPreparedDispatch(h, t.Context(), pending) + prepare := h.read(proto.TypeExecutionPrepare) + handle := acknowledgePreparation(h, prepare.ID) + start := readyPreparedDispatch(t, h, prepare.ID, handle) + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "starting", RunID: start.RunID}) + if _, err := h.s.RequestCancel(t.Context(), h.tenant, h.session.ID, "cancel-start"); err != nil { + t.Fatal(err) + } + frame := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + if frame.ID != start.RunID || frame.DecodePayload(&cancel) != nil || cancel.DeliveryID == "" { + t.Fatal("missing cancellation delivery") + } + // Cancelling the starting owner can publish failure before its separate acknowledgment. + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 4, State: "failed", RunID: start.RunID, ErrorCode: "start_failed"}) + time.Sleep(100 * time.Millisecond) + ack := proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true} + if withOutcome { + // Also verify preservation when a native adapter can supply a complete outcome. + ack.Outcome = &proto.DonePayload{Content: "retained cancellation", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-prepared-native"}} + } + h.write(start.RunID, proto.TypeInteractionDecisionAck, ack) + got := awaitPreparedDispatch(t, result) + var outcome execution.Result + if json.Unmarshal(got.run.Turn.Outcome, &outcome) != nil { + t.Fatal("invalid stored cancellation outcome") + } + want, code := store.TurnFailed, "cancel_outcome_unavailable" + if withOutcome { + want, code = store.TurnCancelled, "" + } + if got.err != nil || got.run.Turn.Status != want || outcome.ErrorCode != code || released.Load() != 1 { + t.Fatal("preparation failure replaced the cancellation receipt", got) + } + events, err := h.s.ListTurnEvents(t.Context(), h.tenant, h.session.ID, start.RunID, 0, 100) + if err != nil { + t.Fatal(err) + } + receipts := 0 + for _, event := range events { + if event.Kind == "cancel_receipt" { + receipts++ + } + } + if receipts != 1 { + t.Fatal("cancellation receipt was not journaled once", receipts) + } + bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) + if err != nil || (withOutcome && (bound.NativeSessionID != "cancelled-prepared-native" || outcome.Done.Content != "retained cancellation")) { + t.Fatal("cancellation lost native continuation or final output", err) + } + }) + } +} diff --git a/services/agents-api/internal/store/prepared_dispatch_native_test.go b/services/agents-api/internal/store/prepared_dispatch_native_test.go new file mode 100644 index 000000000..eca1bdac0 --- /dev/null +++ b/services/agents-api/internal/store/prepared_dispatch_native_test.go @@ -0,0 +1,275 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativePreparedWorkerRemoteEnvironment(t *testing.T) { + testNativePreparedWorkerRemoteEnvironment(t, false) +} + +func testNativePreparedWorkerRemoteEnvironment(t *testing.T, directoryReads bool) { + binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + keyFile := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") + if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") == "" { + t.Skip("built executor launcher, pinned native binary and official SDK, local executor image and real provider key file required") + } + version, err := exec.Command(binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native Codex 0.153.4 required") + } + keyBytes, err := os.ReadFile(keyFile) + if err != nil || strings.TrimSpace(string(keyBytes)) == "" { + t.Fatal("real model credential unavailable") + } + key := strings.TrimSpace(string(keyBytes)) + t.Setenv("PARSAR_CODEX_BIN", binary) + var directoryArtifact *nativeHarnessArtifact + if directoryReads { + directoryArtifact = prepareWorkerDirectoryArtifact(t, binary) + } + h, ctx, root := nativeDispatchHarnessWithTimeout(t, 8*time.Minute) + principal := store.FixtureExecutorPrincipal(t, h.s, h.tenant) + credential, err := h.s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + workspace := "/parsar-prepared-dispatch-" + uuid.NewString() + instructions := "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0." + agent := v1.Agent{ID: "agent_" + uuid.NewString(), Model: "MiniMax-M3", Instructions: &instructions, + MultiAgent: v1.MultiAgentConfig{Enabled: false}, Reasoning: v1.Reasoning{}, ServiceTier: "auto", + Text: v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: "medium"}, Tools: []json.RawMessage{}} + configuration, err := json.Marshal(map[string]any{ + "agent": agent, + "environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}, + }) + if err != nil { + t.Fatal(err) + } + h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-dispatch", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("native fixture must begin without a device binding", err) + } + environment, err := h.s.GetSessionEnvironment(ctx, h.tenant, h.session.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + var registry *codex.Registry + var tokenMu sync.Mutex + secrets := []string{key, credential.Token, h.credential} + harnessTokens := []string{} + h.d.EnvironmentConnection = func(owner context.Context, session store.Session, selected store.Environment) (execution.EnvironmentConnection, error) { + token, release, err := registry.IssueHarnessCredential(owner, session.TenantID, selected.ID) + if err != nil { + return execution.EnvironmentConnection{}, err + } + tokenMu.Lock() + defer tokenMu.Unlock() + harnessTokens = append(harnessTokens, token) + secrets = append(secrets, token) + return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, nil + } + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { + return map[string]any{"codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, nil + } + h.d.CloseEnvironmentConnections = func() { + if registry != nil { + registry.Close() + if registry.LifecycleError() != nil { + t.Error("registry connection lifecycle failed") + } + } + } + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + workerCtx, cancelWorker := context.WithCancel(ctx) + workerDone := make(chan error, 1) + var workerStarted sync.Once + // Capture the offline public snapshot before scheduling the first native preparation. + startWorker := func() { workerStarted.Do(func() { go func() { workerDone <- worker.Run(workerCtx) }() }) } + defer func() { + cancelWorker() + startWorker() + select { + case err := <-workerDone: + if err != nil && err != context.Canceled { + t.Error("worker stopped unexpectedly", err) + } + case <-time.After(15 * time.Second): + t.Error("worker did not release execution ownership") + } + if registry != nil { + registry.Close() + if registry.LifecycleError() != nil { + t.Error("registry connection lifecycle failed") + } + } + server.Close() + }() + registry, err = codex.New(codex.Config{Store: h.s, CheckOwnership: worker.CheckOwnership, ReplaceConnection: worker.ReplaceEnvironmentConnection, ObserveConnection: worker.ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + public, publicToken, foreignToken := preparedPublicHandler(t, h, worker, registry) + secrets = append(secrets, publicToken, foreignToken) + server.Config.Handler = public + server.Start() + awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "pending") + instruction := "REMOTE_" + uuid.NewString() + local := prepareDaemonRemoteWorkspace(t, root, instruction) + proof := map[string]any{"scope": "private Session provisioning and input reservation; public Session read/SSE acceptance with caller-started remote execution; public create/input remain gated", "environment_id": environment.ID, "session_id": h.session.ID, "executor_key_id": credential.KeyID, "executor_key_issued_before_session": true, "executor_key_environment_restricted": false, "native_version": strings.TrimSpace(string(version))} + defer func() { tokenMu.Lock(); defer tokenMu.Unlock(); persistDaemonRemoteProof(t, root, proof, secrets) }() + const memory = "walnut heron violet cedar cobalt willow moss iris" + observer := startPreparedPublicObserver(t, ctx, root, map[string]string{ + "base": server.URL, "token": publicToken, "foreign_token": foreignToken, "session_id": h.session.ID, + "agent_id": agent.ID, "environment_id": environment.ID, "workspace_directory": workspace, + "remote_url": registry.PublicURL(), "memory": memory, "instruction": instruction, + }) + defer observer.close() + observer.await(t, ctx, "ready") + nativeID := "" + for index, phase := range []string{"first", "resumed"} { + if index > 0 { + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor registration after previous release", func() bool { + connected, err := registry.Connected(ctx, h.tenant, environment.ID) + return err == nil && connected + }) + awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "connected") + } + text := "Run the exact command `./placement.sh " + phase + "` once with the native shell. The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." + if index == 0 { + text += " The fictional festival name to remember is " + memory + "." + } else { + text += " Recall the fictional festival name from the first Turn and read retained.txt." + } + payload, _ := json.Marshal(map[string]string{"text": text}) + pending, err := h.s.ReserveEnvironmentInput(ctx, h.tenant, h.session.ID, phase, []store.Input{{Kind: "message", Payload: payload}}) + if err != nil { + t.Fatal(err) + } + if index == 0 { + connection := observer.await(t, ctx, "waiting") + if connection.RemoteURL != registry.PublicURL() || connection.EnvironmentID != environment.ID { + t.Fatal("public Environment identity differs from the provisioned target") + } + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, connection.RemoteURL, connection.EnvironmentID, credential) + if directoryArtifact != nil { + directoryArtifact.container = container + directoryArtifact.installDirectoryHelper(t, ctx) + } + awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "connected") + proof["launcher_remote_url"] = connection.RemoteURL + proof["launcher_environment_id"] = connection.EnvironmentID + startWorker() + } + run := awaitWorkerEnvironmentRun(t, ctx, h.s, h.tenant, pending) + proof[phase] = run + if run.Turn.Status != store.TurnCompleted || len(run.Reservation.Receipts) != 1 { + t.Fatal("native prepared dispatch did not complete; inspect private proof", err) + } + var result execution.Result + if json.Unmarshal(run.Turn.Outcome, &result) != nil || !strings.Contains(result.Done.Content, memory) || !strings.Contains(result.Done.Content, instruction) || strings.Contains(result.Done.Content, "WRONG_LOCAL_INSTRUCTIONS") { + t.Fatal("remote instructions or native memory missing; inspect private proof") + } + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.Device.ID != h.device.ID || bound.NativeSessionID == "" || (index == 1 && bound.NativeSessionID != nativeID) { + t.Fatal("native continuation identity changed", err) + } + nativeID = bound.NativeSessionID + var events []proto.Envelope + var after int32 + for { + batch, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, run.Turn.ID, after, 100) + if err != nil { + t.Fatal(err) + } + if len(batch) == 0 { + break + } + for _, event := range batch { + events = append(events, proto.Envelope{Type: event.Kind, ID: run.Turn.ID, Payload: event.Payload}) + after = event.Ordinal + } + } + proof[phase+"_events"] = events + assertDaemonRemoteCommand(t, events, phase, workspace) + retry, err := h.s.ReserveEnvironmentInput(ctx, h.tenant, h.session.ID, phase, []store.Input{{Kind: "message", Payload: payload}}) + if err != nil || len(retry.Receipts) != 1 || !retry.Receipts[0].Replayed || retry.Receipts[0].TurnID != run.Turn.ID { + t.Fatal("reservation retry allocated or executed another native preparation") + } + if directoryArtifact != nil && index == 0 { + proof["core_directory_reads"] = verifyWorkerDirectoryReads(t, ctx, h, worker, registry, environment, root) + } + } + observer.finish(t) + credentialData, err := os.ReadFile(filepath.Join(root, "executor", "credential.json")) + if err != nil { + t.Fatal(err) + } + var launcherCredential map[string]json.RawMessage + if err := json.Unmarshal(credentialData, &launcherCredential); err != nil { + t.Fatal(err) + } + if _, restricted := launcherCredential["environment_id"]; restricted { + t.Fatal("principal credential file contains an Environment restriction") + } + var launcherKeyID string + if err := json.Unmarshal(launcherCredential["key_id"], &launcherKeyID); err != nil || launcherKeyID != credential.KeyID { + t.Fatal("launcher credential file lost the stable key ID") + } + count, err := os.ReadFile(filepath.Join(local, "execution-count")) + if err != nil || string(count) != "first\nresumed\n" { + t.Fatal("native command omitted or repeated") + } + if data, err := os.ReadFile(filepath.Join(local, "retained.txt")); err != nil || string(data) != "remote-file-content\n" { + t.Fatal("remote file did not persist") + } + if _, err := os.Stat(workspace); !os.IsNotExist(err) { + t.Fatal("executor path appeared on the harness host") + } + tokenMu.Lock() + tokens := append([]string(nil), harnessTokens...) + tokenMu.Unlock() + expectedTokens := 2 + if directoryArtifact != nil { + expectedTokens += 2 + } + if len(tokens) != expectedTokens { + t.Fatal("worker prepared a reservation more than once") + } + for _, token := range tokens { + assertDaemonRemoteSecrets(t, root, "agents-api-"+h.session.ID, key, credential.Token, token, h.credential) + } + environmentEvents := observer.environmentEvents(t) + verifyEnvironmentEventsWithSDK(t, root, environmentEvents) + proof["environment_events"] = environmentEvents + proof["native_thread_id"] = nativeID + proof["status"] = "private_provisioning_public_read_sse_remote_execution_verified" + proof["public_evidence"] = filepath.Join(observer.directory, "public-environment-proof.json") + proof["completed_turns"] = 2 + proof["reservation_retries_did_not_prepare_or_start"] = true + t.Log("real-provider bound Worker evidence", root) +} diff --git a/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go b/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go new file mode 100644 index 000000000..16ca8897a --- /dev/null +++ b/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go @@ -0,0 +1,148 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/google/uuid" +) + +func preparedPublicHandler(t *testing.T, h *dispatchHarness, worker *execution.Worker, registry *codex.Registry) (http.Handler, string, string) { + t.Helper() + token, foreign, foreignTenant := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, + {OrganizationID: "test-org", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + public, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL(registry.PublicURL())) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.Handle("/cloud/environment/", registry.Handler()) + mux.Handle("/", public) + return mux, token, foreign +} + +type preparedPublicObserver struct { + directory string + command *exec.Cmd + cancel context.CancelFunc + done chan struct{} + err error +} + +type preparedPublicConnection struct { + RemoteURL string `json:"remote_url"` + EnvironmentID string `json:"environment_id"` +} + +func startPreparedPublicObserver(t *testing.T, ctx context.Context, root string, settings map[string]string) *preparedPublicObserver { + t.Helper() + directory := filepath.Join(root, "public-environment") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + log, err := os.OpenFile(filepath.Join(directory, "observer.log"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + settings["evidence"] = directory + input, err := json.Marshal(settings) + if err != nil { + _ = log.Close() + t.Fatal(err) + } + ctx, cancel := context.WithCancel(ctx) + command := exec.CommandContext(ctx, os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), "../../tests/official_environment_activity.py") + command.Stdin = bytes.NewReader(input) + command.Stdout, command.Stderr = log, log + if err := command.Start(); err != nil { + cancel() + _ = log.Close() + t.Fatal("public Environment observer failed to start", err) + } + observer := &preparedPublicObserver{directory: directory, command: command, cancel: cancel, done: make(chan struct{})} + go func() { + observer.err = command.Wait() + _ = log.Close() + close(observer.done) + }() + return observer +} + +func (o *preparedPublicObserver) await(t *testing.T, ctx context.Context, name string) preparedPublicConnection { + t.Helper() + var value preparedPublicConnection + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "public Environment "+name, func() bool { + select { + case <-o.done: + t.Fatal("public Environment observer ended before signal; inspect private log", o.directory, o.err) + default: + } + raw, err := os.ReadFile(filepath.Join(o.directory, name+".json")) + if os.IsNotExist(err) { + return false + } + if err != nil || json.Unmarshal(raw, &value) != nil { + t.Fatal("invalid public Environment observer signal") + } + return true + }) + return value +} + +func (o *preparedPublicObserver) finish(t *testing.T) { + t.Helper() + select { + case <-o.done: + if o.err != nil { + t.Fatal("public Environment SDK/raw/SSE verification failed; inspect private log", o.directory, o.err) + } + case <-time.After(30 * time.Second): + t.Fatal("public Environment observer did not finish", o.directory) + } +} + +func (o *preparedPublicObserver) environmentEvents(t *testing.T) []v1.SessionEvent { + t.Helper() + raw, err := os.ReadFile(filepath.Join(o.directory, "public-environment-proof.json")) + var proof struct { + Events []v1.SessionEvent `json:"sdk_events"` + } + if err != nil || json.Unmarshal(raw, &proof) != nil { + t.Fatal("public Environment event evidence unavailable") + } + events := []v1.SessionEvent{} + for _, event := range proof.Events { + if event.Environment != nil { + events = append(events, event) + } + } + return events +} + +func (o *preparedPublicObserver) close() { + o.cancel() + select { + case <-o.done: + case <-time.After(5 * time.Second): + _ = o.command.Process.Kill() + <-o.done + } +} diff --git a/services/agents-api/internal/store/prepared_dispatch_test.go b/services/agents-api/internal/store/prepared_dispatch_test.go new file mode 100644 index 000000000..3477e896f --- /dev/null +++ b/services/agents-api/internal/store/prepared_dispatch_test.go @@ -0,0 +1,182 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type preparedDispatchResult struct { + run execution.EnvironmentRun + err error +} + +func preparedDispatchHarness(t *testing.T) (*dispatchHarness, store.EnvironmentInputReservation, *atomic.Int32) { + t.Helper() + h := newDispatchHarness(t) + var err error + h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "prepared", Configuration: json.RawMessage(`{"agent":{"model":"test-model","instructions":"Keep this instruction."},"environment":{"type":"self_hosted","workspace_directory":"/executor-workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + lease, err := h.s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + h.d.Store = lease.Store() + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + t.Fatal(err) + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true}}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "preparation capability", func() bool { + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.Preparation && info.Capabilities.RemoteEnvironment + }) + released := &atomic.Int32{} + h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { + if owner.Err() != nil || environment.TenantID != h.tenant || environment.SessionID != session.ID || session.ID != h.session.ID { + return execution.EnvironmentConnection{}, errors.New("incorrect connection owner") + } + var once sync.Once + return execution.EnvironmentConnection{URL: "http://private-registry.test", Token: "synthetic-connection-token", Release: func() { once.Do(func() { released.Add(1) }) }}, nil + } + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}}) + if err != nil { + t.Fatal(err) + } + return h, pending, released +} + +func runPreparedDispatch(h *dispatchHarness, ctx context.Context, pending store.EnvironmentInputReservation) <-chan preparedDispatchResult { + out := make(chan preparedDispatchResult, 1) + go func() { + result, err := h.d.RunEnvironmentInput(ctx, h.tenant, h.session.ID, pending.ID) + out <- preparedDispatchResult{result, err} + }() + return out +} + +func awaitPreparedDispatch(t *testing.T, result <-chan preparedDispatchResult) preparedDispatchResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(10 * time.Second): + t.Fatal("prepared dispatcher did not finish") + return preparedDispatchResult{} + } +} + +func acknowledgePreparation(h *dispatchHarness, request string) string { + handle := uuid.NewString() + h.write(request, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 1, State: "preparing", ExpiresAt: time.Now().Add(5 * time.Minute).UnixMilli()}) + return handle +} + +func readyPreparedDispatch(t *testing.T, h *dispatchHarness, request, handle string) proto.ExecutionStartPayload { + t.Helper() + h.write(request, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready", ExpiresAt: time.Now().Add(5 * time.Minute).UnixMilli()}) + frame := h.read(proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if frame.ID != request || frame.DecodePayload(&start) != nil || start.Handle != handle || start.RunID == "" || start.Prompt != "first\n\nsecond" { + t.Fatal("Start changed preparation or original batch", frame.ID, start) + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) + if err != nil || turn.Status != store.TurnInProgress { + t.Fatal("Start preceded atomic claim", turn, err) + } + return start +} + +func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + result := runPreparedDispatch(h, t.Context(), pending) + frame := h.read(proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || prepare.Configuration.Prompt != "" || prepare.Configuration.RunID != "" || prepare.Configuration.ConversationID != "" || prepare.Configuration.RemoteEnvironment == nil || prepare.Configuration.RemoteEnvironment.WorkspaceDirectory != "/executor-workspace" || prepare.Configuration.DisableExecutionEnvironment { + t.Fatal("invalid preparation configuration", prepare) + } + session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.LastTurn != nil { + t.Fatal("preparation created work before readiness", session, err) + } + items, err := h.s.ListItems(t.Context(), h.tenant, h.session.ID, "", 100, true) + if err != nil || len(items.Items) != 0 { + t.Fatal("preparation published input history", items, err) + } + handle := acknowledgePreparation(h, frame.ID) + start := readyPreparedDispatch(t, h, frame.ID, handle) + late := h.message("later", "third") + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + steering := h.read(proto.TypePromptSteer) + var steer proto.PromptSteerPayload + if steering.ID != start.RunID || steering.DecodePayload(&steer) != nil || steer.Text != "third" || !steer.DurableReceipt { + t.Fatal("later input bypassed ordinary steering", steer) + } + h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "retained-prepared-native"}}) + got := awaitPreparedDispatch(t, result) + if got.err != nil || got.run.Turn.Status != store.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence || released.Load() != 1 { + t.Fatal("prepared completion", got, released.Load()) + } + bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "retained-prepared-native" { + t.Fatal("native identity was not committed", bound, err) + } + h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { + t.Error("replay resolved another native connection") + return execution.EnvironmentConnection{}, errors.New("unexpected replay") + } + retry, err := h.d.RunEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID) + if err != nil || len(retry.Reservation.Receipts) != 2 || !retry.Reservation.Receipts[0].Replayed || retry.Reservation.Receipts[0].TurnID != start.RunID || retry.Turn.ID != "" { + t.Fatal("replay executed again", retry, err) + } +} + +func TestPreparedDispatchOwnerOutlivesReservationDeadline(t *testing.T) { + h, pending, released := preparedDispatchHarness(t) + _, pool := store.NewTestStore(t) + connection := h.d.EnvironmentConnection + owners := make(chan context.Context, 1) + h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { + owners <- owner + return connection(owner, session, environment) + } + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + result := runPreparedDispatch(h, parent, pending) + frame := h.read(proto.TypeExecutionPrepare) + owner := <-owners + if _, ok := owner.Deadline(); ok { + t.Fatal("reservation deadline was imposed on the execution owner") + } + handle := acknowledgePreparation(h, frame.ID) + start := readyPreparedDispatch(t, h, frame.ID, handle) + if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { + t.Fatal(err) + } + stored, err := h.d.Store.ExpireEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID) + if err != nil || stored.State != store.EnvironmentInputAdmitted || owner.Err() != nil || released.Load() != 0 { + t.Fatal("admitted execution lost its owner to the pending-input deadline", err) + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "completed after the reservation deadline"}) + got := awaitPreparedDispatch(t, result) + if got.err != nil || got.run.Turn.Status != store.TurnCompleted || owner.Err() == nil || released.Load() != 1 { + t.Fatal("completion did not settle and release the execution owner", got, released.Load()) + } +} diff --git a/services/agents-api/internal/store/project_scopes.go b/services/agents-api/internal/store/project_scopes.go new file mode 100644 index 000000000..bf31f0b78 --- /dev/null +++ b/services/agents-api/internal/store/project_scopes.go @@ -0,0 +1,41 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ErrProjectScopeConflict = errors.New("configured project conflicts with a persisted execution scope") + +// EnsureProjectScopes binds or verifies the complete startup configuration atomically. +// Removing a caller key never removes or changes a persisted project association. +func (s *Store) EnsureProjectScopes(ctx context.Context, scopes []identity.ProjectScope) error { + validated, err := identity.ProjectScopes(scopes) + if err != nil { + return err + } + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + for _, scope := range validated { + tenant, err := parseID(scope.TenantID) + if err != nil { + return err + } + _, err = q.EnsureProjectScope(ctx, sqlc.EnsureProjectScopeParams{TenantID: tenant, OrganizationID: scope.OrganizationID, ProjectID: scope.ProjectID}) + var databaseError *pgconn.PgError + if errors.Is(err, pgx.ErrNoRows) || (errors.As(err, &databaseError) && databaseError.Code == "23505") { + return ErrProjectScopeConflict + } + if err != nil { + return fmt.Errorf("verify execution project scope: %w", err) + } + } + return nil + }) +} diff --git a/services/agents-api/internal/store/project_scopes_test.go b/services/agents-api/internal/store/project_scopes_test.go new file mode 100644 index 000000000..2d5eeac82 --- /dev/null +++ b/services/agents-api/internal/store/project_scopes_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "errors" + "sort" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func TestProjectScopesPersistAndRejectRemapping(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + ids := []string{uuid.NewString(), uuid.NewString()} + sort.Strings(ids) + original := identity.ProjectScope{TenantID: ids[1], OrganizationID: uuid.NewString(), ProjectID: uuid.NewString()} + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{original, original}); err != nil { + t.Fatal(err) + } + pool.Close() + s, pool = testStore(t) + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{original}); err != nil { + t.Fatalf("restart verification: %v", err) + } + newScope := identity.ProjectScope{TenantID: ids[0], OrganizationID: uuid.NewString(), ProjectID: uuid.NewString()} + remapped := original + remapped.ProjectID = uuid.NewString() + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{newScope, remapped}); !errors.Is(err, ErrProjectScopeConflict) { + t.Fatalf("tenant remap = %v", err) + } + var count int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM execution_project_scopes WHERE tenant_id=$1", newScope.TenantID).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial startup configuration = %d, %v", count, err) + } + remapped = original + remapped.TenantID = uuid.NewString() + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{remapped}); !errors.Is(err, ErrProjectScopeConflict) { + t.Fatalf("project remap = %v", err) + } + if err := s.EnsureProjectScopes(ctx, []identity.ProjectScope{newScope}); err != nil { + t.Fatal(err) + } + var organization, project string + if err := pool.QueryRow(ctx, "SELECT organization_id, project_id FROM execution_project_scopes WHERE tenant_id=$1", original.TenantID).Scan(&organization, &project); err != nil || organization != original.OrganizationID || project != original.ProjectID { + t.Fatalf("removed key changed existing mapping: %q %q, %v", organization, project, err) + } +} + +func TestConcurrentProjectScopeBindingHasOneWinner(t *testing.T) { + s, _ := testStore(t) + a := identity.ProjectScope{TenantID: uuid.NewString(), OrganizationID: uuid.NewString(), ProjectID: uuid.NewString()} + b := a + b.ProjectID = uuid.NewString() + results := make(chan error, 2) + var ready sync.WaitGroup + ready.Add(2) + start := make(chan struct{}) + for _, scope := range []identity.ProjectScope{a, b} { + go func(scope identity.ProjectScope) { + ready.Done() + <-start + results <- s.EnsureProjectScopes(t.Context(), []identity.ProjectScope{scope}) + }(scope) + } + ready.Wait() + close(start) + succeeded, conflicted := 0, 0 + for range 2 { + err := <-results + if err == nil { + succeeded++ + } else if errors.Is(err, ErrProjectScopeConflict) { + conflicted++ + } else { + t.Fatal(err) + } + } + if succeeded != 1 || conflicted != 1 { + t.Fatalf("successful/conflicting bindings = %d/%d", succeeded, conflicted) + } +} diff --git a/services/agents-api/internal/store/public_execution_test.go b/services/agents-api/internal/store/public_execution_test.go new file mode 100644 index 000000000..543ec7449 --- /dev/null +++ b/services/agents-api/internal/store/public_execution_test.go @@ -0,0 +1,155 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func publicSession(t *testing.T, h *dispatchHarness, key string) store.Session { + t.Helper() + value, err := h.s.CreateSession(context.Background(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: key, Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"test-model","instructions":"Keep this."},"environment":{"type":"none"}}`)}) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { + h := newDispatchHarness(t) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true}}}}) + h.session = publicSession(t, h, "public") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }) + if second, err := execution.StartWorker(ctx, h.d); err == nil { + cancel() + go second.Run(ctx) + t.Fatal("second service acquired database") + } + inputs := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"First"}]}]}`)}, {Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"Second"}]}]}`)}} + receipts, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "batch", inputs) + if err != nil { + t.Fatal(err) + } + if _, err := worker.SubmitInputs(ctx, uuid.NewString(), h.session.ID, "foreign", inputs); err == nil { + t.Fatal("foreign tenant admitted") + } + retry, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "batch", inputs) + if err != nil || !retry[0].Replayed || retry[0].TurnID != receipts[0].TurnID { + t.Fatal(retry, err) + } + request := h.read(proto.TypePromptRequest) + var prompt proto.PromptRequestPayload + if err := request.DecodePayload(&prompt); err != nil { + t.Fatal(err) + } + if prompt.Prompt != "First\n\nSecond" || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) || prompt.AgentOptions["web_search"] != nil || prompt.AgentOptions["model_verbosity"] != nil { + t.Fatal(prompt) + } + bound, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID) + if err != nil || bound.ID != h.device.ID { + t.Fatal(bound, err) + } + active, err := h.s.GetSession(ctx, h.tenant, h.session.ID) + if err != nil || active.LastTurn == nil || active.LastTurn.Status != store.TurnInProgress { + t.Fatal(active, err) + } + h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "Answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "worker-native"}}) + waitTurn(t, h, receipts[0].TurnID, store.TurnCompleted) + items, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil || len(items.Items) != 3 { + t.Fatal(items, err) + } + next, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "next", inputs[:1]) + if err != nil { + t.Fatal(err) + } + request = h.read(proto.TypePromptRequest) + _ = request.DecodePayload(&prompt) + if prompt.AgentSessionID != "worker-native" { + t.Fatal(prompt) + } + cancel() + waitTurn(t, h, next[0].TurnID, store.TurnFailed) +} + +func waitTurn(t *testing.T, h *dispatchHarness, id, status string) { + t.Helper() + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + turn, err := h.s.GetTurn(context.Background(), h.tenant, h.session.ID, id) + if err != nil { + t.Fatal(err) + } + if turn.Status == status { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("turn %s did not become %s", id, status) +} + +func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { + h := newDispatchHarness(t) + h.session = publicSession(t, h, "interrupted") + first := h.message("first", "Already sent") + ctx := context.Background() + if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + queued := publicSession(t, h, "queued") + if _, err := h.s.SubmitMessage(ctx, h.tenant, queued.ID, "first", json.RawMessage(`{"text":"Not sent"}`)); err != nil { + t.Fatal(err) + } + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + stopped, cancel := context.WithCancel(ctx) + cancel() + if err := worker.Run(stopped); err != context.Canceled { + t.Fatal(err) + } + interrupted, err := h.s.GetSession(ctx, h.tenant, h.session.ID) + if err != nil || interrupted.LastTurn.Status != store.TurnFailed { + t.Fatal(interrupted, err) + } + pending, err := h.s.GetSession(ctx, h.tenant, queued.ID) + if err != nil || pending.LastTurn.Status != store.TurnQueued { + t.Fatal(pending, err) + } + if _, err := h.s.RequestCancel(ctx, h.tenant, queued.ID, "stop-before-dispatch"); err != nil { + t.Fatal(err) + } + pending, err = h.s.GetSession(ctx, h.tenant, queued.ID) + if err != nil || pending.LastTurn.Status != store.TurnCancelled { + t.Fatal(pending, err) + } + restarted, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal("lease not released", err) + } + if err := restarted.Run(stopped); err != context.Canceled { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/public_harness_profile_test.go b/services/agents-api/internal/store/public_harness_profile_test.go new file mode 100644 index 000000000..5f20a109d --- /dev/null +++ b/services/agents-api/internal/store/public_harness_profile_test.go @@ -0,0 +1,204 @@ +package store_test + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/google/uuid" +) + +type publicHarnessProfile struct { + artifact *nativeHarnessArtifact + home string + name string + args []string + settled bool +} + +func newPublicHarnessProfile(t *testing.T, f *publicSelfHostedFixture, native, image, key string) *publicHarnessProfile { + t.Helper() + binary := os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") + if binary == "" { + return nil + } + artifact := newNativeHarnessArtifact(t, native, binary) + home, err := os.MkdirTemp(artifact.root, "ph-") + if err != nil { + t.Fatal(err) + } + profile := &publicHarnessProfile{artifact: artifact, home: home, name: "parsar-public-harness-" + uuid.NewString(), settled: true} + t.Cleanup(func() { + if profile.settled { + if err := os.RemoveAll(home); err != nil { + t.Error("public harness home cleanup failed", err) + } + } + }) + trace := filepath.Join(f.root, "native-exec-trace") + if err := os.Mkdir(trace, 0700); err != nil { + t.Fatal(err) + } + state := filepath.Join(home, ".parsar") + if err := os.Mkdir(state, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(home, "tmp"), 0700); err != nil { + t.Fatal(err) + } + artifact.root, artifact.configuration["root"] = state, state + artifact.bind(t, f.environmentID, f.workspace) + baseURL := os.Getenv("PARSAR_PLACEMENT_MODEL_BASE_URL") + if baseURL == "" { + baseURL = "https://api.minimax.cn/v1" + } + caFile := "/etc/ssl/certs/ca-certificates.crt" + if info, err := os.Stat(caFile); err != nil || !info.Mode().IsRegular() { + t.Fatal("host CA bundle required for real provider TLS") + } + var config bytes.Buffer + if err := toml.NewEncoder(&config).Encode(map[string]any{ + "model_provider": "public_validation", + "model_providers": map[string]any{"public_validation": map[string]any{ + "name": "Public native validation", "base_url": baseURL, + "env_key": "MINIMAX_VALIDATION_KEY", "wire_api": "responses", + }}, + }); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(f.root, "native-system-config.toml") + if err := os.WriteFile(configPath, config.Bytes(), 0600); err != nil { + t.Fatal(err) + } + environment := map[string]string{ + "HOME": home, "PARSAR_HOME": f.root, "PATH": "/usr/local/bin:/usr/bin:/bin", "TMPDIR": filepath.Join(home, "tmp"), + "PARSAR_CODEX_BIN": "/opt/codex", "PARSAR_CODEX_HARNESS_BIN": "/opt/parsar-codex-harness", + "MINIMAX_VALIDATION_KEY": key, "SSL_CERT_FILE": caFile, + } + var envFile strings.Builder + for name, value := range environment { + if strings.ContainsAny(value, "\r\n") { + t.Fatal("invalid native container environment") + } + fmt.Fprintf(&envFile, "%s=%s\n", name, value) + } + envPath := filepath.Join(f.root, "native-container.env") + t.Cleanup(func() { _ = os.Remove(envPath) }) + if err := os.WriteFile(envPath, []byte(envFile.String()), 0600); err != nil { + t.Fatal(err) + } + profile.args = []string{"run", "--name", profile.name, "--network", "host", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), "--env-file", envPath, "--workdir", home, + "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777"} + // Explicit flags override Docker client proxy defaults, including env-file replacements. + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"} { + argument := name + if os.Getenv(name) == "" { + argument += "=" + } + profile.args = append(profile.args, "--env", argument) + } + for _, mount := range [][3]string{ + {native, "/opt/codex", ",readonly"}, {binary, "/opt/parsar-codex-harness", ",readonly"}, + {f.daemonBinary, "/opt/parsar-daemon", ",readonly"}, {configPath, "/etc/codex/config.toml", ",readonly"}, + {caFile, caFile, ",readonly"}, + {trace, trace, ""}, + {home, home, ""}, {filepath.Join(f.root, "parsar-daemon"), filepath.Join(f.root, "parsar-daemon"), ""}, + } { + profile.args = append(profile.args, "--mount", "type=bind,source="+mount[0]+",target="+mount[1]+mount[2]) + } + profile.args = append(profile.args, image, "strace", "-ff", "-e", "trace=execve,execveat", "-s", "256", "-o", filepath.Join(trace, "exec"), "/opt/parsar-daemon", "connect", "--profile", "execution") + return profile +} + +func (p *publicHarnessProfile) start(t *testing.T, f *publicSelfHostedFixture) *relayProcess { + t.Helper() + p.settled = false + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "docker", "rm", "-f", p.name).Run(); err != nil { + t.Error("public harness container cleanup failed", err) + return + } + p.settled = true + }) + return startPublicNativeProcess(t, f.ctx, f.root, "daemon", os.Environ(), "docker", p.args...) +} + +func (f *publicSelfHostedFixture) observeHarnessOwner(t *testing.T) { + t.Helper() + if f.harness == nil { + return + } + a := f.harness.artifact + owner := a.current(t) + if a.owners[owner.PID] { + t.Fatal("public continuation reused an earlier harness process") + } + a.owners[owner.PID] = true + a.readyOwners = append(a.readyOwners, owner) + +} + +func (f *publicSelfHostedFixture) assertHarnessReleased(t *testing.T, proof map[string]any) { + t.Helper() + if f.harness == nil { + return + } + a := f.harness.artifact + a.assertReleased(t, f.ctx) + for _, owner := range a.readyOwners { + awaitDaemonRemoteCondition(t, f.ctx, 10*time.Second, "public harness owner release", func() bool { + _, processErr := os.Stat(filepath.Join("/proc", strconv.Itoa(owner.PID))) + _, ipcErr := os.Stat(filepath.Dir(owner.IPCRoot)) + return os.IsNotExist(processErr) && os.IsNotExist(ipcErr) + }) + } + a.proof["launch_observation"] = "complete execve tracing from daemon startup through final public retries; per-process trace files retained" + a.proof["observed_owners"] = a.readyOwners + a.proof["container_image"] = os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") + a.proof["configuration"] = "task-isolated native system configuration; no harness launch wrapper" + proof["private_harness_artifact"] = a.proof +} + +func (f *publicSelfHostedFixture) nativeStarts() ([]byte, error) { + if f.harness == nil { + return os.ReadFile(filepath.Join(f.root, "native-starts")) + } + paths, err := filepath.Glob(filepath.Join(f.root, "native-exec-trace", "exec.*")) + if err != nil { + return nil, err + } + var starts []string + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, `execve("/opt/parsar-codex-harness", `) { + continue + } + if !strings.HasSuffix(line, " = 0") { + return nil, fmt.Errorf("incomplete or failed native launch observation") + } + pid := strings.TrimPrefix(filepath.Base(path), "exec.") + if _, err := strconv.Atoi(pid); err != nil { + return nil, err + } + starts = append(starts, pid) + } + } + sort.Strings(starts) + return []byte(strings.Join(starts, "\n")), nil +} diff --git a/services/agents-api/internal/store/remote_mcp_credentials_test.go b/services/agents-api/internal/store/remote_mcp_credentials_test.go new file mode 100644 index 000000000..69604ba3b --- /dev/null +++ b/services/agents-api/internal/store/remote_mcp_credentials_test.go @@ -0,0 +1,88 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestRemoteMCPCredentialFailurePrecedesPreparation(t *testing.T) { + for _, mode := range []string{"missing key", "deleted", "tampered"} { + t.Run(mode, func(t *testing.T) { + h := newDispatchHarness(t) + configuration, token := mcpBearerWorkerConfiguration(t, h) + enableWorkerEnvironment(t, h) + configuration = strings.Replace(configuration, `"type":"none"`, `"type":"self_hosted","workspace_directory":"/remote"`, 1) + session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-auth-failure", Configuration: json.RawMessage(configuration)}) + if err != nil { + t.Fatal(err) + } + if err = h.s.BindSessionDevice(t.Context(), h.tenant, session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"must not execute"}`)}}) + if err != nil { + t.Fatal(err) + } + var snapshot execution.Snapshot + if json.Unmarshal([]byte(configuration), &snapshot) != nil { + t.Fatal("invalid fixture") + } + binding := snapshot.MCPCredentials[0] + public, pool := store.NewTestStore(t) + executionStore := h.s + switch mode { + case "missing key": + executionStore = public + case "deleted": + if _, err := h.s.DeleteCredential(t.Context(), h.tenant, binding.VaultID, binding.CredentialID); err != nil { + t.Fatal(err) + } + case "tampered": + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext,15,get_byte(token_ciphertext,15) # 1) WHERE id=$1", binding.CredentialID); err != nil { + t.Fatal(err) + } + } + lease, err := executionStore.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + h.d.Store = lease.Store() + caps := workerEnvironmentCapabilities() + caps.MCPHTTPTools, caps.MCPHTTPBearerAuth, caps.MCPHTTPRemoteEnvironment, caps.MCPHTTPRemoteBearerAuth = true, true, true, true + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), time.Second, "remote authentication capability", func() bool { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.MCPHTTPRemoteBearerAuth + }) + h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { + t.Error("failed secret lookup reached connection setup") + return execution.EnvironmentConnection{}, errors.New("unexpected connection") + } + run, err := h.d.RunEnvironmentInput(t.Context(), h.tenant, session.ID, pending.ID) + if err == nil || run.Turn.ID != "" || strings.Contains(err.Error(), token) { + t.Fatal("failed authentication started work or exposed secret") + } + if mode == "missing key" && !errors.Is(err, store.ErrCredentialStorageUnavailable) { + t.Fatal("missing key failure changed", err) + } + if mode == "deleted" && !errors.Is(err, store.ErrNotFound) { + t.Fatal("deleted binding failure changed", err) + } + current, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, session.ID, pending.ID) + if err != nil || current.State != store.EnvironmentInputPending || len(current.Receipts) != 0 || !current.Deadline.Equal(pending.Deadline) { + t.Fatal("secret failure mutated input", err) + } + assertEnvironmentExpiryHasNoHistory(t, pool, session.ID) + }) + } +} diff --git a/services/agents-api/internal/store/remote_mcp_test.go b/services/agents-api/internal/store/remote_mcp_test.go new file mode 100644 index 000000000..1464253a9 --- /dev/null +++ b/services/agents-api/internal/store/remote_mcp_test.go @@ -0,0 +1,201 @@ +package store_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestRemoteMCPCredentialAdmissionAndRejectedWrites(t *testing.T) { + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New([]byte(strings.Repeat("k", 32))) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + vault, err := s.CreateVault(t.Context(), tenant, store.CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + credential, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, store.CreateStaticCredentialInput{Name: "test", MCPServerURL: "https://tools.example/mcp", Token: "synthetic-token"}) + if err != nil { + t.Fatal(err) + } + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test", TenantID: tenant, TokenSHA256: device.HashCredential("test-token")}}) + if err != nil { + t.Fatal(err) + } + worker, _ := publicInitialWorker(t, s) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://executor.example")) + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"unattached", "missing", "wrong URL", "foreign Vault", "implicit", "explicit"} { + for _, initial := range []bool{false, true} { + tool := map[string]any{"type": "mcp", "server_label": "tools", "connection_origin": "service", "transport": map[string]string{"type": "http", "server_url": "https://tools.example/mcp"}} + if mode != "implicit" { + tool["credential_id"] = credential.ID + } + body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/remote"}, "vault_ids": []string{vault.ID}} + switch mode { + case "unattached": + body["vault_ids"] = []string{} + case "missing": + tool["credential_id"] = uuid.NewString() + case "wrong URL": + tool["transport"] = map[string]string{"type": "http", "server_url": "https://other.example/mcp"} + case "foreign Vault": + body["vault_ids"] = []string{uuid.NewString()} + } + if initial { + body["input"] = "Do not run" + } + raw, _ := json.Marshal(body) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(string(raw))) + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if mode == "implicit" || mode == "explicit" { + if response.Code != http.StatusOK { + t.Fatal("valid remote credential rejected", response.Code, response.Body) + } + var public struct{ ID string } + if json.Unmarshal(response.Body.Bytes(), &public) != nil || public.ID == "" { + t.Fatal("missing Session") + } + session, err := s.GetSession(t.Context(), tenant, public.ID) + var snapshot execution.Snapshot + if err != nil || json.Unmarshal(session.Configuration, &snapshot) != nil || len(snapshot.MCPCredentials) != 1 || snapshot.MCPCredentials[0].CredentialID != credential.ID || strings.Contains(string(session.Configuration), "synthetic-token") { + t.Fatal("frozen credential missing or secret persisted", err) + } + if strings.Contains(response.Body.String(), "mcp_credentials") || strings.Contains(response.Body.String(), "synthetic-token") { + t.Fatal("private authentication exposed") + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1 AND is_initial", public.ID).Scan(&count); err != nil || (count == 1) != initial { + t.Fatal("initial reservation changed", err) + } + continue + } + if response.Code != http.StatusNotFound { + t.Fatal("invalid reference accepted or wrong error", mode, response.Code, response.Body) + } + for _, table := range []string{"sessions", "environments", "turns", "turn_inputs", "session_items", "session_events", "environment_input_reservations"} { + var count int + where := "session_id IN (SELECT id FROM sessions WHERE tenant_id=$1)" + if table == "sessions" { + where = "tenant_id=$1" + } + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM "+table+" WHERE "+where, tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("rejected request wrote execution state", table, count, err) + } + } + } + } +} + +func TestRemoteMCPWorkerRequiresCombinationCapability(t *testing.T) { + for _, profile := range []string{"anonymous", "bearer", "required", "required bearer"} { + authenticated, required := strings.Contains(profile, "bearer"), strings.Contains(profile, "required") + for _, bound := range []bool{false, true} { + t.Run(map[bool]string{false: "selection", true: "bound"}[bound]+"-"+profile, func(t *testing.T) { + h := newDispatchHarness(t) + configuration, token := mcpWorkerConfiguration, "" + if authenticated { + configuration, token = mcpBearerWorkerConfiguration(t, h) + } + if required { + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + } + enableWorkerEnvironment(t, h) + configuration = strings.Replace(configuration, `"type":"none"`, `"type":"self_hosted","workspace_directory":"/remote"`, 1) + session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-mcp", Configuration: json.RawMessage(configuration)}) + if err != nil { + t.Fatal(err) + } + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + if err != nil { + t.Fatal(err) + } + if bound { + if err := h.s.BindSessionDevice(t.Context(), h.tenant, session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + } + caps := workerEnvironmentCapabilities() + caps.MCPHTTPTools = true + caps.MCPHTTPBearerAuth = true + caps.MCPHTTPRemoteEnvironment = authenticated || required + caps.MCPHTTPRemoteBearerAuth = required && authenticated + heartbeat := func() { + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + } + heartbeat() + awaitDaemonRemoteCondition(t, t.Context(), time.Second, "MCP capability", func() bool { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, _, _ := peer.AgentKindStatus("codex") + return info.Capabilities.MCPHTTPTools + }) + frames := workerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + select { + case frame := <-frames: + t.Fatal("old peer received preparation", frame.Type) + case <-time.After(650 * time.Millisecond): + } + current, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, session.ID, pending.ID) + if err != nil || current.State != store.EnvironmentInputPending || len(current.Receipts) != 0 { + t.Fatal("old peer promoted work", err) + } + if !bound { + if _, err := h.s.GetSessionDevice(t.Context(), h.tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("old peer bound", err) + } + } + caps.MCPHTTPRequired = required + caps.MCPHTTPRemoteEnvironment = true + caps.MCPHTTPRemoteBearerAuth = authenticated + heartbeat() + frame := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || prepare.Configuration.RemoteEnvironment == nil || prepare.Configuration.MCPHTTPServers == nil { + t.Fatal("combination missing from preparation") + } + servers := *prepare.Configuration.MCPHTTPServers + if len(servers) != 1 || servers[0].ServerLabel != "tickets" || servers[0].Required != required || servers[0].AllowedTools == nil || len(*servers[0].AllowedTools) != 0 || (servers[0].BearerToken != nil) != authenticated || authenticated && *servers[0].BearerToken != token { + t.Fatal("MCP declaration changed") + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + started := nextWorkerFrame(t, frames, proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if started.DecodePayload(&start) != nil || start.Prompt != "first" { + t.Fatal("input changed") + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done"}) + got := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) + if got.Turn.Status != store.TurnCompleted { + t.Fatal("remote MCP work failed") + } + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + stop() + }) + } + } +} diff --git a/services/agents-api/internal/store/runtime_allocation_state.go b/services/agents-api/internal/store/runtime_allocation_state.go new file mode 100644 index 000000000..35fbf26be --- /dev/null +++ b/services/agents-api/internal/store/runtime_allocation_state.go @@ -0,0 +1,128 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// ObserveRuntimeRunning requires verified original compute identity. It does not +// mark the Environment connected or qualify native preparation. +func (s *Store) ObserveRuntimeRunning(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + return q.ObserveRuntimeRunning(ctx, row.ID) + }) +} + +// KeepRuntimeAllocation follows an authenticated connection and successful +// provider observation. A keepalive cannot revive cleanup or an expired lease. +func (s *Store) KeepRuntimeAllocation(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + return q.KeepRuntimeAllocation(ctx, row.ID) + }) +} + +// SettleRuntimeCreation records evidence that the original Create can no longer +// mutate resources. A timeout, missing container or lost lease is not evidence. +func (s *Store) SettleRuntimeCreation(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + if row.State == "released" { + return row, nil + } + return q.SettleRuntimeCreation(ctx, row.ID) + }) +} + +// RequestRuntimeCleanup revokes future authority before external reclamation. +// Cancellation requests do not prove existing native work has stopped. +func (s *Store) RequestRuntimeCleanup(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + if row.State == "released" { + return row, nil + } + tenant, _ := parseID(owner.TenantID) + if _, err := q.RevokeDevice(ctx, sqlc.RevokeDeviceParams{TenantID: tenant, ID: row.DeviceID}); err != nil { + return sqlc.RuntimeAllocation{}, err + } + current, err := q.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: tenant, EnvironmentID: row.EnvironmentID}) + if err != nil { + return sqlc.RuntimeAllocation{}, err + } + cancel := func() error { return cancelSessionWork(ctx, q, current.SessionID) } + if current.DeletedAt.Valid { + err = cancel() + } else { + err = withEnvironmentInputActivity(ctx, q, current.SessionID, func() error { + if err := terminateRuntimeEnvironment(ctx, q, current); err != nil { + return err + } + return cancel() + }) + } + if err != nil { + return sqlc.RuntimeAllocation{}, err + } + + return q.RequestRuntimeCleanup(ctx, row.ID) + }) +} + +// ReleaseRuntimeAllocation follows successful owned container/volume cleanup. +// Unknown Create outcomes keep their cleanup record even when compute is absent. +func (s *Store) ReleaseRuntimeAllocation(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + if row.State == "released" { + return row, nil + } + return q.ReleaseRuntimeAllocation(ctx, row.ID) + }) +} + +func (s *Store) mutateRuntimeAllocation(ctx context.Context, owner RuntimeAllocation, live bool, apply func(context.Context, *sqlc.Queries, sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error)) (RuntimeAllocation, error) { + if s.executionLease == nil { + return RuntimeAllocation{}, ErrInvalidInput + } + previous, err := s.GetRuntimeAllocation(ctx, owner.TenantID, owner.EnvironmentID) + if err != nil { + return RuntimeAllocation{}, err + } + if previous.ID != owner.ID || previous.DeviceID != owner.DeviceID || previous.ProviderKey != owner.ProviderKey { + return RuntimeAllocation{}, ErrIdempotencyConflict + } + lookup, _ := deviceLookup(owner.TenantID, owner.EnvironmentID) + var result RuntimeAllocation + err = s.withSession(ctx, owner.TenantID, previous.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + current, err := q.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: lookup.TenantID, EnvironmentID: lookup.ID}) + if err != nil { + return err + } + if live { + if current.DeletedAt.Valid { + return ErrNotFound + } + device, err := q.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams{TenantID: lookup.TenantID, ID: session}) + if err != nil { + return err + } + if device.ID != current.RuntimeAllocation.DeviceID || device.EnvironmentID != lookup.ID { + return ErrDeviceBindingConflict + } + } + row, err := apply(ctx, q, current.RuntimeAllocation) + if errors.Is(err, pgx.ErrNoRows) { + return ErrTurnConflict + } + if err == nil { + result = runtimeAllocationFromRow(row, session, lookup.TenantID, current.DeletedAt, current.Expired) + } + return err + }) + if err != nil { + return RuntimeAllocation{}, err + } + return result, nil +} diff --git a/services/agents-api/internal/store/runtime_allocations.go b/services/agents-api/internal/store/runtime_allocations.go new file mode 100644 index 000000000..7d37e99fd --- /dev/null +++ b/services/agents-api/internal/store/runtime_allocations.go @@ -0,0 +1,170 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// RuntimeAllocation retains compute ownership, not public readiness. It survives +// Session deletion until cleanup is confirmed. No bootstrap secret is retained. +type RuntimeAllocation struct { + ID, EnvironmentID, SessionID, TenantID, DeviceID, ProviderKey string + Initialization string + State string + CreateSettled, SessionDeleted, Replayed, Expired bool + CreatedAt, KeptAt time.Time +} + +// ReserveRuntimeAllocation commits the allocation and dedicated device together +// before external Create. Only a fresh receipt authorizes that one Create call. +func (s *Store) ReserveRuntimeAllocation(ctx context.Context, tenant, environment, providerKey, credentialHash string) (RuntimeAllocation, error) { + if s.executionLease == nil { + return RuntimeAllocation{}, ErrInvalidInput + } + provider, err := parseConnectionGeneration(providerKey) + if err != nil { + return RuntimeAllocation{}, err + } + owned, err := s.GetEnvironment(ctx, tenant, environment) + if err != nil { + return RuntimeAllocation{}, err + } + var config struct { + Type string `json:"type"` + } + if json.Unmarshal(owned.Configuration, &config) != nil || config.Type != "openai_hosted" { + return RuntimeAllocation{}, ErrInvalidInput + } + lookup, err := deviceLookup(tenant, environment) + if err != nil { + return RuntimeAllocation{}, err + } + device, err := newDeviceParams(lookup.TenantID, "managed-runtime", credentialHash) + if err != nil { + return RuntimeAllocation{}, err + } + var result RuntimeAllocation + err = s.withPublicSession(ctx, tenant, owned.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + previous, err := q.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: lookup.TenantID, EnvironmentID: lookup.ID}) + if err == nil { + if previous.RuntimeAllocation.ProviderKey != provider { + return ErrIdempotencyConflict + } + result = runtimeAllocationFromRow(previous.RuntimeAllocation, session, lookup.TenantID, previous.DeletedAt, previous.Expired) + result.Replayed = true + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + current, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: lookup.TenantID, ID: session}) + if err != nil { + return err + } + if current.Environment.Status == "failed" || current.Environment.Status == "expired" { + return ErrInvalidInput + } + if err := createEnvironmentDevice(ctx, q, lookup, session, device); err != nil { + return err + } + row, err := q.CreateRuntimeAllocation(ctx, sqlc.CreateRuntimeAllocationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, EnvironmentID: lookup.ID, + DeviceID: device.ID, ProviderKey: provider, + }) + if err == nil { + result = runtimeAllocationFromRow(row, session, lookup.TenantID, pgtype.Timestamptz{}, false) + } + return err + }) + if err != nil { + return RuntimeAllocation{}, err + } + return result, nil +} + +// GetRuntimeAllocation is an internal cleanup lookup, including deleted Sessions. +func (s *Store) GetRuntimeAllocation(ctx context.Context, tenant, environment string) (RuntimeAllocation, error) { + lookup, err := deviceLookup(tenant, environment) + if err != nil { + return RuntimeAllocation{}, err + } + row, err := s.queries.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: lookup.TenantID, EnvironmentID: lookup.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return RuntimeAllocation{}, ErrNotFound + } + if err != nil { + return RuntimeAllocation{}, err + } + return runtimeAllocationFromRow(row.RuntimeAllocation, row.SessionID, row.TenantID, row.DeletedAt, row.Expired), nil +} + +// ListRuntimeAllocations retains unresolved cleanup in bounded recovery scans. +func (s *Store) ListRuntimeAllocations(ctx context.Context, after string) ([]RuntimeAllocation, error) { + if err := s.CheckExecutionOwnership(ctx); err != nil { + return nil, err + } + id := pgtype.UUID{Valid: true} + if after != "" { + var err error + id, err = parseID(after) + if err != nil { + return nil, err + } + } + rows, err := s.queries.ListRuntimeAllocations(ctx, id) + if err != nil { + return nil, err + } + result := make([]RuntimeAllocation, 0, len(rows)) + for _, row := range rows { + result = append(result, runtimeAllocationFromRow(row.RuntimeAllocation, row.SessionID, row.TenantID, row.DeletedAt, row.Expired)) + } + return result, nil +} + +func runtimeAllocationFromRow(row sqlc.RuntimeAllocation, session, tenant pgtype.UUID, deleted pgtype.Timestamptz, expired bool) RuntimeAllocation { + return RuntimeAllocation{ + ID: uuid.UUID(row.ID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), + SessionID: uuid.UUID(session.Bytes).String(), TenantID: uuid.UUID(tenant.Bytes).String(), + DeviceID: uuid.UUID(row.DeviceID.Bytes).String(), ProviderKey: uuid.UUID(row.ProviderKey.Bytes).String(), + Initialization: row.Initialization, State: row.State, CreateSettled: row.CreateSettled, SessionDeleted: deleted.Valid, Expired: expired, + CreatedAt: row.CreatedAt.Time, KeptAt: row.KeptAt.Time, + } +} + +// UnallocatedHostedEnvironment is a committed resource awaiting service bootstrap. +// A missing allocation is distinct from an unknown outcome of an existing Create. +type UnallocatedHostedEnvironment struct { + ID, TenantID, Engine string +} + +func (s *Store) ListUnallocatedHostedEnvironments(ctx context.Context, after string) ([]UnallocatedHostedEnvironment, error) { + if err := s.CheckExecutionOwnership(ctx); err != nil { + return nil, err + } + id := pgtype.UUID{Valid: true} + if after != "" { + var err error + id, err = parseID(after) + if err != nil { + return nil, err + } + } + rows, err := s.queries.ListUnallocatedHostedEnvironments(ctx, id) + if err != nil { + return nil, err + } + result := make([]UnallocatedHostedEnvironment, 0, len(rows)) + for _, row := range rows { + result = append(result, UnallocatedHostedEnvironment{Engine: row.Engine, ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String()}) + } + return result, nil +} diff --git a/services/agents-api/internal/store/runtime_allocations_test.go b/services/agents-api/internal/store/runtime_allocations_test.go new file mode 100644 index 000000000..0d94d34c4 --- /dev/null +++ b/services/agents-api/internal/store/runtime_allocations_test.go @@ -0,0 +1,189 @@ +package store + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/google/uuid" +) + +func TestRuntimeAllocationAtomicOwnershipAndRecovery(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + w := lease.Store() + tenant, provider := uuid.NewString(), uuid.NewString() + session, environment := localEnvironment(t, s, tenant) + secret := uuid.NewString() + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, provider, device.HashCredential(secret)) + if err != nil || owner.Replayed || owner.State != "creating" || owner.CreateSettled { + t.Fatalf("reservation: %+v %v", owner, err) + } + bound, err := s.GetSessionDevice(t.Context(), tenant, session.ID) + if err != nil || bound.ID != owner.DeviceID || bound.EnvironmentID != environment.ID { + t.Fatalf("binding not committed with allocation: %+v %v", bound, err) + } + if _, err := w.ReserveRuntimeAllocation(t.Context(), uuid.NewString(), environment.ID, provider, device.HashCredential(secret)); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign allocation accepted: %v", err) + } + if _, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, uuid.NewString(), device.HashCredential(secret)); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("provider target changed: %v", err) + } + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := w.ObserveRuntimeRunning(t.Context(), owner); err == nil { + t.Fatal("lost writer changed allocation") + } + reopened, _ := testStore(t) + next := executionLease(t, reopened).Store() + retry, err := next.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, provider, device.HashCredential(uuid.NewString())) + if err != nil || !retry.Replayed || retry.ID != owner.ID || retry.DeviceID != owner.DeviceID { + t.Fatalf("restart replaced unknown allocation: %+v %v", retry, err) + } + credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) + if err != nil || !ok || credential.CredentialHash != device.HashCredential(secret) { + t.Fatal("retry rewrote bootstrap credential") + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + retained, err := next.GetRuntimeAllocation(t.Context(), tenant, environment.ID) + if err != nil || !retained.SessionDeleted || retained.ID != owner.ID { + t.Fatalf("deletion discarded cleanup identity: %+v %v", retained, err) + } + if _, err := next.ObserveRuntimeRunning(t.Context(), owner); !errors.Is(err, ErrNotFound) { + t.Fatalf("late creation revived deleted Session: %v", err) + } + if _, err := next.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := next.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("unknown Create forgotten: %v", err) + } + found, cursor := false, "" + for !found { + rows, err := next.ListRuntimeAllocations(t.Context(), cursor) + if err != nil { + t.Fatal(err) + } + if len(rows) == 0 { + break + } + for _, row := range rows { + found = found || row.ID == owner.ID + cursor = row.ID + } + } + + if !found { + t.Fatal("unknown cleanup absent from recovery scan") + } + if _, err := next.SettleRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := next.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + var releases int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE id=$1 AND released_at IS NOT NULL", owner.ID).Scan(&releases); err != nil || releases != 1 { + t.Fatalf("cleanup confirmation not durable: %d %v", releases, err) + } +} + +func TestRuntimeAllocationOneWinnerAndRollback(t *testing.T) { + s, pool := testStore(t) + w := executionLease(t, s).Store() + tenant, provider := uuid.NewString(), uuid.NewString() + _, environment := localEnvironment(t, s, tenant) + var wg sync.WaitGroup + results := make(chan RuntimeAllocation, 8) + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + value, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, provider, device.HashCredential(uuid.NewString())) + results <- value + errs <- err + }() + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + winners, id := 0, "" + for result := range results { + if !result.Replayed { + winners++ + } + if id != "" && id != result.ID { + t.Fatal("multiple allocation identities") + } + id = result.ID + } + if winners != 1 { + t.Fatalf("%d fresh Create receipts", winners) + } + _, fail := localEnvironment(t, s, tenant) + // Reject the final insert after device/binding writes to prove transactional rollback. + constraint := "fixture_" + uuid.NewString()[:8] + _, err := pool.Exec(t.Context(), "ALTER TABLE runtime_allocations ADD CONSTRAINT "+constraint+" CHECK (environment_id <> '"+fail.ID+"'::uuid)") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE runtime_allocations DROP CONSTRAINT "+constraint) + }) + if _, err := w.ReserveRuntimeAllocation(t.Context(), tenant, fail.ID, provider, device.HashCredential(uuid.NewString())); err == nil { + t.Fatal("injected insert failure succeeded") + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM devices WHERE environment_id=$1", fail.ID).Scan(&count); err != nil || count != 0 { + t.Fatalf("failed reservation left a device: %d %v", count, err) + } +} + +func TestRuntimeAllocationExpiryAndRevocation(t *testing.T) { + s, pool := testStore(t) + w := executionLease(t, s).Store() + tenant := uuid.NewString() + _, environment := localEnvironment(t, s, tenant) + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + owner, err = w.ObserveRuntimeRunning(t.Context(), owner) + if err != nil || !owner.CreateSettled { + t.Fatalf("running observation: %+v %v", owner, err) + } + if _, err := w.KeepRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_allocations SET kept_at=clock_timestamp()-interval '61 minutes' WHERE id=$1", owner.ID); err != nil { + t.Fatal(err) + } + if _, err := w.KeepRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("expired allocation renewed: %v", err) + } + if _, err := w.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("cleanup credential still authenticates") + } + if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + got, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, owner.ProviderKey, device.HashCredential(uuid.NewString())) + if err != nil || !got.Replayed || got.State != "released" || got.KeptAt.After(time.Now()) { + t.Fatalf("cleanup permitted replacement: %+v %v", got, err) + } +} diff --git a/services/agents-api/internal/store/runtime_connection_test.go b/services/agents-api/internal/store/runtime_connection_test.go new file mode 100644 index 000000000..c0b1cc087 --- /dev/null +++ b/services/agents-api/internal/store/runtime_connection_test.go @@ -0,0 +1,123 @@ +package store_test + +import ( + "context" + "errors" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestManagedRuntimeConnectionTracksAuthenticatedSocket(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, session, environment := managedSession(t, s) + server := httptest.NewUnstartedServer(nil) + wsURL := "ws://" + server.Listener.Addr().String() + "/api/v1/agent-daemon/ws" + handler, registry, err := runtime.NewGateway(s, wsURL) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = handler + server.Start() + t.Cleanup(func() { server.Close(); runtime.CloseConnections(registry) }) + p := &lifecycleProvider{resources: map[string]sandbox.Info{}} + key := uuid.NewString() + start := func() *execution.Worker { + w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: registry, ManagedRuntimes: &execution.RuntimeProviders{CoreURL: server.URL + "/api/v1", Providers: map[string]sandbox.Provider{key: p}}}) + if err != nil { + t.Fatal(err) + } + return w + } + stop := func(w *execution.Worker) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = w.Run(ctx) + } + w := start() + t.Cleanup(func() { stop(w) }) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, environment.ID, key) + if err != nil { + t.Fatal(err) + } + assertStatus := func(want string) { + t.Helper() + for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + got, err := s.GetEnvironment(t.Context(), tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + if got.Status == want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("Environment did not reach", want) + } + dial := func(token string) (*websocket.Conn, error) { + u, _ := url.Parse(wsURL) + u.RawQuery = url.Values{"device_id": {owner.DeviceID}, "token": {token}, "version": {proto.Version}}.Encode() + conn, response, err := websocket.DefaultDialer.Dial(u.String(), nil) + if response != nil && response.Body != nil { + response.Body.Close() + } + return conn, err + } + assertStatus("pending") // Compute existence alone is insufficient. + if conn, err := dial(uuid.NewString()); err == nil { + conn.Close() + t.Fatal("unrelated credential connected") + } + assertStatus("pending") + conn, err := dial(p.credential) + if err != nil { + t.Fatal("authorized connection failed") + } + defer conn.Close() + assertStatus("connected") + got, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || got.LastTurn != nil || got.EnvironmentInputActivity != nil { + t.Fatal("connection fabricated native execution", err) + } + if _, err := s.GetEnvironment(t.Context(), uuid.NewString(), environment.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign Environment access", err) + } + p.unavailable = true + conn.Close() + assertStatus("disconnected") // Provider outage cannot conceal socket loss. + p.unavailable = false + conn, err = dial(p.credential) + if err != nil { + t.Fatal("authorized reconnection failed") + } + defer conn.Close() + assertStatus("connected") + stop(w) + w = start() + assertStatus("connected") + retained, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) + if err != nil || retained.ID != owner.ID || retained.DeviceID != owner.DeviceID || p.creates != 1 { + t.Fatal("restart replaced Runtime identity", err) + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + reconcileManagedState(t, w, s, tenant, environment.ID, "released") + if conn, err := dial(p.credential); err == nil { + conn.Close() + t.Fatal("released Runtime reconnected") + } +} diff --git a/services/agents-api/internal/store/runtime_environment_terminal.go b/services/agents-api/internal/store/runtime_environment_terminal.go new file mode 100644 index 000000000..69bb327dc --- /dev/null +++ b/services/agents-api/internal/store/runtime_environment_terminal.go @@ -0,0 +1,26 @@ +package store + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// terminateRuntimeEnvironment participates in the allocation's Session transaction. +// Public expiry does not assert compute removal or invent an expired SSE variant. +func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current sqlc.GetRuntimeAllocationRow) error { + row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: current.TenantID, ID: current.SessionID}) + if err != nil { + return err + } + if row.Environment.Status != "expired" && row.Environment.Status != "failed" { + if current.Expired { + err = q.SetEnvironmentConnectionStatus(ctx, sqlc.SetEnvironmentConnectionStatusParams{ID: row.Environment.ID, Status: "expired"}) + } else { + err = recordEnvironmentConnection(ctx, q, row, "failed") + } + if err != nil { + return err + } + } + return q.FailSessionEnvironmentInput(ctx, current.SessionID) +} diff --git a/services/agents-api/internal/store/runtime_environment_terminal_test.go b/services/agents-api/internal/store/runtime_environment_terminal_test.go new file mode 100644 index 000000000..dc15cb065 --- /dev/null +++ b/services/agents-api/internal/store/runtime_environment_terminal_test.go @@ -0,0 +1,129 @@ +package store + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestManagedEnvironmentTerminationSettlesInputAndPreservesIdentity(t *testing.T) { + for _, expired := range []bool{false, true} { + t.Run(map[bool]string{false: "failed", true: "expired"}[expired], func(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("initial-terminal", "openai_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("initial")} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID) + writer := executionLease(t, s).Store() + owner, err := writer.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if expired { + if _, err := pool.Exec(t.Context(), "UPDATE runtime_allocations SET kept_at=clock_timestamp()-interval '61 minutes' WHERE id=$1", owner.ID); err != nil { + t.Fatal(err) + } + } + if _, err := writer.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + ended, err := s.GetSession(t.Context(), tenant, session.ID) + status := "failed" + if expired { + status = "expired" + } + if err != nil || ended.Environment.Status != status || ended.LastTurn != nil || ended.EnvironmentInputActivity == nil || ended.EnvironmentInputActivity.Status != "failed" || ended.EnvironmentInputActivity.Failure != "environment_unavailable" { + t.Fatal("terminal projection", ended, err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("terminal credential remained usable", err) + } + failed, err := writer.PromoteEnvironmentInput(t.Context(), tenant, session.ID, reservation.ID) + if err != nil || failed.State != EnvironmentInputFailed || len(failed.Receipts) != 0 || failed.SettledAt == nil || !failed.Deadline.Equal(reservation.Deadline) { + t.Fatal("late preparation resurrected failed input", failed, err) + } + if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, session.ID, "new", []Input{messageInput("later")}); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatal("terminal environment admitted new input", err) + } + if _, err := s.CreateSession(t.Context(), tenant, input); err != nil { + t.Fatal("matching creation retry changed outcome", err) + } + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + expected := 2 + if expired { + expected = 1 + } + if len(events) != expected || events[len(events)-1].Event.Type != "agent.session.failed" { + t.Fatal("wrong terminal events", events) + } + if !expired && (events[0].Event.Type != "agent.session.environment.failed" || events[0].Event.Environment.Error == nil) { + t.Fatal("missing safe environment failure", events) + } + cursor, _ := s.SessionEventCursor(t.Context(), tenant, session.ID) + if _, err := writer.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if next, err := s.SessionEventCursor(t.Context(), tenant, session.ID); err != nil || next != cursor { + t.Fatal("cleanup repeated terminal events", next, err) + } + if err := writer.ReplaceEnvironmentConnection(t.Context(), tenant, session.Environment.ID, uuid.NewString()); !errors.Is(err, ErrInvalidInput) { + t.Fatal("late connection revived terminal environment", err) + } + if _, err := writer.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { + t.Fatal("unknown creation was forgotten", err) + } + environmentInputHistory(t, pool, session.ID, 0, 0) + }) + } +} + +func TestManagedEnvironmentFailureRollsBackWithSessionEvent(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("rollback-terminal", "openai_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("initial")} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + writer := executionLease(t, s).Store() + owner, err := writer.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + constraint := pgx.Identifier{"terminal_failure_" + uuid.NewString()[:8]}.Sanitize() + if _, err := pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR payload->'event'->>'type' <> 'agent.session.failed') NOT VALID"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) + }) + if _, err := writer.RequestRuntimeCleanup(t.Context(), owner); err == nil { + t.Fatal("cleanup committed without failure event") + } + current, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || current.Environment.Status != "pending" || current.EnvironmentInputActivity != nil { + t.Fatal("partial public termination", err) + } + if reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID); reservation.State != EnvironmentInputPending { + t.Fatal("partial input failure", reservation) + } + allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, session.Environment.ID) + if err != nil || allocation.State != "creating" { + t.Fatal("partial allocation transition", err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || !ok { + t.Fatal("partial credential revocation", err) + } +} diff --git a/services/agents-api/internal/store/runtime_initialization.go b/services/agents-api/internal/store/runtime_initialization.go new file mode 100644 index 000000000..6028efbeb --- /dev/null +++ b/services/agents-api/internal/store/runtime_initialization.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) requireInitializedEnvironment(ctx context.Context, tenant, session pgtype.UUID) error { + ready, err := s.queries.GetSessionInitializationReady(ctx, sqlc.GetSessionInitializationReadyParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if !ready.Valid || !ready.Bool { + return ErrNotFound + } + return nil +} + +func (s *Store) ClaimRuntimeInitialization(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + return q.ClaimRuntimeInitialization(ctx, row.ID) + }) +} + +func (s *Store) CompleteRuntimeInitialization(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { + return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + return q.CompleteRuntimeInitialization(ctx, row.ID) + }) +} diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go new file mode 100644 index 000000000..16b0c3a44 --- /dev/null +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -0,0 +1,156 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type initializingProvider struct { + lifecycleProvider + writes int + fail bool + check func() +} + +func (p *initializingProvider) RunCommand(_ context.Context, _ sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + p.writes++ + if p.check != nil { + p.check() + } + if p.fail { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + if c.Args[len(c.Args)-1] == "/usr/local/bin/agents-api-runtime-initialize" { + var operation struct { + Version int `json:"version"` + Action string `json:"action"` + } + if json.Unmarshal(c.Stdin, &operation) != nil || operation.Version != 1 || operation.Action == "" { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + return sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed"}`}, nil + } + size, err := strconv.Atoi(c.Args[len(c.Args)-1]) + if err != nil || len(c.Stdin) != size+32 { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + digest := sha256.Sum256(c.Stdin[:size]) + if !bytes.Equal(digest[:], c.Stdin[size:]) { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + return sandbox.CommandResult{Stdout: fmt.Sprintf(`{"version":1,"outcome":"completed","size_bytes":%d}`, size)}, nil +} + +func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { + for _, mode := range []string{"complete", "restart", "uncertain", "setup-complete", "setup-restart", "setup-uncertain"} { + t.Run(mode, func(t *testing.T) { + setupOnly := strings.HasPrefix(mode, "setup-") + mode = strings.TrimPrefix(mode, "setup-") + expectedSteps := 2 + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} + if setupOnly { + input.InitialFiles = nil + input.Initialization = store.EnvironmentSetup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []store.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} + expectedSteps = 4 + } + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: mode == "uncertain"} + key := uuid.NewString() + w, stop := managedWorker(t, s, key, p) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil || owner.Initialization != "pending" { + t.Fatal("initialization ownership", owner, err) + } + credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) + if err != nil || !ok || credential.ID != owner.DeviceID { + t.Fatal("pending initialization blocks daemon authentication") + } + p.check = func() { + if _, err := s.GetSessionDevice(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("pending file access", err) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("premature native preparation", err) + } + } + // Another allocation is observed between file steps, rather than after the full batch. + otherTenant, _, otherEnv := managedSession(t, s) + if _, err := w.ProvisionEnvironment(t.Context(), otherTenant, otherEnv.ID, key); err != nil { + t.Fatal(err) + } + for n := 0; p.writes == 0 && n < 100; n++ { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + if p.writes != 1 { + t.Fatal("initialization did not perform one bounded file step", p.writes) + } + afterFirst := p.gets + if mode == "restart" { + stop() + w, _ = managedWorker(t, s, key, p) + } + if mode == "complete" { + for n := 0; p.writes < expectedSteps && n < 100; n++ { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || got.Initialization != "complete" || p.writes != expectedSteps || p.gets <= afterFirst { + t.Fatal("completion or maintenance", got, err, p.writes) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { + t.Fatal("ready execution still blocked", err) + } + stop() + w, _ = managedWorker(t, s, key, p) + for range 4 { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + if p.writes != expectedSteps { + t.Fatal("completed initialization replayed") + } + } else { + reconcileManagedState(t, w, s, tenant, env.ID, "released") + if p.writes != 1 || p.kills != 1 { + t.Fatal("uncertain initialization replayed or released twice", p.writes, p.kills) + } + failed, err := s.GetEnvironment(t.Context(), tenant, env.ID) + if err != nil || failed.Status != "failed" { + t.Fatal("failed initialization exposed", failed, err) + } + } + }) + } +} diff --git a/services/agents-api/internal/store/runtime_input_admission_test.go b/services/agents-api/internal/store/runtime_input_admission_test.go new file mode 100644 index 000000000..182f822f3 --- /dev/null +++ b/services/agents-api/internal/store/runtime_input_admission_test.go @@ -0,0 +1,48 @@ +package store_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestManagedRuntimeDisabledAdmissionPreservesCancelAndRetry(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, session, _ := managedSession(t, s) + inputs := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"accepted work"}`)}} + accepted, err := s.SubmitInputs(t.Context(), tenant, session.ID, "work", inputs) + if err != nil { + t.Fatal(err) + } + p := &lifecycleProvider{resources: map[string]sandbox.Info{}} + w, stop := managedWorker(t, s, uuid.NewString(), p) + defer stop() + if _, err := w.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: session.Configuration}); !errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("disabled admission accepted new hosted Session", err) + } + cancel := []store.Input{{Kind: "cancel", Payload: json.RawMessage(`{}`)}} + first, err := w.SubmitInputs(t.Context(), tenant, session.ID, "cancel", cancel) + if err != nil || len(first) != 1 { + t.Fatal("existing work cannot be cancelled", first, err) + } + replay, err := w.SubmitInputs(t.Context(), tenant, session.ID, "cancel", cancel) + if err != nil || len(replay) != 1 || !replay[0].Replayed || replay[0].Sequence != first[0].Sequence || replay[0].TurnID != first[0].TurnID { + t.Fatal("cancel retry lost its receipt", replay, err) + } + retry, err := w.SubmitInputs(t.Context(), tenant, session.ID, "work", inputs) + if err != nil || len(retry) != 1 || !retry[0].Replayed || retry[0].Sequence != accepted[0].Sequence || retry[0].TurnID != accepted[0].TurnID { + t.Fatal("matching input retry lost its accepted outcome", retry, err) + } + if _, err := w.SubmitInputs(t.Context(), uuid.NewString(), session.ID, "cancel", cancel); !errors.Is(err, store.ErrNotFound) { + t.Fatal("disabled admission weakened tenant isolation", err) + } + if p.creates != 0 { + t.Fatal("existing controls provisioned a new Runtime") + } +} diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go new file mode 100644 index 000000000..85b6222f2 --- /dev/null +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -0,0 +1,243 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Controlled provider faults exercise durable recovery, not native/model acceptance. +type lifecycleProvider struct { + mu sync.Mutex + resources map[string]sandbox.Info + creates, kills, gets int + loseCreate, absent, unavailable bool + credentialHash string + credential string +} + +func (p *lifecycleProvider) Create(_ context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.creates++ + p.credentialHash = device.HashCredential(b.Credential) + p.credential = b.Credential + i := sandbox.Info{Reference: b.Reference, ProviderID: b.AllocationID, State: "running", BootstrapComplete: true} + if !p.absent { + p.resources[b.AllocationID] = i + } + if p.loseCreate { + return sandbox.Info{}, errors.New("fixture: lost Create response") + } + return i, nil +} +func (p *lifecycleProvider) GetInfo(_ context.Context, r sandbox.Reference) (sandbox.Info, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.gets++ + if p.unavailable { + return sandbox.Info{}, errors.New("fixture: provider offline") + } + i, ok := p.resources[r.AllocationID] + if !ok { + return sandbox.Info{}, sandbox.ErrNotFound + } + return i, nil +} +func (p *lifecycleProvider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} +func (p *lifecycleProvider) Kill(_ context.Context, r sandbox.Reference) error { + p.mu.Lock() + defer p.mu.Unlock() + p.kills++ + delete(p.resources, r.AllocationID) + return nil +} +func (p *lifecycleProvider) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, errors.New("not used") +} + +func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.Provider) (*execution.Worker, func()) { + t.Helper() + w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: &execution.RuntimeProviders{CoreURL: "http://core.invalid/api/v1", Providers: map[string]sandbox.Provider{key: p}}}) + if err != nil { + t.Fatal(err) + } + var once sync.Once + stop := func() { + once.Do(func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = w.Run(ctx) }) + } + t.Cleanup(stop) + return w, stop +} + +func managedSession(t *testing.T, s *store.Store) (string, store.Session, store.Environment) { + t.Helper() + tenant := uuid.NewString() + v, e := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`)}) + if e != nil { + t.Fatal(e) + } + env, e := s.GetSessionEnvironment(t.Context(), tenant, v.ID) + if e != nil { + t.Fatal(e) + } + return tenant, v, env +} + +func reconcileManagedState(t *testing.T, w *execution.Worker, s *store.Store, tenant, environment, state string) { + t.Helper() + for range 100 { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + got, err := s.GetRuntimeAllocation(t.Context(), tenant, environment) + if err != nil { + t.Fatal(err) + } + if got.State == state { + return + } + } + t.Fatal("allocation did not reach", state) +} + +func TestManagedRuntimeLostCreateRestartAndDeletion(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, session, env := managedSession(t, s) + key := uuid.NewString() + p := &lifecycleProvider{resources: map[string]sandbox.Info{}, loseCreate: true} + w, stop := managedWorker(t, s, key, p) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err == nil || owner.ID == "" { + t.Fatal("fault did not retain allocation") + } + credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) + if err != nil || !ok || credential.CredentialHash != p.credentialHash { + t.Fatal("provider received unbound credential") + } + stop() + next, _ := managedWorker(t, s, key, p) + reconcileManagedState(t, next, s, tenant, env.ID, "running") + recovered, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || recovered.ID != owner.ID || !recovered.CreateSettled || recovered.State != "running" { + t.Fatalf("lost response recovery: %+v %v", recovered, err) + } + retry, err := next.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil || !retry.Replayed || retry.ID != owner.ID || p.creates != 1 { + t.Fatal("restart replayed Create") + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + // A scan may first exhaust its previous cursor before starting a new cycle. + reconcileManagedState(t, next, s, tenant, env.ID, "released") + clean, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || clean.State != "released" || p.kills != 1 { + t.Fatalf("deleted cleanup: %+v %v", clean, err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("cleanup did not revoke authority") + } +} + +func TestManagedRuntimeUnknownCreationRetainsCleanup(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, session, env := managedSession(t, s) + key := uuid.NewString() + p := &lifecycleProvider{resources: map[string]sandbox.Info{}, loseCreate: true, absent: true} + w, _ := managedWorker(t, s, key, p) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err == nil { + t.Fatal("expected uncertain creation") + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + reconcileManagedState(t, w, s, tenant, env.ID, "cleanup_pending") + got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || got.State != "cleanup_pending" || got.CreateSettled || p.creates != 1 { + t.Fatalf("unknown creation forgotten: %+v %v", got, err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("unknown allocation retains execution authority") + } + // A late completion is still owned and reclaimed on the next scan. + p.resources[owner.ID] = sandbox.Info{Reference: sandbox.Reference{TenantID: tenant, EnvironmentID: env.ID, AllocationID: owner.ID}, ProviderID: owner.ID, State: "running", BootstrapComplete: true} + reconcileManagedState(t, w, s, tenant, env.ID, "released") + got, err = s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || got.State != "released" || len(p.resources) != 0 { + t.Fatalf("late creation escaped cleanup: %+v %v", got, err) + } +} + +func TestManagedRuntimeExpiryRevokesWhenProviderUnavailable(t *testing.T) { + s, pool := store.NewTestStore(t) + tenant, _, env := managedSession(t, s) + key := uuid.NewString() + p := &lifecycleProvider{resources: map[string]sandbox.Info{}} + w, _ := managedWorker(t, s, key, p) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_allocations SET kept_at=clock_timestamp()-interval '61 minutes' WHERE id=$1", owner.ID); err != nil { + t.Fatal(err) + } + p.unavailable = true + reconcileManagedState(t, w, s, tenant, env.ID, "cleanup_pending") + got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || got.State != "cleanup_pending" { + t.Fatalf("expiry lost on provider failure: %+v %v", got, err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("expired credential still authenticates") + } + if p.kills != 0 { + t.Fatal("unavailable provider misreported cleanup") + } +} + +func TestManagedRuntimeStoppedComputeDoesNotRequestCleanup(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, _, env := managedSession(t, s) + key := uuid.NewString() + p := &lifecycleProvider{resources: map[string]sandbox.Info{}} + w, _ := managedWorker(t, s, key, p) + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil { + t.Fatal(err) + } + info := p.resources[owner.ID] + info.State = "exited" + p.resources[owner.ID] = info + for _, missing := range []bool{false, true} { + if missing { + delete(p.resources, owner.ID) + } + before := p.gets + for i := 0; i < 100 && p.gets == before; i++ { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + if p.gets == before { + t.Fatal("fixture allocation not inspected") + } + got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || got.State != "running" || p.kills != 0 || p.creates != 1 { + t.Fatalf("compute interruption authorized replacement/cleanup: %+v %v", got, err) + } + } +} diff --git a/services/agents-api/internal/store/runtime_pending_test.go b/services/agents-api/internal/store/runtime_pending_test.go new file mode 100644 index 000000000..f5e00f183 --- /dev/null +++ b/services/agents-api/internal/store/runtime_pending_test.go @@ -0,0 +1,91 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestManagedRuntimeAutomaticBootstrapRecoversCommittedSessions(t *testing.T) { + s, _ := store.NewTestStore(t) + tenant, idle, idleEnvironment := managedSession(t, s) + initial, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted"}}`), InitialInputs: []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"hello"}`)}}}) + if err != nil { + t.Fatal(err) + } + _, deleted, deletedEnvironment := managedSession(t, s) + if err := s.DeleteSession(t.Context(), deleted.TenantID, deleted.ID); err != nil { + t.Fatal(err) + } + key := uuid.NewString() + p := &lifecycleProvider{resources: map[string]sandbox.Info{}} + start := func() *execution.Worker { + w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: &execution.RuntimeProviders{CoreURL: "http://core.invalid/api/v1", DefaultProvider: key, Providers: map[string]sandbox.Provider{key: p}}}) + if err != nil { + t.Fatal(err) + } + return w + } + stop := func(w *execution.Worker) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = w.Run(ctx) + } + w := start() + closed := false + t.Cleanup(func() { + if !closed { + stop(w) + } + }) + // Both Sessions committed before this Worker existed, including one without inputs. + for range 100 { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + idleOwner, err := s.GetRuntimeAllocation(t.Context(), tenant, idleEnvironment.ID) + if err != nil || idleOwner.State != "running" { + t.Fatal("idle creation was stranded", idleOwner, err) + } + initialOwner, err := s.GetRuntimeAllocation(t.Context(), tenant, initial.Environment.ID) + if err != nil || initialOwner.State != "running" { + t.Fatal("initial creation was stranded", initialOwner, err) + } + if _, err := s.GetRuntimeAllocation(t.Context(), deleted.TenantID, deletedEnvironment.ID); err == nil { + t.Fatal("deleted Session provisioned") + } + waiting, err := s.GetSession(t.Context(), tenant, initial.ID) + if err != nil || waiting.LastTurn != nil || waiting.EnvironmentInputActivity != nil { + t.Fatal("compute existence claimed input readiness", waiting, err) + } + quiet, err := s.GetSession(t.Context(), tenant, idle.ID) + if err != nil || quiet.LastTurn != nil || quiet.EnvironmentInputActivity != nil { + t.Fatal("idle creation fabricated work", err) + } + creates := p.creates + stop(w) + closed = true + next := start() + defer stop(next) + for range 100 { + if err := next.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + if p.creates != creates { + t.Fatal("restart repeated bootstrap", creates, p.creates) + } + for _, owner := range []store.RuntimeAllocation{idleOwner, initialOwner} { + got, err := s.GetRuntimeAllocation(t.Context(), tenant, owner.EnvironmentID) + if err != nil || got.ID != owner.ID || got.DeviceID != owner.DeviceID { + t.Fatal("restart replaced allocation identity", got, err) + } + } +} diff --git a/services/agents-api/internal/store/scheduling.go b/services/agents-api/internal/store/scheduling.go new file mode 100644 index 000000000..a98c7fb1d --- /dev/null +++ b/services/agents-api/internal/store/scheduling.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type ExecutionWork struct{ TenantID, SessionID, TurnID, Status string } + +type EnvironmentInputWork struct{ TenantID, SessionID, ReservationID string } + +func executionWorkCursor(after string, connectedDevices []string) (pgtype.UUID, []pgtype.UUID, error) { + id := pgtype.UUID{Valid: true} + var err error + if after != "" { + id, err = parseID(after) + if err != nil { + return id, nil, err + } + } + devices := make([]pgtype.UUID, 0, len(connectedDevices)) + for _, value := range connectedDevices { + device, err := parseID(value) + if err != nil { + return id, nil, err + } + devices = append(devices, device) + } + return id, devices, nil +} + +func (s *Store) ListEnvironmentInputWork(ctx context.Context, after string, connectedDevices []string) ([]EnvironmentInputWork, error) { + id, devices, err := executionWorkCursor(after, connectedDevices) + if err != nil { + return nil, err + } + rows, err := s.queries.ListEnvironmentInputWork(ctx, sqlc.ListEnvironmentInputWorkParams{AfterID: id, ConnectedDevices: devices}) + if err != nil { + return nil, err + } + work := make([]EnvironmentInputWork, 0, len(rows)) + for _, row := range rows { + work = append(work, EnvironmentInputWork{TenantID: uuid.UUID(row.TenantID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), ReservationID: uuid.UUID(row.ID.Bytes).String()}) + } + return work, nil +} + +func (s *Store) ListExecutionWork(ctx context.Context, after string, statuses []string, connectedDevices []string) ([]ExecutionWork, error) { + id, devices, err := executionWorkCursor(after, connectedDevices) + if err != nil { + return nil, err + } + rows, err := s.queries.ListExecutionWork(ctx, sqlc.ListExecutionWorkParams{AfterID: id, Statuses: statuses, ConnectedOnly: connectedDevices != nil, ConnectedDevices: devices}) + if err != nil { + return nil, err + } + work := make([]ExecutionWork, 0, len(rows)) + for _, row := range rows { + work = append(work, ExecutionWork{TenantID: uuid.UUID(row.TenantID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), TurnID: uuid.UUID(row.ID.Bytes).String(), Status: row.Status}) + } + return work, nil +} + +func (s *Store) ListExecutionDevices(ctx context.Context, tenantID string) ([]ExecutionDevice, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + rows, err := s.queries.ListExecutionDevices(ctx, tenant) + if err != nil { + return nil, err + } + devices := make([]ExecutionDevice, 0, len(rows)) + for _, row := range rows { + devices = append(devices, ExecutionDevice{ID: uuid.UUID(row.ID.Bytes).String(), Name: row.Name}) + } + return devices, nil +} + +func (s *Store) sessionActivity(ctx context.Context, session Session, err error) (Session, error) { + if err != nil { + return Session{}, err + } + id, _ := parseID(session.ID) + tenant, _ := parseID(session.TenantID) + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + environment, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: id}) + if err == nil { + value, err := environmentFromRow(environment.Environment, environment.TenantID, environment.Configuration, nil) + if err != nil { + return err + } + session.Environment = &value + session.EnvironmentInputActivity, err = environmentInputActivity(ctx, q, id) + if err != nil { + return err + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return err + } + row, err := q.GetLatestSessionTurn(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + turn := turnFromRow(row) + session.LastTurn = &turn + session.RequiredActions, err = functionActions(ctx, q, row) + if err != nil { + return err + } + session.Usage, err = q.SessionTokenUsage(ctx, id) + return err + }) + return session, err +} diff --git a/services/agents-api/internal/store/self_hosted_cancel_public_test.go b/services/agents-api/internal/store/self_hosted_cancel_public_test.go new file mode 100644 index 000000000..6a659690e --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_cancel_public_test.go @@ -0,0 +1,196 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSelfHostedCancellationOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "cancel-caller", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "cancel-caller", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + serve := func() (*httptest.Server, func(bool)) { + t.Helper() + worker, stop := publicInitialWorker(t, s) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://offline-executor.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server, stop + } + server, stop := serve() + settings := map[string]any{"base": server.URL, "token": token, "foreign_token": foreign} + run := func(phase string) json.RawMessage { + t.Helper() + settings["phase"] = phase + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_cancel.py") + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("public self-hosted cancellation %s: %v %s", phase, err, output) + } + if !json.Valid(output) { + t.Fatal("invalid public cancellation fixture result") + } + return output + } + accepted := run("create") + var created struct { + ID string `json:"id"` + InitialID string `json:"initial_id"` + LaterID string `json:"later_id"` + IdleKey string `json:"idle_key"` + ActiveKey string `json:"active_key"` + } + if err := json.Unmarshal(accepted, &created); err != nil || created.ID == "" || created.InitialID == "" || created.LaterID == "" { + t.Fatal("missing public cancellation fixture identities", err) + } + settings["accepted"] = accepted + receipts := func(key, target string) []store.InputReceipt { + t.Helper() + rows, err := pool.Query(t.Context(), `SELECT sequence, COALESCE(turn_id::text,'') FROM turn_inputs + WHERE session_id=$1 AND idempotency_key=$2 ORDER BY batch_position`, created.ID, key) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var result []store.InputReceipt + for rows.Next() { + var receipt store.InputReceipt + if err := rows.Scan(&receipt.Sequence, &receipt.TurnID); err != nil { + t.Fatal(err) + } + if receipt.Sequence <= 0 || receipt.TurnID != target { + t.Fatal("public cancellation receipt changed target") + } + result = append(result, receipt) + } + if err := rows.Err(); err != nil || len(result) != 2 || result[1].Sequence != result[0].Sequence+1 { + t.Fatal("cancellation batch did not retain exactly two ordered receipts", err) + } + return result + } + snapshot := func(sessionID string) string { + t.Helper() + var value string + err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object( + 'session', (SELECT to_jsonb(s) FROM sessions s WHERE id=$1), + 'reservations', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM environment_input_reservations r WHERE session_id=$1), + 'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM turns t WHERE session_id=$1), + 'inputs', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sequence) FROM turn_inputs i WHERE session_id=$1), + 'items', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.id) FROM session_items i WHERE session_id=$1), + 'events', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM session_events e WHERE session_id=$1))::text`, sessionID).Scan(&value) + if err != nil { + t.Fatal(err) + } + return value + } + idleReceipts := receipts(created.IdleKey, "") + later, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, "controlled-later-input", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Retain pending input."}`)}}) + if err != nil || later.State != store.EnvironmentInputPending || later.IsInitial { + t.Fatal("could not establish controlled later reservation", err) + } + pending := map[string]string{created.InitialID: snapshot(created.InitialID), created.LaterID: snapshot(created.LaterID)} + transition := func(id, from, to string) { + t.Helper() + if _, err := s.TransitionTurn(t.Context(), tenant, created.ID, id, store.TurnTransition{ExpectedStatus: from, Status: to}); err != nil { + t.Fatal(err) + } + } + start := func() string { + t.Helper() + // Controlled callbacks isolate HTTP admission; no daemon or model runs in this fixture. + input, err := s.SubmitMessage(t.Context(), tenant, created.ID, uuid.NewString(), json.RawMessage(`{"text":"Controlled active work."}`)) + if err != nil { + t.Fatal(err) + } + transition(input.TurnID, store.TurnQueued, store.TurnInProgress) + settings["turn_id"] = input.TurnID + return input.TurnID + } + first := start() + if err := s.AppendTurnEvents(t.Context(), tenant, created.ID, first, 1, []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"controlled-partial","delta":"Retained partial output."}`)}}); err != nil { + t.Fatal(err) + } + before := snapshot(created.ID) + run("idle_replay") + if snapshot(created.ID) != before { + t.Fatal("idle cancellation replay or rejected input changed active work") + } + itemsBefore, err := s.ListItems(t.Context(), tenant, created.ID, "", 100, true) + if err != nil || len(itemsBefore.Items) != 2 { + t.Fatal("controlled partial output was not recorded", err) + } + cursor, err := s.SessionEventCursor(t.Context(), tenant, created.ID) + if err != nil { + t.Fatal(err) + } + run("active") + activeReceipts := receipts(created.ActiveKey, first) + turn, err := s.GetTurn(t.Context(), tenant, created.ID, first) + if err != nil || turn.Status != store.TurnInProgress || turn.CancelRequestedAt.IsZero() || !turn.CompletedAt.IsZero() { + t.Fatal("204 must admit cancellation without fabricating native completion", err) + } + itemsAfter, err := s.ListItems(t.Context(), tenant, created.ID, "", 100, true) + if err != nil || !reflect.DeepEqual(itemsBefore, itemsAfter) { + t.Fatal("cancellation admission changed partial history", err) + } + afterCursor, err := s.SessionEventCursor(t.Context(), tenant, created.ID) + if err != nil || afterCursor != cursor { + t.Fatal("cancellation admission fabricated an execution event", err) + } + transition(first, store.TurnInProgress, store.TurnCancelled) + for _, reopen := range []bool{false, true} { + if reopen { + stop(false) + server.Close() + pool.Close() + s, pool = store.NewTestStore(t) + server, stop = serve() + settings["base"] = server.URL + } + next := start() + before := snapshot(created.ID) + run("replay") + if snapshot(created.ID) != before || !reflect.DeepEqual(idleReceipts, receipts(created.IdleKey, "")) || !reflect.DeepEqual(activeReceipts, receipts(created.ActiveKey, first)) { + t.Fatal("cancel replay changed original identity or later active work", "reopened", reopen) + } + for id, expected := range pending { + if snapshot(id) != expected { + t.Fatal("rejected cancellation changed pending reservation, deadline or history", "reopened", reopen) + } + } + transition(next, store.TurnInProgress, store.TurnCompleted) + } +} diff --git a/services/agents-api/internal/store/self_hosted_functions_public_test.go b/services/agents-api/internal/store/self_hosted_functions_public_test.go new file mode 100644 index 000000000..a87876df1 --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_functions_public_test.go @@ -0,0 +1,192 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSelfHostedFunctionsOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "function-caller", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "function-caller", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + worker, _ := publicInitialWorker(t, s) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://offline-executor.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + settings := map[string]any{"base": server.URL, "token": token, "foreign_token": foreign} + run := func(phase string) json.RawMessage { + t.Helper() + settings["phase"] = phase + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_functions.py") + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("public self-hosted functions %s: %v %s", phase, err, output) + } + if !json.Valid(output) { + t.Fatal("invalid public function fixture result") + } + return output + } + accepted := run("create") + var created struct { + ID string `json:"id"` + SavedID string `json:"saved_id"` + InitialID string `json:"initial_id"` + LaterID string `json:"later_id"` + } + if err := json.Unmarshal(accepted, &created); err != nil || created.ID == "" || created.SavedID == "" || created.InitialID == "" || created.LaterID == "" { + t.Fatal("missing public function fixture identities", err) + } + settings["accepted"] = accepted + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 4 { + t.Fatal("unsupported function configuration persisted a Session", count, err) + } + if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, "controlled-later-input", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Retain pending input."}`)}}); err != nil { + t.Fatal(err) + } + snapshot := func(sessionID string) string { + t.Helper() + var value string + err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object( + 'session', (SELECT to_jsonb(s) FROM sessions s WHERE id=$1), + 'reservations', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM environment_input_reservations r WHERE session_id=$1), + 'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM turns t WHERE session_id=$1), + 'inputs', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sequence) FROM turn_inputs i WHERE session_id=$1), + 'calls', (SELECT jsonb_agg(to_jsonb(c) ORDER BY c.turn_id,c.call_id) FROM function_calls c WHERE session_id=$1), + 'items', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.id) FROM session_items i WHERE session_id=$1), + 'events', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM session_events e WHERE session_id=$1))::text`, sessionID).Scan(&value) + if err != nil { + t.Fatal(err) + } + return value + } + transition := func(session, turn, from, to string) { + t.Helper() + if _, err := s.TransitionTurn(t.Context(), tenant, session, turn, store.TurnTransition{ExpectedStatus: from, Status: to}); err != nil { + t.Fatal(err) + } + } + start := func(session string, nativeCalls []string) (string, []string) { + t.Helper() + // Calls and observations are controlled callbacks, not daemon or model execution. + input, err := s.SubmitMessage(t.Context(), tenant, session, uuid.NewString(), json.RawMessage(`{"text":"Controlled function work."}`)) + if err != nil { + t.Fatal(err) + } + transition(session, input.TurnID, store.TurnQueued, store.TurnInProgress) + var calls []string + for _, native := range nativeCalls { + id := items.Identity(input.TurnID, "tool:"+native) + if err := s.RecordFunctionCall(t.Context(), tenant, session, input.TurnID, store.FunctionCall{CallID: id, ExecutorCallID: native, Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)}); err != nil { + t.Fatal(err) + } + calls = append(calls, id) + } + return input.TurnID, calls + } + first, calls := start(created.ID, []string{"a", "b", "c"}) + other, otherCalls := start(created.SavedID, []string{"other"}) + settings["turn_id"], settings["calls"] = first, calls + settings["other_turn"], settings["other_call"] = other, otherCalls[0] + unchanged := map[string]string{created.SavedID: snapshot(created.SavedID), created.InitialID: snapshot(created.InitialID), created.LaterID: snapshot(created.LaterID)} + before := snapshot(created.ID) + run("reject") + if snapshot(created.ID) != before { + t.Fatal("rejected result batch changed calls, receipts, activity or history") + } + submitted := run("submit") + var result struct { + Batch []map[string]any `json:"batch"` + } + if err := json.Unmarshal(submitted, &result); err != nil || len(result.Batch) != 3 { + t.Fatal("missing submitted result batch", err) + } + settings["accepted"] = submitted + history, err := s.ListTurnInputs(t.Context(), tenant, created.ID, first, 0, 100) + if err != nil || len(history) != 4 || history[0].Kind != "message" { + t.Fatal("same-key concurrency duplicated result receipts", err) + } + for index, callID := range calls { + call, err := s.GetFunctionCall(t.Context(), tenant, created.ID, first, callID) + if err != nil || call.Applied { + t.Fatal("HTTP admission invented native application", err) + } + expected := result.Batch[index] + delete(expected, "type") + delete(expected, "turn_id") + delete(expected, "call_id") + var actual map[string]any + if err := json.Unmarshal(call.Result, &actual); err != nil || !reflect.DeepEqual(actual, expected) { + t.Fatal("result omission, null, output order or error changed", err) + } + var input store.FunctionResultInput + if err := json.Unmarshal(history[index+1].Payload, &input); err != nil || input.CallID != callID || input.TurnID != first || history[index+1].Kind != "tool_result" { + t.Fatal("result batch order or target changed", err) + } + if err := s.ConfirmFunctionResult(t.Context(), tenant, created.ID, first, callID); err != nil { + t.Fatal(err) + } + } + var observations []store.ExecutionEvent + for _, native := range []string{"a", "b", "c"} { + observations = append(observations, store.ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(fmt.Sprintf(`{"id":%q,"stage":"after","observation":{"status":"completed","kind":"function","name":"lookup_ticket","arguments":{"ticket":"42"},"content":[{"type":"input_text","text":"normalized native output"}]}}`, native))}) + } + if err := s.AppendTurnEvents(t.Context(), tenant, created.ID, first, 1, observations); err != nil { + t.Fatal(err) + } + transition(created.ID, first, store.TurnInProgress, store.TurnCompleted) + before = snapshot(created.ID) + run("terminal") + if snapshot(created.ID) != before { + t.Fatal("terminal result retry changed the original receipt or history") + } + next, nextCalls := start(created.ID, []string{"next"}) + settings["next_turn"], settings["next_call"] = next, nextCalls[0] + before = snapshot(created.ID) + run("later") + if snapshot(created.ID) != before { + t.Fatal("prior result retry changed later work or old receipts") + } + for id, expected := range unchanged { + if snapshot(id) != expected { + t.Fatal("result submission changed another Session or pending reservation") + } + } + transition(created.ID, next, store.TurnWaiting, store.TurnFailed) + transition(created.SavedID, other, store.TurnWaiting, store.TurnFailed) +} diff --git a/services/agents-api/internal/store/self_hosted_initial_public_test.go b/services/agents-api/internal/store/self_hosted_initial_public_test.go new file mode 100644 index 000000000..da66eb46f --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_initial_public_test.go @@ -0,0 +1,236 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "initial-creator", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "user", SubjectID: "different-creator", TokenSHA256: device.HashCredential(peer), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "initial-creator", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + const origin = "https://offline-executor.example" + serve := func(s *store.Store, worker *execution.Worker) *httptest.Server { + t.Helper() + options := []api.Option{api.WithEnvironmentRemoteURL(origin)} + if worker != nil { + options = append(options, api.WithExecution(worker)) + } + handler, err := api.NewHandler(s, auth, "codex", options...) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server + } + worker, stop := publicInitialWorker(t, s) + server := serve(s, worker) + settings := map[string]any{"base": server.URL, "token": token, "peer_token": peer, "foreign_token": foreign, "remote_url": origin} + run := func(phase string) json.RawMessage { + t.Helper() + settings["phase"] = phase + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 75*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_initial.py") + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("public self-hosted initial %s: %v %s", phase, err, output) + } + if !json.Valid(output) { + t.Fatal("invalid public initial fixture result") + } + return output + } + accepted := run("create") + var created struct { + Cases []struct { + ID string `json:"id"` + EnvironmentID string `json:"environment_id"` + Texts []string `json:"texts"` + } `json:"cases"` + } + if err := json.Unmarshal(accepted, &created); err != nil || len(created.Cases) != 4 { + t.Fatal("missing public initial creation cases", err) + } + reservations := func(s *store.Store, pool *pgxpool.Pool) map[string]store.EnvironmentInputReservation { + t.Helper() + result := make(map[string]store.EnvironmentInputReservation) + for _, item := range created.Cases { + var id string + if err := pool.QueryRow(t.Context(), "SELECT id FROM environment_input_reservations WHERE session_id=$1 AND is_initial", item.ID).Scan(&id); err != nil { + t.Fatal("public creation did not reserve initial input", err) + } + reservation, err := s.GetEnvironmentInputReservation(t.Context(), tenant, item.ID, id) + if err != nil || !reservation.IsInitial || len(reservation.Inputs) != 1 || len(reservation.Receipts) != 0 { + t.Fatal("invalid public initial reservation", err) + } + var batch struct { + Input []struct { + Content []struct{ Text string } `json:"content"` + } `json:"input"` + } + if err := json.Unmarshal(reservation.Inputs[0].Payload, &batch); err != nil { + t.Fatal(err) + } + var texts []string + for _, message := range batch.Input { + var text strings.Builder + for _, part := range message.Content { + text.WriteString(part.Text) + } + texts = append(texts, text.String()) + } + if !reflect.DeepEqual(texts, item.Texts) { + t.Fatal("public initial text order changed") + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, item.ID) + if err != nil || environment.ID != item.EnvironmentID || environment.Status != "pending" { + t.Fatal("public initial Environment identity changed", err) + } + var history int + if err := pool.QueryRow(t.Context(), `SELECT + (SELECT count(*) FROM turns WHERE session_id=$1) + + (SELECT count(*) FROM turn_inputs WHERE session_id=$1) + + (SELECT count(*) FROM session_items WHERE session_id=$1)`, item.ID).Scan(&history); err != nil || history != 0 { + t.Fatal("offline public creation manufactured Turn or input history", history, err) + } + result[item.ID] = reservation + } + return result + } + before := reservations(s, pool) + for _, reservation := range before { + if reservation.State != store.EnvironmentInputPending || reservation.Deadline.Sub(reservation.CreatedAt) != 5*time.Minute { + t.Fatal("public initial creation did not retain its database deadline") + } + } + stop(false) + server.Close() + pool.Close() + reopened, reopenedPool := store.NewTestStore(t) + worker, stop = publicInitialWorker(t, reopened) + server = serve(reopened, worker) + settings["base"], settings["accepted"] = server.URL, accepted + run("reopen") + if !reflect.DeepEqual(before, reservations(reopened, reopenedPool)) { + t.Fatal("reopened public retry changed reservation identity or deadline") + } + failureID := created.Cases[0].ID + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + // Advance one known deadline; the running Worker still owns settlement and events. + tag, err := reopenedPool.Exec(r.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE session_id=$1 AND is_initial AND state='pending'", failureID) + if err != nil || tag.RowsAffected() != 1 { + t.Error("controlled initial deadline update failed", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer control.Close() + settings["expiry_control"] = control.URL + run("expire") + after := reservations(reopened, reopenedPool) + for id, reservation := range after { + if id == failureID { + if reservation.ID != before[id].ID || reservation.State != store.EnvironmentInputExpired || reservation.SettledAt == nil { + t.Fatal("Worker did not settle the original public initial reservation") + } + } else if !reflect.DeepEqual(reservation, before[id]) { + t.Fatal("one initial expiry changed unrelated pending work") + } + } + var pid uint32 + err = reopenedPool.QueryRow(t.Context(), `SELECT pid FROM pg_locks WHERE locktype='advisory' + AND database=(SELECT oid FROM pg_database WHERE datname=current_database()) + AND classid=(706172736172::bigint >> 32)::oid + AND objid=(706172736172::bigint & 4294967295)::oid AND objsubid=1 AND granted`).Scan(&pid) + if err != nil { + t.Fatal(err) + } + var killed bool + if err := reopenedPool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", pid).Scan(&killed); err != nil || !killed { + t.Fatal("could not end the fixture Worker's execution lease", err) + } + stop(true) + settings["disabled_base"] = serve(reopened, nil).URL + run("unavailable") + if !reflect.DeepEqual(after, reservations(reopened, reopenedPool)) { + t.Fatal("unavailable execution or recorded retry changed initial work") + } +} + +func publicInitialWorker(t *testing.T, s *store.Store) (*execution.Worker, func(bool)) { + t.Helper() + dispatcher := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry()} + dispatcher.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { + t.Error("offline public creation attempted native preparation") + return execution.EnvironmentConnection{}, errors.New("offline fixture has no native connection") + } + worker, err := execution.StartWorker(t.Context(), dispatcher) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + var once sync.Once + stop := func(expectFailure bool) { + once.Do(func() { + defer cancel() + if !expectFailure { + cancel() + } + select { + case err := <-done: + if err == nil || (!expectFailure && !errors.Is(err, context.Canceled)) { + t.Error("unexpected offline Worker completion", err) + } + case <-time.After(10 * time.Second): + t.Error("offline Worker did not release execution ownership") + } + }) + } + t.Cleanup(func() { stop(false) }) + return worker, stop +} diff --git a/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go b/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go new file mode 100644 index 000000000..5b20d122b --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go @@ -0,0 +1,175 @@ +package store_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePublicSelfHostedCancellationStandalone(t *testing.T) { + f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_cancel_native.py") + // Hold the later native command open while the client retries the old cancel key. + resumedScript := `#!/bin/sh +set -eu +printf 'started\n' >> resumed-start-count +while [ ! -f resumed.release ]; do date +%s > resumed.heartbeat; sleep 1; done +exec ./placement.sh resumed +` + if err := os.WriteFile(filepath.Join(f.local, "resume.sh"), []byte(resumedScript), 0700); err != nil { + t.Fatal(err) + } + daemon := f.startDaemon(t) + awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual long command heartbeat", func() bool { + _, err := os.Stat(filepath.Join(f.local, "cancel.heartbeat")) + return err == nil + }) + f.observeHarnessOwner(t) + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "cancel-ready.json"), map[string]string{"environment_id": f.environmentID}) + requested := awaitPublicNativeSignal(t, f.ctx, f.observer, "cancel-requested", 150*time.Second) + unix, err := strconv.ParseFloat(requested["request_started_unix"], 64) + if err != nil || unix <= 0 { + t.Fatal("missing public cancellation request time") + } + cancelAt := time.UnixMilli(int64(unix * 1000)) + // Measure actual process exit and stopped side effects separately from HTTP and Turn status. + awaitDaemonRemoteExit(t, f.ctx, f.container, f.local) + observedExitSeconds := time.Since(cancelAt).Seconds() + first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-cancelled", 45*time.Second) + if first["turn_id"] != requested["turn_id"] { + t.Fatal("cancelled Turn differs from public request target") + } + binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { + t.Fatal("cancellation lost native device/history binding", err) + } + var receipt *proto.InteractionDecisionAckPayload + firstEvents := f.events(t, first["turn_id"]) + assertDaemonRemoteCommand(t, firstEvents, "first", f.workspace) + for _, event := range firstEvents { + if event.Type != "cancel_receipt" { + continue + } + var value proto.InteractionDecisionAckPayload + if receipt != nil || event.DecodePayload(&value) != nil { + t.Fatal("invalid or repeated native cancellation receipt") + } + receipt = &value + } + if receipt == nil || !receipt.Applied || receipt.Outcome == nil || receipt.ErrorCode != "" || receipt.DeliveryID != "cancel:"+first["turn_id"] || receipt.Outcome.Metadata[proto.DoneMetaAgentSessionID] != binding.NativeSessionID { + t.Fatal("public cancellation lacks its actual native outcome/identity") + } + firstStarts, err := f.nativeStarts() + if err != nil || len(strings.Fields(string(firstStarts))) != 1 { + t.Fatal("cancelled input started another native harness") + } + awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") + select { + case <-daemon.done: + t.Fatal("daemon exited during cancellation") + default: + } + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) + awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual resumed native command", func() bool { + _, err := os.Stat(filepath.Join(f.local, "resumed.heartbeat")) + return err == nil + }) + f.observeHarnessOwner(t) + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resumed-active.json"), map[string]string{"session_id": f.sessionID}) + second := awaitPublicNativeSignal(t, f.ctx, f.observer, "old-cancel-retried", 30*time.Second) + before, err := os.ReadFile(filepath.Join(f.local, "resumed.heartbeat")) + if err != nil { + t.Fatal(err) + } + awaitDaemonRemoteCondition(t, f.ctx, 5*time.Second, "resumed heartbeat after old cancellation replay", func() bool { + after, err := os.ReadFile(filepath.Join(f.local, "resumed.heartbeat")) + return err == nil && string(after) != string(before) + }) + active, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, second["turn_id"]) + if err != nil || active.Status != store.TurnInProgress || !active.CancelRequestedAt.IsZero() { + t.Fatal("old public cancellation affected the active resumed Turn", err) + } + if err := os.WriteFile(filepath.Join(f.local, "resumed.release"), []byte("continue\n"), 0600); err != nil { + t.Fatal(err) + } + select { + case <-f.observer.done: + f.observer.finish(t) + case <-f.ctx.Done(): + t.Fatal("public cancellation continuation timed out; inspect private proof") + } + finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || finalBinding != binding { + t.Fatal("cold continuation changed native device/history binding", err) + } + data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-cancellation-proof.json")) + if err != nil { + t.Fatal(err) + } + var publicProof struct { + Case string `json:"case"` + Turns []struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"turns"` + } + if json.Unmarshal(data, &publicProof) != nil || publicProof.Case != "public_cancellation" || len(publicProof.Turns) != 2 || publicProof.Turns[0].ID != first["turn_id"] || publicProof.Turns[0].Status != "cancelled" || publicProof.Turns[1].ID != second["turn_id"] || publicProof.Turns[1].Status != "completed" { + t.Fatal("public proof lacks cancelled and cold-resumed real Turns") + } + settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) + if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { + t.Fatal("cancellation/retries changed original reservation identity", err) + } + assertDaemonRemoteCommand(t, f.events(t, second["turn_id"]), "resumed", f.workspace) + for _, phase := range []string{"first", "resumed"} { + cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) + if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { + t.Fatal("real command did not use the executor-only workspace") + } + } + for name, want := range map[string]string{"execution-count": "first\nresumed\n", "resumed-start-count": "started\n", "retained.txt": "remote-file-content\n"} { + value, err := os.ReadFile(filepath.Join(f.local, name)) + if err != nil || string(value) != want { + t.Fatal("real cancellation/continuation side effects differ", name, err) + } + } + if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { + t.Fatal("remote workspace appeared on the harness host") + } + if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("native command inherited transport/provider credentials") + } + starts, err := f.nativeStarts() + processes := strings.Fields(string(starts)) + if err != nil || len(processes) != 2 || processes[0] == processes[1] { + t.Fatal("continuation did not start exactly one fresh harness per Turn") + } + artifact, err := os.ReadFile(f.serverBinary) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(artifact) + proof := map[string]any{ + "status": "built_service_public_self_hosted_cancellation_verified", "case": "public_cancellation", + "session_id": f.sessionID, "environment_id": f.environmentID, "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), + "native_version": f.version, "native_thread_id": binding.NativeSessionID, "native_harness_processes": processes, + "cancelled_turn": first["turn_id"], "completed_turn": second["turn_id"], "native_cancel_receipt": receipt, + "cancel_request_to_observed_exit_seconds": observedExitSeconds, "process_exit_and_stopped_heartbeat_observed": true, + "executor_and_daemon_retained": true, "old_cancel_retry_did_not_retarget": true, "command_execution_count": "first\nresumed\n", + "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, + "reservation_id": f.reservation.ID, "reservation_deadline": f.reservation.Deadline, + "public_evidence": filepath.Join(f.observer.directory, "public-cancellation-proof.json"), + "limits": "Observed native cleanup timing is scenario-specific; no general OS quiescence, pre-Start Outcome or complete final usage claim.", + } + f.assertHarnessReleased(t, proof) + persistDaemonRemoteProof(t, f.root, proof, f.secrets) + t.Log("built standalone public cancellation real-provider evidence", f.root) +} diff --git a/services/agents-api/internal/store/self_hosted_public_fixture_test.go b/services/agents-api/internal/store/self_hosted_public_fixture_test.go new file mode 100644 index 000000000..0a38b412d --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_fixture_test.go @@ -0,0 +1,198 @@ +package store_test + +import ( + "context" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type publicSelfHostedFixture struct { + ctx context.Context + store *store.Store + observer *preparedPublicObserver + root, local, workspace, serverBinary, daemonBinary, version string + tenant, sessionID, environmentID, deviceID, remoteURL, container string + reservation store.EnvironmentInputReservation + executor store.IssuedExecutorCredential + daemonEnvironment, secrets []string + harness *publicHarnessProfile +} + +// This fixture provisions credentials and transport only; the Python client owns public Session/input requests. +func newPublicSelfHostedFixture(t *testing.T, mode, script string) *publicSelfHostedFixture { + t.Helper() + if os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") != "" && script != "official_self_hosted_cancel_native.py" { + t.Fatal("public harness qualification currently requires the cancellation fixture") + } + serverBinary, nativeBinary := os.Getenv("PARSAR_AGENTS_API_SERVER_BIN"), os.Getenv("PARSAR_CODEX_BINARY") + image, keyFile := os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") + proofRoot, daemonBinary := os.Getenv("PARSAR_NATIVE_PROOF_DIR"), os.Getenv("PARSAR_NATIVE_DAEMON_BIN") + if serverBinary == "" || nativeBinary == "" || keyFile == "" || proofRoot == "" || daemonBinary == "" || + !strings.HasPrefix(image, "sha256:") || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") == "" { + t.Skip("built standalone service, daemon, launcher, pinned native/SDK, local image and real provider credential required") + } + version, err := exec.Command(nativeBinary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native Codex 0.153.4 required") + } + keyBytes, err := os.ReadFile(keyFile) + if err != nil || strings.TrimSpace(string(keyBytes)) == "" { + t.Fatal("real provider credential unavailable") + } + key := strings.TrimSpace(string(keyBytes)) + s, pool := store.NewTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + t.Cleanup(cancel) + root, err := os.MkdirTemp(proofRoot, strings.TrimSuffix(script, ".py")+"-"+mode+"-") + if err != nil { + t.Fatal(err) + } + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + executor, err := s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + caller, foreign, deviceToken := uuid.NewString(), uuid.NewString(), uuid.NewString() + daemonDevice, err := s.CreateDevice(ctx, tenant, "Public self-hosted acceptance", device.HashCredential(deviceToken)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanup, release := context.WithTimeout(context.Background(), 10*time.Second) + defer release() + if err := s.RevokeDevice(cleanup, tenant, daemonDevice.ID); err != nil { + t.Error("owned device credential cleanup failed", err) + } + if err := s.RevokeExecutorCredential(cleanup, principal, executor.KeyID); err != nil { + t.Error("owned executor credential cleanup failed", err) + } + }) + keys := filepath.Join(root, "api-keys.json") + writePublicNativeJSON(t, keys, []api.APIKey{ + {TenantID: tenant, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(caller)}, + {TenantID: foreignTenant, OrganizationID: principal.OrganizationID, ProjectID: foreignTenant, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(foreign)}, + }) + address := publicNativeAddress(t) + base := "http://" + address + serverEnvironment := publicNativeEnvironment(map[string]string{ + "AGENTS_API_DATABASE_URL": os.Getenv("PARSAR_AGENTS_API_TEST_DATABASE_URL"), + "AGENTS_API_KEYS_FILE": keys, "AGENTS_API_ADDR": address, "AGENTS_API_ENGINE": "codex", + "AGENTS_API_DAEMON_WS_URL": "ws://" + address + "/api/v1/agent-daemon/ws", + "AGENTS_API_EXECUTOR_URL": base, "AGENTS_API_EXECUTOR_KEYS_FILE": "", "AGENTS_API_HARNESS_KEYS_FILE": "", + "PARSAR_HOME": root, + }) + server := startPublicNativeProcess(t, ctx, root, "server", serverEnvironment, serverBinary) + awaitPublicNativeServer(t, ctx, server, base) + instruction := "REMOTE_" + uuid.NewString() + local := prepareDaemonRemoteWorkspace(t, root, instruction) + workspace := "/parsar-public-self-hosted-" + uuid.NewString() + const memory = "walnut heron violet cedar cobalt willow moss iris" + observer := startPublicNativeClientScript(t, ctx, root, script, map[string]string{ + "base": base, "token": caller, "foreign_token": foreign, "workspace_directory": workspace, + "remote_url": base, "memory": memory, "instruction": instruction, "creation_mode": mode, + "model": os.Getenv("PARSAR_PLACEMENT_MODEL"), + }) + waiting := awaitPublicNativeSignal(t, ctx, observer, "waiting", 70*time.Second) + sessionID, environmentID := waiting["session_id"], waiting["environment_id"] + environment, err := s.GetSessionEnvironment(ctx, tenant, sessionID) + if err != nil || environment.ID != environmentID || waiting["remote_url"] != base || environment.Status != "pending" || waiting["creation_mode"] != mode { + t.Fatal("public Environment differs from owned offline target", err) + } + var reservationID string + if err := pool.QueryRow(ctx, "SELECT id FROM environment_input_reservations WHERE session_id=$1", sessionID).Scan(&reservationID); err != nil { + t.Fatal("public first input did not retain its reservation", err) + } + reservation, err := s.GetEnvironmentInputReservation(ctx, tenant, sessionID, reservationID) + if err != nil || reservation.State != store.EnvironmentInputPending || reservation.IsInitial != (mode != "empty_later") { + t.Fatal("public first input has incorrect reservation origin/state", err) + } + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, nativeBinary, image, waiting["remote_url"], environmentID, executor) + awaitEnvironmentConnectionState(t, ctx, s, tenant, environmentID, "connected") + writePublicNativeJSON(t, filepath.Join(observer.directory, "initial-connection-ready.json"), map[string]string{"environment_id": environmentID}) + connectedRead := awaitPublicNativeSignal(t, ctx, observer, "initial-connection-read", 20*time.Second) + if connectedRead["environment_id"] != environmentID { + t.Fatal("public connected retrieval observed the wrong Environment") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v1/agents/environments/"+environmentID, nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+executor.Token) + request.Header.Set("OpenAI-Beta", "agents=v1") + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{Proxy: nil}} + defer client.CloseIdleConnections() + response, err := client.Do(request) + if err != nil { + t.Fatal("executor-only credential retrieval request failed", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatal("executor-only credential authorized a public Environment read") + } + profile := filepath.Join(root, "parsar-daemon", "execution") + if err := os.MkdirAll(profile, 0700); err != nil { + t.Fatal(err) + } + writePublicNativeJSON(t, filepath.Join(profile, "auth.json"), map[string]string{ + "server_url": base + "/api/v1", "runtime_id": daemonDevice.ID, "runner_credential": deviceToken, "device_name": "Public self-hosted acceptance", + }) + wrapper := filepath.Join(root, "codex-minimax") + wrapperText := "#!/bin/sh\nfor argument in \"$@\"; do\n if [ \"$argument\" = app-server ]; then printf '%s\\n' \"$$\" >> \"$PARSAR_PUBLIC_NATIVE_LAUNCH_LOG\"; fi\ndone\nexec \"$PARSAR_PUBLIC_NATIVE_CODEX\" -c 'model_provider=\"minimax_validation\"' -c 'model_providers.minimax_validation.name=\"MiniMax validation\"' -c 'model_providers.minimax_validation.base_url=\"https://api.minimax.cn/v1\"' -c 'model_providers.minimax_validation.env_key=\"MINIMAX_VALIDATION_KEY\"' -c 'model_providers.minimax_validation.wire_api=\"responses\"' \"$@\"\n" + if os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") == "" { + if err := os.WriteFile(wrapper, []byte(wrapperText), 0700); err != nil { + t.Fatal(err) + } + } + daemonEnvironment := publicNativeEnvironment(map[string]string{ + "PARSAR_HOME": root, "PARSAR_CODEX_BIN": wrapper, "PARSAR_PUBLIC_NATIVE_CODEX": nativeBinary, "MINIMAX_VALIDATION_KEY": key, + "PARSAR_PUBLIC_NATIVE_LAUNCH_LOG": filepath.Join(root, "native-starts"), + }) + fixture := &publicSelfHostedFixture{ + ctx: ctx, store: s, observer: observer, root: root, local: local, workspace: workspace, + serverBinary: serverBinary, daemonBinary: daemonBinary, version: strings.TrimSpace(string(version)), + tenant: tenant, sessionID: sessionID, environmentID: environmentID, deviceID: daemonDevice.ID, + remoteURL: waiting["remote_url"], container: container, reservation: reservation, executor: executor, + daemonEnvironment: daemonEnvironment, secrets: []string{key, caller, foreign, deviceToken, executor.Token}, + } + fixture.harness = newPublicHarnessProfile(t, fixture, nativeBinary, image, key) + return fixture +} + +func (f *publicSelfHostedFixture) startDaemon(t *testing.T) *relayProcess { + t.Helper() + if f.harness != nil { + return f.harness.start(t, f) + } + return startPublicNativeProcess(t, f.ctx, f.root, "daemon", f.daemonEnvironment, f.daemonBinary, "connect", "--profile", "execution") +} + +func (f *publicSelfHostedFixture) events(t *testing.T, turnID string) []proto.Envelope { + t.Helper() + var observed []proto.Envelope + var after int32 + for { + events, err := f.store.ListTurnEvents(f.ctx, f.tenant, f.sessionID, turnID, after, 100) + if err != nil { + t.Fatal(err) + } + if len(events) == 0 { + return observed + } + for _, event := range events { + observed = append(observed, proto.Envelope{Type: event.Kind, Payload: event.Payload}) + after = event.Ordinal + } + } +} diff --git a/services/agents-api/internal/store/self_hosted_public_functions_native_test.go b/services/agents-api/internal/store/self_hosted_public_functions_native_test.go new file mode 100644 index 000000000..8335457fe --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_functions_native_test.go @@ -0,0 +1,127 @@ +package store_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePublicSelfHostedFunctionsStandalone(t *testing.T) { + f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_functions_native.py") + f.startDaemon(t) + first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 180*time.Second) + binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { + t.Fatal("first function Turn lacks native device/history binding", err) + } + call, err := f.store.GetFunctionCall(f.ctx, f.tenant, f.sessionID, first["turn_id"], first["call_id"]) + if err != nil || !call.Applied || len(call.Result) == 0 { + t.Fatal("first public function result lacks a native application receipt", err) + } + firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + if err != nil || len(strings.Fields(string(firstStarts))) != 1 { + t.Fatal("first function Turn started more than one native harness") + } + awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) + select { + case <-f.observer.done: + f.observer.finish(t) + case <-f.ctx.Done(): + t.Fatal("public function continuation timed out; inspect private proof") + } + finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || finalBinding != binding { + t.Fatal("function continuation changed native history/device binding", err) + } + data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-functions-proof.json")) + if err != nil { + t.Fatal(err) + } + var publicProof struct { + Case string `json:"case"` + Status string `json:"status"` + Turns []struct { + ID string `json:"id"` + } `json:"turns"` + Calls []string `json:"calls"` + Submissions []struct { + Event map[string]any `json:"event"` + } `json:"accepted_results"` + } + if json.Unmarshal(data, &publicProof) != nil || publicProof.Case != "public_functions" || publicProof.Status != "public_functions_and_cold_continuation_verified" || len(publicProof.Turns) != 2 || len(publicProof.Calls) != 2 || len(publicProof.Submissions) != 2 || publicProof.Turns[0].ID != first["turn_id"] { + t.Fatal("public proof does not contain the two accepted function Turns") + } + settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) + if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { + t.Fatal("function results changed the original message reservation", err) + } + receipts := make([]store.FunctionCall, 0, 2) + for index, phase := range []string{"first", "resumed"} { + turnID := publicProof.Turns[index].ID + call, err := f.store.GetFunctionCall(f.ctx, f.tenant, f.sessionID, turnID, publicProof.Calls[index]) + if err != nil || !call.Applied || call.ExecutorCallID == "" || call.Name != "lookup_festival" { + t.Fatal("public function result was not acknowledged by the native adapter", err) + } + var arguments, result map[string]any + if json.Unmarshal(call.Arguments, &arguments) != nil || !reflect.DeepEqual(arguments, map[string]any{"phase": phase}) || json.Unmarshal(call.Result, &result) != nil { + t.Fatal("stored function arguments/result differ from the public call") + } + expected := publicProof.Submissions[index].Event + for _, field := range []string{"type", "turn_id", "call_id"} { + delete(expected, field) + } + if !reflect.DeepEqual(result, expected) { + t.Fatal("stored function result did not preserve the SDK submission") + } + receipts = append(receipts, call) + assertDaemonRemoteCommand(t, f.events(t, turnID), phase, f.workspace) + cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) + if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { + t.Fatal("function Turn command did not run in the executor-only workspace") + } + } + count, err := os.ReadFile(filepath.Join(f.local, "execution-count")) + if err != nil || string(count) != "first\nresumed\n" { + t.Fatal("function or input retry repeated or omitted real command execution") + } + retained, err := os.ReadFile(filepath.Join(f.local, "retained.txt")) + if err != nil || string(retained) != "remote-file-content\n" { + t.Fatal("remote file did not persist between function Turns") + } + if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { + t.Fatal("remote workspace appeared on the harness host") + } + if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("native command inherited transport/provider credentials") + } + starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + processes := strings.Fields(string(starts)) + if err != nil || len(processes) != 2 || processes[0] == processes[1] { + t.Fatal("function continuation did not cold-start exactly one fresh harness per Turn") + } + artifact, err := os.ReadFile(f.serverBinary) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(artifact) + proof := map[string]any{ + "status": "built_service_public_self_hosted_functions_verified", "case": "public_functions", "completed_turns": 2, + "session_id": f.sessionID, "environment_id": f.environmentID, "native_thread_id": binding.NativeSessionID, + "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_version": f.version, + "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, + "native_harness_processes": processes, "native_function_receipts": receipts, "command_execution_count": string(count), + "old_result_retry_did_not_retarget": true, "public_evidence": filepath.Join(f.observer.directory, "public-functions-proof.json"), + "limits": "One success mapping and one SDK-generated error through real native callbacks; no complete tool-set, image understanding or crash recovery claim.", + } + persistDaemonRemoteProof(t, f.root, proof, f.secrets) + t.Log("built standalone public self-hosted function real-provider evidence", f.root) +} diff --git a/services/agents-api/internal/store/self_hosted_public_helpers_test.go b/services/agents-api/internal/store/self_hosted_public_helpers_test.go new file mode 100644 index 000000000..6ae376459 --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_helpers_test.go @@ -0,0 +1,149 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func startPublicNativeProcess(t *testing.T, ctx context.Context, root, name string, environment []string, binary string, args ...string) *relayProcess { + t.Helper() + output, err := os.OpenFile(filepath.Join(root, name+".log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + t.Fatal(err) + } + command := exec.CommandContext(ctx, binary, args...) + command.Env, command.Dir = environment, root + command.Stdout, command.Stderr = output, output + if err := command.Start(); err != nil { + _ = output.Close() + t.Fatal(name, "failed to start", err) + } + process := &relayProcess{command: command, done: make(chan struct{})} + go func() { process.err = command.Wait(); _ = output.Close(); close(process.done) }() + t.Cleanup(func() { + _ = command.Process.Signal(os.Interrupt) + select { + case <-process.done: + case <-time.After(12 * time.Second): + _ = command.Process.Kill() + <-process.done + } + }) + return process +} + +func publicNativeAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return address +} + +func awaitPublicNativeServer(t *testing.T, ctx context.Context, process *relayProcess, base string) { + t.Helper() + client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} + defer client.CloseIdleConnections() + awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "built Agents API health", func() bool { + select { + case <-process.done: + t.Fatal("built Agents API exited; inspect private server log") + default: + } + response, err := client.Get(base + "/healthz") + if err != nil { + return false + } + _ = response.Body.Close() + return response.StatusCode == http.StatusOK + }) +} + +func writePublicNativeJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func startPublicNativeClientScript(t *testing.T, ctx context.Context, root, script string, settings map[string]string) *preparedPublicObserver { + t.Helper() + directory := filepath.Join(root, "public-environment") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + settings["evidence"] = directory + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + output, err := os.OpenFile(filepath.Join(directory, "observer.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + t.Fatal(err) + } + owner, cancel := context.WithCancel(ctx) + command := exec.CommandContext(owner, os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), filepath.Join("../../tests", script)) + command.Stdin, command.Stdout, command.Stderr = bytes.NewReader(input), output, output + if err := command.Start(); err != nil { + cancel() + _ = output.Close() + t.Fatal(err) + } + observer := &preparedPublicObserver{directory: directory, command: command, cancel: cancel, done: make(chan struct{})} + go func() { observer.err = command.Wait(); _ = output.Close(); close(observer.done) }() + t.Cleanup(observer.close) + return observer +} + +func awaitPublicNativeSignal(t *testing.T, ctx context.Context, observer *preparedPublicObserver, name string, timeout time.Duration) map[string]string { + t.Helper() + var value map[string]string + awaitDaemonRemoteCondition(t, ctx, timeout, "public client "+name, func() bool { + select { + case <-observer.done: + t.Fatal("public client exited before signal; inspect private client log", observer.err) + default: + } + data, err := os.ReadFile(filepath.Join(observer.directory, name+".json")) + if os.IsNotExist(err) { + return false + } + if err != nil || json.Unmarshal(data, &value) != nil { + t.Fatal("invalid public client signal") + } + return true + }) + return value +} + +func publicNativeEnvironment(overrides map[string]string) []string { + result := make([]string, 0, len(os.Environ())+len(overrides)) + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if _, replaced := overrides[name]; !replaced { + result = append(result, entry) + } + } + for name, value := range overrides { + result = append(result, name+"="+value) + } + return result +} diff --git a/services/agents-api/internal/store/self_hosted_public_native_test.go b/services/agents-api/internal/store/self_hosted_public_native_test.go new file mode 100644 index 000000000..b4f950e50 --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_native_test.go @@ -0,0 +1,113 @@ +package store_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePublicSelfHostedStandalone(t *testing.T) { + runNativePublicSelfHosted(t, "empty_later") +} + +func TestNativePublicSelfHostedOrdinaryInitialStandalone(t *testing.T) { + runNativePublicSelfHosted(t, "ordinary_initial") +} + +func TestNativePublicSelfHostedStreamedInitialStandalone(t *testing.T) { + runNativePublicSelfHosted(t, "streamed_initial") +} + +func runNativePublicSelfHosted(t *testing.T, mode string) { + t.Helper() + f := newPublicSelfHostedFixture(t, mode, "official_self_hosted.py") + f.startDaemon(t) + first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 180*time.Second) + binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { + t.Fatal("first public Turn lacks native device/history binding", err) + } + firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + if err != nil || len(strings.Fields(string(firstStarts))) != 1 { + t.Fatal("first input retry started another native harness") + } + awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) + select { + case <-f.observer.done: + f.observer.finish(t) + case <-f.ctx.Done(): + t.Fatal("public second Turn timed out; inspect private proof") + } + finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || finalBinding != binding { + t.Fatal("public continuation changed native history/device binding", err) + } + data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-environment-proof.json")) + if err != nil { + t.Fatal(err) + } + var publicProof struct { + CreationMode string `json:"creation_mode"` + Turns []struct { + ID string `json:"id"` + } `json:"turns"` + } + if json.Unmarshal(data, &publicProof) != nil || publicProof.CreationMode != mode || len(publicProof.Turns) != 2 || publicProof.Turns[0].ID != first["turn_id"] { + t.Fatal("public proof does not contain the two accepted Turns") + } + settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) + if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial != f.reservation.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { + t.Fatal("public retries changed original reservation origin/deadline", err) + } + for index, phase := range []string{"first", "resumed"} { + observed := f.events(t, publicProof.Turns[index].ID) + assertDaemonRemoteCommand(t, observed, phase, f.workspace) + cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) + if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { + t.Fatal("actual command did not run in the executor-only workspace") + } + } + count, err := os.ReadFile(filepath.Join(f.local, "execution-count")) + if err != nil || string(count) != "first\nresumed\n" { + t.Fatal("public retry repeated or omitted real command execution") + } + retained, err := os.ReadFile(filepath.Join(f.local, "retained.txt")) + if err != nil || string(retained) != "remote-file-content\n" { + t.Fatal("remote file did not persist between public Turns") + } + if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { + t.Fatal("remote workspace appeared on the harness host") + } + if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("native command inherited transport/provider credentials") + } + starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + processes := strings.Fields(string(starts)) + if err != nil || len(processes) != 2 || processes[0] == processes[1] { + t.Fatal("continuation did not cold-start exactly one fresh harness per Turn") + } + artifact, err := os.ReadFile(f.serverBinary) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(artifact) + proof := map[string]any{ + "status": "built_service_public_self_hosted_real_execution_verified", "session_id": f.sessionID, "environment_id": f.environmentID, + "creation_mode": mode, "reservation_id": f.reservation.ID, "reservation_initial": f.reservation.IsInitial, "reservation_deadline": f.reservation.Deadline, + "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_thread_id": binding.NativeSessionID, + "native_version": f.version, "completed_turns": 2, "launcher_remote_url": f.remoteURL, + "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, "executor_key_issued_before_session": true, + "already_connected_second_input": true, "command_execution_count": string(count), "public_evidence": filepath.Join(f.observer.directory, "public-environment-proof.json"), + "native_harness_processes": processes, + } + persistDaemonRemoteProof(t, f.root, proof, f.secrets) + t.Log("built standalone public self-hosted real-provider evidence", f.root) +} diff --git a/services/agents-api/internal/store/self_hosted_public_steering_native_test.go b/services/agents-api/internal/store/self_hosted_public_steering_native_test.go new file mode 100644 index 000000000..b1f3179a8 --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_public_steering_native_test.go @@ -0,0 +1,196 @@ +package store_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestNativePublicSelfHostedSteeringStandalone(t *testing.T) { + f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_steering_native.py") + const gate = `#!/bin/sh +set -eu +phase="$1" +printf '%s\n' "$phase" >> gate-start-count +remaining=90 +while [ ! -f "$phase.release" ]; do + test "$remaining" -gt 0 || { printf 'fixture gate timed out\n' >&2; exit 94; } + date +%s > "$phase.heartbeat" + remaining=$((remaining - 1)) + sleep 1 +done +exec ./placement.sh "$phase" +` + if err := os.WriteFile(filepath.Join(f.local, "gate.sh"), []byte(gate), 0700); err != nil { + t.Fatal(err) + } + f.startDaemon(t) + t.Cleanup(func() { + for _, phase := range []string{"first", "resumed"} { + _ = os.WriteFile(filepath.Join(f.local, phase+".release"), []byte("cleanup\n"), 0600) + } + }) + awaitHeartbeat := func(phase string) { + t.Helper() + awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual "+phase+" command heartbeat", func() bool { + select { + case <-f.observer.done: + t.Fatal("public steering client exited before native gate; inspect private proof") + default: + } + _, err := os.Stat(filepath.Join(f.local, phase+".heartbeat")) + return err == nil + }) + } + releaseGate := func(phase string) { + t.Helper() + if err := os.WriteFile(filepath.Join(f.local, phase+".release"), []byte("continue\n"), 0600); err != nil { + t.Fatal(err) + } + } + awaitHeartbeat("first") + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "steer-ready.json"), map[string]string{"environment_id": f.environmentID}) + submitted := awaitPublicNativeSignal(t, f.ctx, f.observer, "steer-submitted", 30*time.Second) + firstID := submitted["turn_id"] + inputs, err := f.store.ListTurnInputs(f.ctx, f.tenant, f.sessionID, firstID, 0, 100) + if err != nil || len(inputs) != 2 || inputs[1].Kind != "message" || submitted["value"] == "" || !strings.Contains(string(inputs[1].Payload), submitted["value"]) { + t.Fatal("active public input did not target the original Turn exactly once", err) + } + sequence := inputs[1].Sequence + var releaseReceipt proto.PromptSteerAckPayload + awaitDaemonRemoteCondition(t, f.ctx, 30*time.Second, "actual native steering write", func() bool { + for _, event := range f.events(t, firstID) { + if event.Type == proto.TypePromptSteerAck { + var receipt proto.PromptSteerAckPayload + if event.DecodePayload(&receipt) != nil { + t.Fatal("invalid native steering receipt") + } + if receipt.InputID == strconv.FormatInt(sequence, 10) && (receipt.Written || receipt.Accepted) { + releaseReceipt = receipt + return true + } + } + } + return false + }) + // Written releases this test gate only; final acceptance below requires native Accepted. + releaseGate("first") + first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 150*time.Second) + if first["turn_id"] != firstID { + t.Fatal("steering completed a different public Turn") + } + completed, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, firstID) + var outcome execution.Result + if err != nil || completed.Status != store.TurnCompleted || json.Unmarshal(completed.Outcome, &outcome) != nil || outcome.AppliedThrough != sequence { + t.Fatal("completed Turn did not retain native steering application", err) + } + var applied []proto.PromptSteerAckPayload + for _, event := range f.events(t, firstID) { + if event.Type == proto.TypePromptSteerAck { + var receipt proto.PromptSteerAckPayload + if event.DecodePayload(&receipt) != nil || receipt.InputID != strconv.FormatInt(sequence, 10) { + t.Fatal("native steering receipt has the wrong input identity") + } + if receipt.Accepted { + applied = append(applied, receipt) + } + } + } + if len(applied) != 1 || applied[0].ErrorCode != "" { + t.Fatal("active input lacks exactly one actual native acceptance receipt") + } + binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { + t.Fatal("steered Turn lacks native device/history binding", err) + } + firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + if err != nil || len(strings.Fields(string(firstStarts))) != 1 { + t.Fatal("steering started another harness instead of using the active one") + } + awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) + awaitHeartbeat("resumed") + writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resumed-active.json"), map[string]string{"session_id": f.sessionID}) + second := awaitPublicNativeSignal(t, f.ctx, f.observer, "old-steer-retried", 30*time.Second) + secondID := second["turn_id"] + active, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, secondID) + if err != nil || active.Status != store.TurnInProgress || secondID == firstID { + t.Fatal("old active-input retry changed later execution", err) + } + secondInputs, err := f.store.ListTurnInputs(f.ctx, f.tenant, f.sessionID, secondID, 0, 100) + if err != nil || len(secondInputs) != 1 || strings.Contains(string(secondInputs[0].Payload), submitted["value"]) { + t.Fatal("old active input was replayed into the cold Turn", err) + } + releaseGate("resumed") + select { + case <-f.observer.done: + f.observer.finish(t) + case <-f.ctx.Done(): + t.Fatal("public steering continuation timed out; inspect private proof") + } + finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) + if err != nil || finalBinding != binding { + t.Fatal("cold steering continuation changed native history binding", err) + } + for _, event := range f.events(t, secondID) { + if event.Type == proto.TypePromptSteerAck { + t.Fatal("old active-input retry reached the later native Turn") + } + } + settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) + if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { + t.Fatal("active inputs changed the original reservation", err) + } + for index, phase := range []string{"first", "resumed"} { + assertDaemonRemoteCommand(t, f.events(t, []string{firstID, secondID}[index]), phase, f.workspace) + cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) + if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { + t.Fatal("real command did not use the executor-only workspace") + } + } + for name, want := range map[string]string{"execution-count": "first\nresumed\n", "gate-start-count": "first\nresumed\n", "retained.txt": "remote-file-content\n"} { + value, err := os.ReadFile(filepath.Join(f.local, name)) + if err != nil || string(value) != want { + t.Fatal("real steering/continuation side effects differ", name, err) + } + } + if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { + t.Fatal("remote workspace appeared on the harness host") + } + if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { + t.Fatal("native command inherited transport/provider credentials") + } + starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) + processes := strings.Fields(string(starts)) + if err != nil || len(processes) != 2 || processes[0] == processes[1] { + t.Fatal("steering continuation did not use exactly one fresh harness per Turn") + } + artifact, err := os.ReadFile(f.serverBinary) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(artifact) + proof := map[string]any{ + "status": "built_service_public_self_hosted_steering_verified", "case": "public_steering", "completed_turns": 2, + "session_id": f.sessionID, "environment_id": f.environmentID, "native_thread_id": binding.NativeSessionID, + "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_version": f.version, + "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, + "native_harness_processes": processes, "gate_release_receipt": releaseReceipt, "native_accepted_receipts": applied, + "steered_turn_id": firstID, "input_sequence": sequence, "applied_through": outcome.AppliedThrough, + "old_input_did_not_retarget": true, "command_execution_count": "first\nresumed\n", + "public_evidence": filepath.Join(f.observer.directory, "public-steering-proof.json"), + "limits": "Written only releases the fixture gate. Accepted, applied cursor and model answers establish this workflow; no general crash recovery or OS-quiescence claim.", + } + persistDaemonRemoteProof(t, f.root, proof, f.secrets) + t.Log("built standalone public self-hosted steering real-provider evidence", f.root) +} diff --git a/services/agents-api/internal/store/self_hosted_steering_public_test.go b/services/agents-api/internal/store/self_hosted_steering_public_test.go new file mode 100644 index 000000000..6b3ae9c60 --- /dev/null +++ b/services/agents-api/internal/store/self_hosted_steering_public_test.go @@ -0,0 +1,176 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSelfHostedSteeringOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "steering-caller", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "steering-caller", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + worker, _ := publicInitialWorker(t, s) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://offline-executor.example")) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + settings := map[string]any{"base": server.URL, "token": token, "foreign_token": foreign} + run := func(phase string) json.RawMessage { + t.Helper() + settings["phase"] = phase + input, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_steering.py") + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("public self-hosted steering %s: %v %s", phase, err, output) + } + if !json.Valid(output) { + t.Fatal("invalid public steering fixture result") + } + return output + } + accepted := run("create") + var created struct { + ID string `json:"id"` + InitialID string `json:"initial_id"` + LaterID string `json:"later_id"` + BatchKey string `json:"batch_key"` + PendingKey string `json:"pending_key"` + IdleKey string `json:"idle_key"` + RollbackKey string `json:"rollback_key"` + Batch []json.RawMessage `json:"batch"` + PendingEvent json.RawMessage `json:"pending_event"` + } + if err := json.Unmarshal(accepted, &created); err != nil || created.ID == "" || len(created.Batch) != 2 { + t.Fatal("missing public steering fixture identities", err) + } + settings["accepted"] = accepted + inputs := []store.Input{{Kind: "message", Payload: created.Batch[0]}, {Kind: "message", Payload: created.Batch[1]}} + if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, created.PendingKey, []store.Input{{Kind: "message", Payload: created.PendingEvent}}); err != nil { + t.Fatal(err) + } + snapshot := func(sessionID string) string { + t.Helper() + var value string + err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object( + 'session', (SELECT to_jsonb(s) FROM sessions s WHERE id=$1), + 'reservations', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM environment_input_reservations r WHERE session_id=$1), + 'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM turns t WHERE session_id=$1), + 'inputs', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sequence) FROM turn_inputs i WHERE session_id=$1), + 'items', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.id) FROM session_items i WHERE session_id=$1), + 'events', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM session_events e WHERE session_id=$1))::text`, sessionID).Scan(&value) + if err != nil { + t.Fatal(err) + } + return value + } + pending := map[string]string{created.InitialID: snapshot(created.InitialID), created.LaterID: snapshot(created.LaterID)} + transition := func(turn, from, to string) { + t.Helper() + if _, err := s.TransitionTurn(t.Context(), tenant, created.ID, turn, store.TurnTransition{ExpectedStatus: from, Status: to}); err != nil { + t.Fatal(err) + } + } + start := func() string { + t.Helper() + // Controlled Turn callbacks isolate admission from native application and model behavior. + input, err := s.SubmitMessage(t.Context(), tenant, created.ID, uuid.NewString(), json.RawMessage(`{"text":"Controlled original work."}`)) + if err != nil { + t.Fatal(err) + } + transition(input.TurnID, store.TurnQueued, store.TurnInProgress) + settings["turn_id"] = input.TurnID + return input.TurnID + } + first := start() + run("active") + direct, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.ID, created.BatchKey, inputs) + if err != nil || direct.State != store.EnvironmentInputAdmitted || direct.ID != "" || !direct.Deadline.IsZero() || len(direct.Receipts) != 2 || direct.Receipts[0].TurnID != first || !direct.Receipts[0].Replayed { + t.Fatal("active batch acquired a reservation or changed its direct receipt", direct, err) + } + history, err := s.ListTurnInputs(t.Context(), tenant, created.ID, first, 0, 100) + if err != nil || len(history) != 3 || history[1].Sequence != direct.Receipts[0].Sequence || history[2].Sequence != direct.Receipts[1].Sequence { + t.Fatal("concurrent active batch duplicated or reordered inputs", err) + } + var reservations int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", created.ID).Scan(&reservations); err != nil || reservations != 0 { + t.Fatal("active text created a preparation reservation", err) + } + before := snapshot(created.ID) + run("reject") + if snapshot(created.ID) != before { + t.Fatal("rejected steering changed history or retry identity") + } + constraint := "steering_failure_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err := pool.Exec(t.Context(), "ALTER TABLE turn_inputs ADD CONSTRAINT "+constraint+" CHECK (idempotency_key <> '"+created.RollbackKey+"' OR batch_position=0)"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE turn_inputs DROP CONSTRAINT IF EXISTS "+constraint) + }) + run("rollback") + if snapshot(created.ID) != before { + t.Fatal("second-insert failure left partial text, Items or events") + } + if _, err := pool.Exec(t.Context(), "ALTER TABLE turn_inputs DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + transition(first, store.TurnInProgress, store.TurnCompleted) + for _, phase := range []string{"terminal", "later"} { + if phase == "later" { + start() + } + before := snapshot(created.ID) + run(phase) + retry, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.ID, created.BatchKey, inputs) + if err != nil || !reflect.DeepEqual(direct, retry) || snapshot(created.ID) != before { + t.Fatal("active text retry retargeted work or changed its direct identity", err) + } + } + transition(settings["turn_id"].(string), store.TurnInProgress, store.TurnCompleted) + run("idle") + var id string + if err := pool.QueryRow(t.Context(), "SELECT id FROM environment_input_reservations WHERE session_id=$1 AND idempotency_key=$2", created.ID, created.IdleKey).Scan(&id); err != nil { + t.Fatal("idle input did not wait for preparation", err) + } + idle, err := s.GetEnvironmentInputReservation(t.Context(), tenant, created.ID, id) + if err != nil || idle.State != store.EnvironmentInputPending || len(idle.Receipts) != 0 || idle.Deadline.Sub(idle.CreatedAt) != 5*time.Minute { + t.Fatal("idle input bypassed preparation", idle, err) + } + for id, expected := range pending { + if snapshot(id) != expected { + t.Fatal("steering changed pending input, deadline or foreign history") + } + } +} diff --git a/services/agents-api/internal/store/session_agent_filter_public_test.go b/services/agents-api/internal/store/session_agent_filter_public_test.go new file mode 100644 index 000000000..1feb7767b --- /dev/null +++ b/services/agents-api/internal/store/session_agent_filter_public_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSessionAgentFilterOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + h, err = api.NewHandler(store.New(pool), auth, "codex") + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(h) + defer recovered.Close() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, python, "../../tests/official_session_agent_filter.py", server.URL, token, foreign, recovered.URL) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("official Session Agent filtering: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/agents-api/internal/store/session_agent_filter_test.go b/services/agents-api/internal/store/session_agent_filter_test.go new file mode 100644 index 000000000..841dfaa21 --- /dev/null +++ b/services/agents-api/internal/store/session_agent_filter_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestSessionAgentFilterPaginationAndIsolation(t *testing.T) { + s, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + root := "agent_inline-root" + var expected []string + create := func(tenant, key, agent string) Session { + configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"id": agent, "model": "test-model"}, "environment": map[string]string{"type": "none"}}) + value, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: key, Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + return value + } + for i := range 9 { + agent := "other-agent" + if i%2 == 0 { + agent = root + } + value := create(tenant, fmt.Sprint(i), agent) + if agent == root { + expected = append(expected, value.ID) + } + } + other := create(foreign, "foreign", root) + if _, err := pool.Exec(t.Context(), "UPDATE sessions SET created_at=$1 WHERE tenant_id=$2", time.Unix(1700000000, 0), tenant); err != nil { + t.Fatal(err) + } + slices.Sort(expected) + read := func(s *Store, ascending bool) []string { + var got []string + cursor := "" + for { + page, err := s.ListSessions(t.Context(), tenant, cursor, 2, ascending, &root) + if err != nil { + t.Fatal(err) + } + if len(page.Sessions) == 0 || len(page.Sessions) > 2 { + t.Fatal(page) + } + for _, value := range page.Sessions { + if value.TenantID != tenant { + t.Fatal("foreign Session", value.ID) + } + got = append(got, value.ID) + } + if page.NextCursor == "" { + return got + } + if page.NextCursor != page.Sessions[len(page.Sessions)-1].ID || len(got) > len(expected) { + t.Fatal("invalid continuation", page) + } + cursor = page.NextCursor + } + } + if got := read(s, true); !slices.Equal(got, expected) { + t.Fatal(got, expected) + } + reverse := slices.Clone(expected) + slices.Reverse(reverse) + if got := read(s, false); !slices.Equal(got, reverse) { + t.Fatal(got, reverse) + } + unfiltered, err := s.ListSessions(t.Context(), tenant, "", 100, false, nil) + if err != nil || len(unfiltered.Sessions) != 9 { + t.Fatal(unfiltered, err) + } + for _, id := range []string{"", "unknown", root + " ", "' OR true --"} { + page, err := s.ListSessions(t.Context(), tenant, "", 100, false, &id) + if err != nil || page.Sessions == nil || len(page.Sessions) != 0 || page.NextCursor != "" { + t.Fatal(page, err) + } + } + if _, err := s.ListSessions(t.Context(), tenant, other.ID, 2, true, &root); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + page, err := s.ListSessions(t.Context(), foreign, "", 100, false, &root) + if err != nil || len(page.Sessions) != 1 || page.Sessions[0].ID != other.ID { + t.Fatal(page, err) + } + pool.Close() + restored, _ := testStore(t) + if got := read(restored, true); !slices.Equal(got, expected) { + t.Fatal(got, expected) + } +} diff --git a/services/agents-api/internal/store/session_artifacts.go b/services/agents-api/internal/store/session_artifacts.go new file mode 100644 index 000000000..3c5866588 --- /dev/null +++ b/services/agents-api/internal/store/session_artifacts.go @@ -0,0 +1,159 @@ +package store + +import ( + "context" + "errors" + "io" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type SessionArtifact struct { + ID string + SessionID string + TurnID string + EnvironmentID string + Path string + SizeBytes int64 + CreatedAt time.Time +} + +type ArtifactPage struct { + Artifacts []SessionArtifact + NextCursor string +} + +func (s *Store) GetSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) (SessionArtifact, error) { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return SessionArtifact{}, err + } + row, err := s.queries.GetSessionArtifact(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return SessionArtifact{}, ErrNotFound + } + return artifactFromRow(row), err +} + +func (s *Store) ListSessionArtifacts(ctx context.Context, tenantID, sessionID, environmentID, cursor string, limit int, ascending bool) (ArtifactPage, error) { + if limit < 1 || limit > 100 { + return ArtifactPage{}, ErrInvalidInput + } + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return ArtifactPage{}, err + } + tenant, _ := parseID(tenantID) + session, _ := parseID(sessionID) + params := sqlc.ListSessionArtifactsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} + if environmentID != "" { + var err error + params.EnvironmentID, err = parseID(environmentID) + if err != nil { + return ArtifactPage{}, err + } + } + if cursor != "" { + after, err := s.GetSessionArtifact(ctx, tenantID, sessionID, cursor) + if err != nil { + return ArtifactPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListSessionArtifacts(ctx, params) + if err != nil { + return ArtifactPage{}, err + } + page := ArtifactPage{Artifacts: make([]SessionArtifact, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + page.Artifacts = append(page.Artifacts, artifactFromRow(row)) + } + return page, nil +} + +// ReadSessionArtifact keeps an admitted snapshot available across concurrent deletion. +func (s *Store) ReadSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string, consume func(SessionArtifact, io.Reader) error) error { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return err + } + if consume == nil { + return ErrInvalidInput + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + row, err := s.queries.WithTx(tx).GetSessionArtifact(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) + if err != nil { + return err + } + if err := consume(artifactFromRow(row), body); err != nil { + return err + } + if err := body.Close(); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) DeleteSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) error { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + q := s.queries.WithTx(tx) + // Use the same lock order as whole-Session deletion and Turn publication. + locked, err := q.LockSession(ctx, sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}) + if errors.Is(err, pgx.ErrNoRows) || err == nil && locked.DeletedAt.Valid { + return ErrNotFound + } + if err != nil { + return err + } + oid, err := q.DeleteSessionArtifact(ctx, sqlc.DeleteSessionArtifactParams(lookup)) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + if err := objects.Unlink(ctx, oid.Uint32); err != nil { + return err + } + return tx.Commit(ctx) +} + +func artifactLookup(tenantID, sessionID, artifactID string) (sqlc.GetSessionArtifactParams, error) { + ids, err := turnLookup(tenantID, sessionID, artifactID) + return sqlc.GetSessionArtifactParams{TenantID: ids.TenantID, SessionID: ids.SessionID, ID: ids.ID}, err +} + +func artifactFromRow(row sqlc.SessionArtifact) SessionArtifact { + return SessionArtifact{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), + TurnID: uuid.UUID(row.TurnID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), + Path: row.Path, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/agents-api/internal/store/session_artifacts_test.go b/services/agents-api/internal/store/session_artifacts_test.go new file mode 100644 index 000000000..2884295de --- /dev/null +++ b/services/agents-api/internal/store/session_artifacts_test.go @@ -0,0 +1,261 @@ +package store + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "io" + "reflect" + "testing" + "time" + + "github.com/google/uuid" +) + +func artifactArchive(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var data bytes.Buffer + w := tar.NewWriter(&data) + for name, body := range files { + if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := w.Write(body); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func artifactTurn(t *testing.T, s *Store, kind string) (tenant, session, environment, turn string) { + t.Helper() + tenant = uuid.NewString() + created, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact", kind, "/workspace")) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) + if err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, created.ID, "artifact-turn") + transition(t, s, tenant, created.ID, input.TurnID, TurnQueued, TurnInProgress) + return tenant, created.ID, env.ID, input.TurnID +} + +func TestSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + data := bytes.Repeat([]byte("immutable\x00"), 100000) + archive := artifactArchive(t, map[string][]byte{"outputs/a.bin": data, "outputs/nested/empty": {}}) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || len(page.Artifacts) != 0 { + t.Fatalf("private capture visible: %+v %v", page, err) + } + completed := transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + page, err = s.ListSessionArtifacts(t.Context(), tenant, session, environment, "", 100, true) + if err != nil || len(page.Artifacts) != 2 { + t.Fatalf("published capture: %+v %v", page, err) + } + for _, a := range page.Artifacts { + if a.SessionID != session || a.TurnID != turn || a.EnvironmentID != environment || !a.CreatedAt.Equal(completed.CompletedAt) { + t.Fatalf("publication metadata: %+v", a) + } + foreign := uuid.NewString() + if _, err := s.GetSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign metadata: %v", err) + } + if _, err := s.GetSessionArtifact(t.Context(), tenant, uuid.NewString(), a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong session metadata: %v", err) + } + if err := s.DeleteSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete: %v", err) + } + if err := s.ReadSessionArtifact(t.Context(), foreign, session, a.ID, func(SessionArtifact, io.Reader) error { + t.Error("foreign read reached content") + return nil + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign read: %v", err) + } + } + if _, err := s.ListSessionArtifacts(t.Context(), uuid.NewString(), session, "", "", 100, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign list: %v", err) + } + if empty, err := s.ListSessionArtifacts(t.Context(), tenant, session, uuid.NewString(), "", 100, false); err != nil || len(empty.Artifacts) != 0 { + t.Fatalf("environment filter: %+v %v", empty, err) + } + // A later completed Turn publishes another immutable version of the same path. + next := submitMessage(t, s, tenant, session, "version-two") + transition(t, s, tenant, session, next.TurnID, TurnQueued, TurnInProgress) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, next.TurnID, environment, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/a.bin": []byte("new")}))); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, next.TurnID, TurnInProgress, TurnCompleted) + all, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || len(all.Artifacts) != 3 { + t.Fatal(all, err) + } + for _, asc := range []bool{true, false} { + var got []SessionArtifact + cursor := "" + for { + part, err := s.ListSessionArtifacts(t.Context(), tenant, session, environment, cursor, 1, asc) + if err != nil { + t.Fatal(err) + } + got = append(got, part.Artifacts...) + if part.NextCursor == "" { + break + } + if len(got) > 3 { + t.Fatal("pagination repeated artifacts") + } + cursor = part.NextCursor + } + want := append([]SessionArtifact(nil), all.Artifacts...) + if !asc { + want[0], want[2] = want[2], want[0] + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ordered pages differ: %+v %+v", got, want) + } + } + // Expiration is a controlled fixture; stored reads must not touch Runtime. + if _, err := pool.Exec(t.Context(), "UPDATE environments SET status='expired' WHERE id=$1", environment); err != nil { + t.Fatal(err) + } + for _, a := range page.Artifacts { + if err := New(pool).ReadSessionArtifact(t.Context(), tenant, session, a.ID, func(meta SessionArtifact, r io.Reader) error { + if err := s.DeleteSessionArtifact(t.Context(), tenant, session, a.ID); err != nil { + return err + } + body, err := io.ReadAll(r) + want := data + if a.Path == "/workspace/outputs/nested/empty" { + want = nil + } + if meta != a || !bytes.Equal(body, want) { + t.Error("expired/deleted artifact damaged admitted snapshot") + } + return err + }); err != nil { + t.Fatal(err) + } + if _, err := s.GetSessionArtifact(t.Context(), tenant, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted metadata retained: %v", err) + } + } + if err := s.DeleteSession(t.Context(), tenant, session); err != nil { + t.Fatal(err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("objects leaked: %d -> %d", before, count) + } +} + +type artifactReadError struct{} + +func (artifactReadError) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + valid := artifactArchive(t, map[string][]byte{"outputs/a": []byte("data")}) + for name, body := range map[string]io.Reader{ + "transport-failure-after-valid-tar": io.MultiReader(bytes.NewReader(valid), artifactReadError{}), + "truncated-body": bytes.NewReader(valid[:513]), + "trailing-data": io.MultiReader(bytes.NewReader(valid), bytes.NewReader([]byte("not archive padding"))), + "traversal": bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/../secret": []byte("no")})), + "private-root": bytes.NewReader(artifactArchive(t, map[string][]byte{"secrets/key": []byte("no")})), + } { + t.Run(name, func(t *testing.T) { + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, body); err == nil { + t.Fatal("invalid capture accepted") + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("rollback leaked objects: %d -> %d", before, count) + } + }) + } + for _, ids := range [][4]string{{uuid.NewString(), session, turn, environment}, {tenant, session, turn, uuid.NewString()}} { + if err := s.StageTurnArtifacts(t.Context(), ids[0], ids[1], ids[2], ids[3], artifactReadError{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("unauthorized capture reached reader: %v", err) + } + } + st, ss, se, sr := artifactTurn(t, s, "self_hosted") + if err := s.StageTurnArtifacts(t.Context(), st, ss, sr, se, artifactReadError{}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("self_hosted publication allowed: %v", err) + } +} + +func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { + for _, status := range []string{TurnFailed, TurnCancelled} { + t.Run(status, func(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + body := artifactArchive(t, map[string][]byte{"outputs/a": []byte("private")}) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, turn, TurnInProgress, status) + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("terminal capture leaked objects: %d -> %d", before, count) + } + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("late capture accepted: %v", err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("late capture leaked objects: %d -> %d", before, count) + } + }) + } +} + +func TestSessionArtifactTransferDoesNotBlockDeletionOrCancellation(t *testing.T) { + for _, operation := range []string{"delete", "cancel"} { + t.Run(operation, func(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + result := make(chan error, 1) + go func() { result <- s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, reader) }() + // A complete TAR arrives, but transport has not acknowledged success yet. + if _, err := writer.Write(artifactArchive(t, map[string][]byte{"outputs/a": []byte("partial")})); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + want := ErrNotFound + if operation == "delete" { + if err := s.DeleteSession(ctx, tenant, session); err != nil { + t.Fatalf("transfer blocked deletion: %v", err) + } + } else { + if _, err := s.RequestCancel(ctx, tenant, session, "cancel-capture"); err != nil { + t.Fatalf("transfer blocked cancellation: %v", err) + } + want = ErrTurnConflict + } + writer.Close() + if err := <-result; !errors.Is(err, want) { + t.Fatalf("late publication after %s: %v", operation, err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("late capture leaked objects: %d -> %d", before, count) + } + }) + } +} diff --git a/services/agents-api/internal/store/session_configuration_test.go b/services/agents-api/internal/store/session_configuration_test.go new file mode 100644 index 000000000..819b489ae --- /dev/null +++ b/services/agents-api/internal/store/session_configuration_test.go @@ -0,0 +1,115 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestConfigurationCanonicalization(t *testing.T) { + input := ` {"environment":{"type":"none"},"agent":{"revision":9007199254740993,"model":"example"}} ` + want := `{"agent":{"model":"example","revision":9007199254740993},"environment":{"type":"none"}}` + got, err := canonicalJSONObject([]byte(input)) + if err != nil || string(got) != want { + t.Fatalf("canonical = %s, %v; want %s", got, err, want) + } + for _, raw := range []string{`null`, `[]`, `"text"`, `{} {}`, `{`} { + if _, err := canonicalJSONObject([]byte(raw)); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid JSON object accepted (length %d): %v", len(raw), err) + } + } +} + +func TestConfigurationSizeLimitSurvivesJSONBRoundTrip(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + empty := `{"agent":{"model":"example","instructions":""},"environment":{"type":"none"}}` + raw := strings.Replace(empty, `"instructions":""`, `"instructions":"`+strings.Repeat("x", 512*1024-len(empty))+`"`, 1) + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "size-limit", Configuration: []byte(raw)} + first, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + got, err := s.GetSession(ctx, tenant, first.ID) + if err != nil || string(got.Configuration) != string(first.Configuration) { + t.Fatalf("configuration failed round trip: %v", err) + } + page, err := s.ListSessions(ctx, tenant, "", 10, false, nil) + if err != nil || len(page.Sessions) != 1 || page.Sessions[0].ID != first.ID { + t.Fatalf("configuration broke listing: %v", err) + } + input.Configuration = append(input.Configuration, ' ') + if _, err := s.CreateSession(ctx, tenant, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("oversized request accepted: %v", err) + } +} + +func TestConfigurationIsPartOfSessionIdentity(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "configured", + Configuration: []byte(`{"agent":{"model":"example","instructions":"First"},"environment":{"type":"none"}}`)} + first, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + input.Configuration = []byte(`{"environment": {"type":"none"}, "agent":{"instructions":"First", "model":"example"}}`) + replay, err := s.CreateSession(ctx, tenant, input) + if err != nil || replay.ID != first.ID || string(replay.Configuration) != string(first.Configuration) { + t.Fatalf("equivalent configuration changed identity: %+v, %v", replay, err) + } + for _, configuration := range []string{ + `{"agent":{"model":"different","instructions":"First"},"environment":{"type":"none"}}`, + `{"agent":{"model":"example","instructions":"Changed"},"environment":{"type":"none"}}`, + `{"agent":{"model":"example","instructions":"First"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`, + } { + input.Configuration = []byte(configuration) + if _, err := s.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed snapshot was accepted: %v", err) + } + } + stored, err := s.GetSession(ctx, tenant, first.ID) + if err != nil || string(stored.Configuration) != string(first.Configuration) { + t.Fatalf("retry mutated snapshot: %+v, %v", stored, err) + } +} + +func TestOriginalSessionHashRespectsRecordedCreator(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + legacyHash := sha256.Sum256([]byte(`{"Engine":"codex","Metadata":{}}`)) + for _, known := range []bool{false, true} { + tenant, id := uuid.NewString(), uuid.NewString() + var kind, creatorID any + if known { + kind, creatorID = FixtureCreator().Kind, FixtureCreator().ID + } + // Seed each ownership state explicitly; neither the migration nor a retry assigns it. + _, err := pool.Exec(ctx, `INSERT INTO sessions (id, tenant_id, engine, metadata, idempotency_key, request_hash, creator_kind, creator_id) + VALUES ($1, $2, 'codex', '{}', 'legacy', $3, $4, $5)`, id, tenant, hex.EncodeToString(legacyHash[:]), kind, creatorID) + if err != nil { + t.Fatal(err) + } + for _, configuration := range [][]byte{nil, []byte(`{}`), []byte(` { } `)} { + got, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy", Configuration: configuration}) + if !known { + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("unknown historical creator was claimed", err) + } + } else if err != nil || got.ID != id || string(got.Configuration) != "{}" || got.Creator == nil || *got.Creator != FixtureCreator() { + t.Fatalf("original hash retry = %+v, %v", got, err) + } + } + read, err := s.GetSession(ctx, tenant, id) + if err != nil || (read.Creator != nil) != known { + t.Fatal("retry changed recorded creator", read, err) + } + } +} diff --git a/services/agents-api/internal/store/session_creation_identity.go b/services/agents-api/internal/store/session_creation_identity.go new file mode 100644 index 000000000..ae58c48af --- /dev/null +++ b/services/agents-api/internal/store/session_creation_identity.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { + if len(raw) == 0 { + return pgtype.Text{}, nil + } + if len(raw) > 16<<20 { + return pgtype.Text{}, ErrInvalidInput + } + canonical, err := canonicalJSONObject(raw) + if err != nil { + return pgtype.Text{}, err + } + hash := sha256.Sum256(canonical) + return pgtype.Text{String: hex.EncodeToString(hash[:]), Valid: true}, nil +} + +// FindSessionCreation recovers recorded caller intent without resolving a mutable source. +func (s *Store) FindSessionCreation(ctx context.Context, tenantID, key string, request json.RawMessage, creator identity.Subject) (SessionCreation, error) { + if err := creator.Validate(); err != nil { + return SessionCreation{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + tenant, err := parseID(tenantID) + if err != nil { + return SessionCreation{}, err + } + if strings.TrimSpace(key) == "" || len(key) > 128 { + return SessionCreation{}, ErrInvalidInput + } + hash, err := creationRequestHash(request) + if err != nil { + return SessionCreation{}, err + } + if !hash.Valid { + return SessionCreation{}, ErrInvalidInput + } + var row sqlc.Session + var environment *Environment + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + row, err = q.FindSessionCreation(ctx, sqlc.FindSessionCreationParams{TenantID: tenant, IdempotencyKey: key}) + if err != nil { + return err + } + environment, err = sessionEnvironmentSnapshot(ctx, q, row) + return err + }) + if errors.Is(err, pgx.ErrNoRows) { + return SessionCreation{}, ErrNotFound + } + if err != nil { + return SessionCreation{}, fmt.Errorf("find session creation: %w", err) + } + if row.DeletedAt.Valid || !row.CreatorKind.Valid || !row.CreatorID.Valid || row.CreatorKind.String != creator.Kind || row.CreatorID.String != creator.ID { + return SessionCreation{}, ErrIdempotencyConflict + } + // Missing request intent does not imply missing ownership. Known creators may + // still fall back to the original resolved-request equivalence at the upsert. + if !row.CreationRequestHash.Valid { + return SessionCreation{}, ErrNotFound + } + if row.CreationRequestHash.String != hash.String { + return SessionCreation{}, ErrIdempotencyConflict + } + session, err := sessionFromRow(row) + session.Environment = environment + // The row and cursor share one committed snapshot; later events remain observable. + return SessionCreation{Session: session, Cursor: row.EventSequence}, err +} diff --git a/services/agents-api/internal/store/session_creation_identity_test.go b/services/agents-api/internal/store/session_creation_identity_test.go new file mode 100644 index 000000000..2e445846d --- /dev/null +++ b/services/agents-api/internal/store/session_creation_identity_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sync" + "testing" + + "github.com/google/uuid" +) + +func TestSessionCreationIdentityConvergesOnFrozenSnapshot(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + request := json.RawMessage(`{"agent_id":"source","agent":{"tools":[{"parameters":{"const":9007199254740993}}]}}`) + const count = 8 + results := make(chan SessionCreation, count) + errs := make(chan error, count) + var wg sync.WaitGroup + for i := range count { + wg.Add(1) + go func() { + defer wg.Done() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "same", CreationRequest: request, Configuration: json.RawMessage(fmt.Sprintf(`{"resolved":%d}`, i)), InitialInputs: []Input{messageInput("one")}} + result, err := s.CreateSessionStream(ctx, tenant, input) + results <- result + errs <- err + }() + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + var first SessionCreation + created := 0 + for result := range results { + if first.Session.ID == "" { + first = result + } + if result.Session.ID != first.Session.ID || string(result.Session.Configuration) != string(first.Session.Configuration) { + t.Fatal("concurrent resolution changed winner", first, result) + } + if result.Created { + created++ + if result.Cursor != 0 { + t.Fatal("creation cursor passed initial input") + } + } + } + var turns, inputs int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM turns WHERE session_id=$1`, first.Session.ID).Scan(&turns); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM turn_inputs WHERE session_id=$1`, first.Session.ID).Scan(&inputs); err != nil { + t.Fatal(err) + } + if created != 1 || turns != 1 || inputs != 1 { + t.Fatal(created, turns, inputs) + } + restarted := New(pool) + retry, err := restarted.FindSessionCreation(ctx, tenant, "same", json.RawMessage(`{"agent":{"tools":[{"parameters":{"const":9007199254740993}}]},"agent_id":"source"}`), FixtureCreator()) + if err != nil || retry.Created || retry.Session.ID != first.Session.ID || retry.Cursor == 0 { + t.Fatal(retry, err) + } + if _, err := restarted.FindSessionCreation(ctx, tenant, "same", json.RawMessage(`{"agent_id":"source","agent":{"tools":[{"parameters":{"const":9007199254740992}}]}}`), FixtureCreator()); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("numeric identity collapsed", err) + } + if _, err := restarted.FindSessionCreation(ctx, uuid.NewString(), "same", request, FixtureCreator()); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign lookup", err) + } + if _, err := restarted.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "same", CreationRequest: json.RawMessage(`{"agent_id":"changed"}`), Configuration: first.Session.Configuration}); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed caller admitted", err) + } +} + +func TestSessionCreationIdentityDoesNotInventHistoricalIntent(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "historical", Configuration: json.RawMessage(`{"agent":{"id":"source","instructions":"original"}}`)} + first, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + input.CreationRequest = json.RawMessage(`{"agent_id":"source"}`) + if _, err := s.FindSessionCreation(ctx, tenant, input.IdempotencyKey, input.CreationRequest, FixtureCreator()); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + retry, err := s.CreateSession(ctx, tenant, input) + if err != nil || !reflect.DeepEqual(first, retry) { + t.Fatal(retry, err) + } + var hash *string + if err := pool.QueryRow(ctx, `SELECT creation_request_hash FROM sessions WHERE id=$1`, first.ID).Scan(&hash); err != nil || hash != nil { + t.Fatal("historical intent was manufactured", hash, err) + } + input.Configuration = json.RawMessage(`{"agent":{"id":"source","instructions":"changed"}}`) + if _, err := s.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("historical retry rules changed", err) + } +} diff --git a/services/agents-api/internal/store/session_creation_stream.go b/services/agents-api/internal/store/session_creation_stream.go new file mode 100644 index 000000000..757a2aa2d --- /dev/null +++ b/services/agents-api/internal/store/session_creation_stream.go @@ -0,0 +1,19 @@ +package store + +import "context" + +// SessionCreation starts observation at the Session upsert. Session contains the +// resource fields before initial work, not a later activity snapshot. Only a new +// creation emits that snapshot; retries observe future changes from Cursor. +type SessionCreation struct { + Session Session + Created bool + Cursor int64 +} + +// CreateSessionStream shares admission and retry identity with ordinary creation. +// The upsert returns its event cursor while holding the Session write lock, before +// initial inputs commit. No post-commit cursor lookup may skip those inputs. +func (s *Store) CreateSessionStream(ctx context.Context, tenant string, input CreateSessionInput) (SessionCreation, error) { + return s.createSession(ctx, tenant, input) +} diff --git a/services/agents-api/internal/store/session_creation_stream_test.go b/services/agents-api/internal/store/session_creation_stream_test.go new file mode 100644 index 000000000..d3cc70edf --- /dev/null +++ b/services/agents-api/internal/store/session_creation_stream_test.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" +) + +func TestCreationStreamStartsBeforeOwnInputsAndRetriesAtUpsertCursor(t *testing.T) { + s, _ := testStore(t) + other, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "stream", InitialInputs: []Input{messageInput("first")}} + var wg sync.WaitGroup + results := make(chan SessionCreation, 8) + for i := range 8 { + wg.Go(func() { + st := s + if i%2 == 0 { + st = other + } + result, err := st.CreateSessionStream(ctx, tenant, input) + if err != nil { + t.Error(err) + return + } + results <- result + }) + } + wg.Wait() + close(results) + var created SessionCreation + var retries []SessionCreation + for result := range results { + if result.Created { + if created.Created { + t.Fatal("two creation owners") + } + created = result + } else { + retries = append(retries, result) + } + } + if !created.Created || created.Cursor != 0 || created.Session.LastTurn != nil || len(retries) != 7 { + t.Fatal("invalid pre-input creation snapshot", created, retries) + } + id := created.Session.ID + initial, err := s.ListSessionEvents(ctx, tenant, id, created.Cursor) + if err != nil || len(initial) != 3 || initial[0].Event.Type != "agent.session.turn.created" { + t.Fatal("lost initial events", initial, err) + } + for _, retry := range retries { + if retry.Session.ID != id || retry.Cursor != initial[len(initial)-1].Sequence { + t.Fatal(retry) + } + if events, err := s.ListSessionEvents(ctx, tenant, id, retry.Cursor); err != nil || len(events) != 0 { + t.Fatal("retry replayed initial events", events, err) + } + } + ordinary, err := s.CreateSession(ctx, tenant, input) + if err != nil || ordinary.ID != id || ordinary.LastTurn == nil { + t.Fatal(ordinary, err) + } + transition(t, s, tenant, id, ordinary.LastTurn.ID, TurnQueued, TurnInProgress) + transition(t, s, tenant, id, ordinary.LastTurn.ID, TurnInProgress, TurnCompleted) + // Completing before the HTTP observer drains does not change its start point. + all, err := s.ListSessionEvents(ctx, tenant, id, created.Cursor) + if err != nil || len(all) <= len(initial) || all[0].Event.EventID != initial[0].Event.EventID { + t.Fatal(all, err) + } + late, err := s.CreateSessionStream(ctx, tenant, input) + if err != nil || late.Created || late.Cursor != all[len(all)-1].Sequence { + t.Fatal(late, err) + } + next, err := s.SubmitInputs(ctx, tenant, id, "next", []Input{messageInput("later")}) + if err != nil { + t.Fatal(err) + } + future, err := s.ListSessionEvents(ctx, tenant, id, late.Cursor) + if err != nil || len(future) != 3 || future[0].Turn.ID != next[0].TurnID { + t.Fatal(future, err) + } + turns, err := s.ListTurns(ctx, tenant, id, "", 100, true) + if err != nil || len(turns.Turns) != 2 { + t.Fatal(turns, err) + } + foreign, err := s.CreateSessionStream(ctx, uuid.NewString(), input) + if err != nil || !foreign.Created || foreign.Session.ID == id || foreign.Cursor != 0 { + t.Fatal(foreign, err) + } +} + +func TestCreationStreamIdleAndNonstreamRetry(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "idle"} + first, err := s.CreateSessionStream(ctx, tenant, input) + if err != nil || !first.Created || first.Cursor != 0 || first.Session.LastTurn != nil { + t.Fatal(first, err) + } + if _, err := s.SubmitInputs(ctx, tenant, first.Session.ID, "message", []Input{messageInput("later")}); err != nil { + t.Fatal(err) + } + retry, err := s.CreateSessionStream(ctx, tenant, input) + if err != nil || retry.Created || retry.Cursor == 0 { + t.Fatal(retry, err) + } + ordinary, err := s.CreateSession(ctx, tenant, input) + if err != nil || ordinary.ID != first.Session.ID || ordinary.LastTurn == nil { + t.Fatal(ordinary, err) + } + input.IdempotencyKey = "nonstream-first" + ordinary, err = s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + retry, err = s.CreateSessionStream(ctx, tenant, input) + if err != nil || retry.Created || retry.Session.ID != ordinary.ID || retry.Cursor != 0 { + t.Fatal(retry, err) + } +} diff --git a/services/agents-api/internal/store/session_creator.go b/services/agents-api/internal/store/session_creator.go new file mode 100644 index 000000000..bb9eaa6d9 --- /dev/null +++ b/services/agents-api/internal/store/session_creator.go @@ -0,0 +1,19 @@ +package store + +import ( + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/jackc/pgx/v5/pgtype" +) + +func sessionCreator(kind, id pgtype.Text) (*identity.Subject, error) { + if !kind.Valid && !id.Valid { + return nil, nil + } + creator := identity.Subject{Kind: kind.String, ID: id.String} + if !kind.Valid || !id.Valid || creator.Validate() != nil { + return nil, fmt.Errorf("invalid stored Session creator") + } + return &creator, nil +} diff --git a/services/agents-api/internal/store/session_creator_test.go b/services/agents-api/internal/store/session_creator_test.go new file mode 100644 index 000000000..6ed0d6e84 --- /dev/null +++ b/services/agents-api/internal/store/session_creator_test.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func TestSessionCreatorIsRequiredBeforeCreation(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("creator-required", "self_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("initial")} + for _, invalid := range []identity.Subject{{}, {Kind: "user"}, {ID: "someone"}, {Kind: "workspace", ID: "someone"}} { + input.Creator = invalid + if _, err := s.CreateSessionStream(t.Context(), tenant, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid creator accepted: %v", err) + } + } + page, err := s.ListSessions(t.Context(), tenant, "", 10, false, nil) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("invalid creation wrote resources", page, err) + } + input.Creator = FixtureCreator() + created, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || created.Creator == nil || *created.Creator != input.Creator { + t.Fatal("valid creator did not persist", created, err) + } +} + +func TestConcurrentSessionCreatorsCannotShareCreationRetry(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + request := json.RawMessage(`{"agent_id":"source"}`) + creators := []identity.Subject{{Kind: "user", ID: "same-id"}, {Kind: "service_account", ID: "same-id"}} + type outcome struct { + result SessionCreation + err error + } + results := make(chan outcome, 8) + start := make(chan struct{}) + var ready sync.WaitGroup + ready.Add(8) + for i := range 8 { + go func() { + input := environmentInput("shared-retry", "self_hosted", "/workspace") + input.Creator = creators[i%2] + input.CreationRequest, input.InitialInputs = request, []Input{messageInput("once")} + ready.Done() + <-start + result, err := s.CreateSessionStream(t.Context(), tenant, input) + results <- outcome{result, err} + }() + } + ready.Wait() + close(start) + var winner Session + succeeded, conflicted, created := 0, 0, 0 + for range 8 { + got := <-results + if errors.Is(got.err, ErrIdempotencyConflict) { + conflicted++ + continue + } + if got.err != nil || got.result.Session.Creator == nil { + t.Fatal("creation failed", got) + } + succeeded++ + if got.result.Created { + created++ + } + if winner.ID == "" { + winner = got.result.Session + } + if winner.ID != got.result.Session.ID || *winner.Creator != *got.result.Session.Creator { + t.Fatal("creator or Session changed across retries") + } + } + if succeeded != 4 || conflicted != 4 || created != 1 { + t.Fatalf("success/conflict/created = %d/%d/%d", succeeded, conflicted, created) + } + var environments, turns, inputs, reservations, initialReservations int + if err := pool.QueryRow(t.Context(), `SELECT + (SELECT count(*) FROM environments WHERE session_id=$1), + (SELECT count(*) FROM turns WHERE session_id=$1), + (SELECT count(*) FROM turn_inputs WHERE session_id=$1), + (SELECT count(*) FROM environment_input_reservations WHERE session_id=$1), + (SELECT count(*) FROM environment_input_reservations WHERE session_id=$1 AND is_initial)`, winner.ID).Scan(&environments, &turns, &inputs, &reservations, &initialReservations); err != nil || environments != 1 || turns != 0 || inputs != 0 || reservations != 1 || initialReservations != 1 { + t.Fatal("concurrent creators duplicated or prematurely admitted resources", environments, turns, inputs, reservations, initialReservations, err) + } + pool.Close() + restarted, _ := testStore(t) + for _, creator := range creators { + found, err := restarted.FindSessionCreation(t.Context(), tenant, "shared-retry", request, creator) + if creator == *winner.Creator { + if err != nil || found.Session.ID != winner.ID || found.Session.Creator == nil || *found.Session.Creator != creator { + t.Fatal("creator retry did not survive restart", found, err) + } + } else if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("early recovery ignored creator kind", err) + } + } + if _, err := restarted.GetSession(t.Context(), uuid.NewString(), winner.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("creator bypassed project isolation", err) + } +} + +func TestHistoricalUnknownCreatorCannotBeClaimedByRetry(t *testing.T) { + for _, recordedIntent := range []bool{false, true} { + t.Run(map[bool]string{false: "unknown intent", true: "recorded intent"}[recordedIntent], func(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := environmentInput("historical-owner", "self_hosted", "/workspace") + input.CreationRequest = json.RawMessage(`{"agent_id":"historical-source"}`) + created, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + // Explicitly represent pre-migration ownership; production creation has no unknown mode. + if _, err := pool.Exec(ctx, `UPDATE sessions SET creator_kind=NULL, creator_id=NULL, + creation_request_hash=CASE WHEN $2 THEN creation_request_hash ELSE NULL END WHERE id=$1`, created.ID, recordedIntent); err != nil { + t.Fatal(err) + } + var before, after string + if err := pool.QueryRow(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", created.ID).Scan(&before); err != nil { + t.Fatal(err) + } + read, err := s.GetSession(ctx, tenant, created.ID) + if err != nil || read.Creator != nil { + t.Fatal("historical ownership was invented", read, err) + } + page, err := s.ListSessions(ctx, tenant, "", 10, false, nil) + if err != nil || len(page.Sessions) != 1 || page.Sessions[0].Creator != nil { + t.Fatal("historical project reads changed", page, err) + } + if _, err := s.FindSessionCreation(ctx, tenant, input.IdempotencyKey, input.CreationRequest, input.Creator); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("early retry claimed historical ownership", err) + } + if _, err := s.CreateSessionStream(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("upsert claimed historical ownership", err) + } + for _, statement := range []string{ + "UPDATE sessions SET creator_kind='user' WHERE id=$1", + "UPDATE sessions SET creator_id='someone' WHERE id=$1", + } { + if _, err := pool.Exec(ctx, statement, created.ID); err == nil { + t.Fatal("partial creator passed the database constraint") + } + } + if err := pool.QueryRow(ctx, "SELECT to_jsonb(s)::text FROM sessions s WHERE id=$1", created.ID).Scan(&after); err != nil || after != before { + t.Fatal("retry changed historical data", err) + } + }) + } +} diff --git a/services/agents-api/internal/store/session_deletion.go b/services/agents-api/internal/store/session_deletion.go new file mode 100644 index 000000000..dfefb5a7f --- /dev/null +++ b/services/agents-api/internal/store/session_deletion.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// DeleteSession removes public access while retaining state needed to settle execution. +func (s *Store) DeleteSession(ctx context.Context, tenantID, sessionID string) error { + return s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + if err := cancelSessionWork(ctx, q, session); err != nil { + return err + } + if err := q.DeleteSessionArtifacts(ctx, session); err != nil { + return err + } + return q.MarkSessionDeleted(ctx, session) + }) +} + +func cancelSessionWork(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + turn, err := q.GetActiveTurn(ctx, session) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + if err := requestTurnCancel(ctx, q, session, turn); err != nil { + return err + } + } + if err := q.CancelSessionEnvironmentInput(ctx, session); err != nil { + return err + } + return nil +} + +func requestTurnCancel(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn sqlc.Turn) error { + if err := q.RequestTurnCancel(ctx, sqlc.RequestTurnCancelParams{ID: turn.ID, SessionID: session}); err != nil { + return err + } + if turn.Status == TurnQueued { + cancelled, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) + if err != nil { + return err + } + if err := recordTurnChange(ctx, q, cancelled, false); err != nil { + return err + } + } else if turn.Status == TurnWaiting && !turn.CancelRequestedAt.Valid { + cancelling, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) + if err != nil { + return err + } + if err := recordSessionActivity(ctx, q, cancelling, nil); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/store/session_deletion_execution_test.go b/services/agents-api/internal/store/session_deletion_execution_test.go new file mode 100644 index 000000000..3adb71db2 --- /dev/null +++ b/services/agents-api/internal/store/session_deletion_execution_test.go @@ -0,0 +1,93 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { + h := newFunctionHarness(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + input := h.message("start", "Run") + h.read(proto.TypePromptRequest) + h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "pending", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) + state := functionState(t, h, 1) + if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); err != nil { + t.Fatal(err) + } + var request proto.PromptCancelPayload + if err := h.read(proto.TypePromptCancel).DecodePayload(&request); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(store.FunctionResultInput{TurnID: input.TurnID, CallID: state.RequiredActions[0].CallID, Result: json.RawMessage(`{"success":true,"output":"late"}`)}) + if _, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "late", []store.Input{{Kind: "tool_result", Payload: raw}}); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "deleted-native"}}}) + waitTurn(t, h, input.TurnID, store.TurnCancelled) + bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) + if err != nil || bound.NativeSessionID != "deleted-native" { + t.Fatal(bound, err) + } + if _, err := h.s.GetSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + if _, err := h.d.Run(ctx, h.tenant, h.session.ID, input.TurnID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("deleted Session must be unavailable to new dispatch", err) + } + h.session = publicSession(t, h, "unrelated") + next := h.message("next", "Unrelated work") + h.read(proto.TypePromptRequest) + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) + waitTurn(t, h, next.TurnID, store.TurnCompleted) +} + +func TestDeletedSessionRestartStillReconcilesHiddenClaim(t *testing.T) { + h := newDispatchHarness(t) + input := h.message("interrupted", "Run") + ctx := t.Context() + if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); err != nil { + t.Fatal(err) + } + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + stopped, cancel := context.WithCancel(ctx) + cancel() + if err := worker.Run(stopped); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnFailed { + t.Fatal(turn, err) + } + if _, err := h.s.GetSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/session_deletion_public_test.go b/services/agents-api/internal/store/session_deletion_public_test.go new file mode 100644 index 000000000..39930668f --- /dev/null +++ b/services/agents-api/internal/store/session_deletion_public_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSessionDeletionOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + h, err = api.NewHandler(store.New(pool), auth, "codex", api.WithExecution(store.New(pool))) + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(h) + defer recovered.Close() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, python, "../../tests/official_session_delete.py", server.URL, token, foreign, recovered.URL) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("official Session deletion: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/agents-api/internal/store/session_deletion_test.go b/services/agents-api/internal/store/session_deletion_test.go new file mode 100644 index 000000000..f85da918e --- /dev/null +++ b/services/agents-api/internal/store/session_deletion_test.go @@ -0,0 +1,123 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" +) + +func TestSessionDeletionPreservesExecutionAndRejectsAdmission(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + tenant := uuid.NewString() + for _, status := range []string{TurnQueued, TurnInProgress, TurnCompleted} { + t.Run(status, func(t *testing.T) { + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: status} + session, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + receipt, err := s.SubmitMessage(ctx, tenant, session.ID, "input", json.RawMessage(`{"text":"retained"}`)) + if err != nil { + t.Fatal(err) + } + if status != TurnQueued { + _, err = s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}) + if err != nil { + t.Fatal(err) + } + } + if status == TurnCompleted { + _, err = s.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, TurnCompleted, nil, "", receipt.Sequence) + if err != nil { + t.Fatal(err) + } + } + if err := s.DeleteSession(ctx, uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + fresh := New(pool) + if _, err := fresh.GetSession(ctx, tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := fresh.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + if _, err := fresh.CreateSessionStream(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + if _, err := fresh.SubmitMessage(ctx, tenant, session.ID, "input", json.RawMessage(`{"text":"retained"}`)); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := fresh.ListItems(ctx, tenant, session.ID, "", 20, true); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + turn, err := fresh.GetTurn(ctx, tenant, session.ID, receipt.TurnID) + if err != nil { + t.Fatal(err) + } + if status == TurnQueued { + if turn.Status != TurnCancelled { + t.Fatal(turn) + } + if _, err := fresh.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + } else if status == TurnInProgress { + if turn.CancelRequestedAt.IsZero() { + t.Fatal("missing internal cancellation") + } + if _, err := fresh.CompleteExecution(ctx, tenant, session.ID, receipt.TurnID, TurnCancelled, nil, "", receipt.Sequence); err != nil { + t.Fatal(err) + } + } else if turn.Status != TurnCompleted { + t.Fatal(turn) + } + inputs, err := fresh.ListTurnInputs(ctx, tenant, session.ID, receipt.TurnID, 0, 20) + if err != nil || len(inputs) != 1 { + t.Fatal(inputs, err) + } + if _, err := fresh.SessionEventCursor(ctx, tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := fresh.ListSessionEvents(ctx, tenant, session.ID, 0); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + }) + } +} + +func TestSessionDeletionSerializesAdmissionBeforeRetryLookup(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "creation"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.RequestCancel(ctx, tenant, session.ID, "existing"); err != nil { + t.Fatal(err) + } + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err := tx.Exec(ctx, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := s.RequestCancel(ctx, tenant, session.ID, "existing"); done <- err }() + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + if err := <-done; !errors.Is(err, ErrNotFound) { + t.Fatal("retry admitted after deletion", err) + } +} diff --git a/services/agents-api/internal/store/session_environment_snapshot.go b/services/agents-api/internal/store/session_environment_snapshot.go new file mode 100644 index 000000000..011e520eb --- /dev/null +++ b/services/agents-api/internal/store/session_environment_snapshot.go @@ -0,0 +1,21 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +func sessionEnvironmentSnapshot(ctx context.Context, q *sqlc.Queries, session sqlc.Session) (*Environment, error) { + row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: session.TenantID, ID: session.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + value, err := environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) + if err != nil { + return nil, err + } + return &value, nil +} diff --git a/services/agents-api/internal/store/session_environment_snapshot_test.go b/services/agents-api/internal/store/session_environment_snapshot_test.go new file mode 100644 index 000000000..6785a0790 --- /dev/null +++ b/services/agents-api/internal/store/session_environment_snapshot_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" +) + +func TestSelfHostedCreationSnapshotRetainsEnvironmentAndCursor(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "environment-snapshot", + Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), + CreationRequest: json.RawMessage(`{"agent_id":"saved-agent","environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), + } + created, err := s.CreateSessionStream(t.Context(), tenant, input) + if err != nil || !created.Created || created.Cursor != 0 || created.Session.Environment == nil { + t.Fatal("missing creation Environment", created, err) + } + environment := created.Session.Environment + if environment.ID == "" || environment.SessionID != created.Session.ID || environment.TenantID != tenant || environment.Status != "pending" { + t.Fatal("incorrect creation association", environment) + } + pending, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.Session.ID, "later", []Input{messageInput("later")}) + if err != nil || pending.State != EnvironmentInputPending { + t.Fatal(pending, err) + } + events, err := s.ListSessionEvents(t.Context(), tenant, created.Session.ID, created.Cursor) + if err != nil || len(events) != 1 || events[0].Event.Type != "agent.session.requires_action" { + t.Fatal("creation cursor lost subsequent activity", events, err) + } + retry, err := s.CreateSessionStream(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + recovered, err := s.FindSessionCreation(t.Context(), tenant, input.IdempotencyKey, input.CreationRequest, input.Creator) + if err != nil { + t.Fatal(err) + } + for _, value := range []SessionCreation{retry, recovered} { + snapshot := value.Session + if value.Created || value.Cursor != events[0].Sequence || snapshot.Environment == nil || snapshot.Environment.ID != environment.ID || string(snapshot.Environment.Configuration) != string(environment.Configuration) { + t.Fatal("retry changed Environment or cursor", value) + } + if snapshot.LastTurn != nil || snapshot.EnvironmentInputActivity != nil || snapshot.Usage != nil { + t.Fatal("creation snapshot borrowed later activity", snapshot) + } + } + changedCreator := input.Creator + changedCreator.ID = "another-creator" + if _, err := s.FindSessionCreation(t.Context(), tenant, input.IdempotencyKey, input.CreationRequest, changedCreator); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("retry creator isolation", err) + } + current, err := s.GetSession(t.Context(), tenant, created.Session.ID) + if err != nil || current.EnvironmentInputActivity == nil || current.EnvironmentInputActivity.Status != "requires_action" { + t.Fatal("ordinary read lost current activity", current, err) + } + if created.Cursor != 0 || created.Session.EnvironmentInputActivity != nil { + t.Fatal("creation snapshot mutated") + } +} diff --git a/services/agents-api/internal/store/session_events.go b/services/agents-api/internal/store/session_events.go new file mode 100644 index 000000000..37aa47631 --- /dev/null +++ b/services/agents-api/internal/store/session_events.go @@ -0,0 +1,135 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "sort" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrStreamGap = errors.New("live event buffer exceeded; recover through Session and Items reads") + +// SessionChange keeps transition snapshots separate from public response rendering. +type SessionChange struct { + Sequence int64 `json:"-"` + Event v1.SessionEvent `json:"event"` + Turn *Turn `json:"turn,omitempty"` + SessionUsage json.RawMessage `json:"session_usage,omitempty"` + RequiredActions []v1.FunctionCallAction `json:"required_actions,omitempty"` + EnvironmentInputActivity *EnvironmentInputActivity `json:"environment_input_activity,omitempty"` +} + +func recordSessionChange(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, change SessionChange) error { + change.Event.EventID = uuid.NewString() + change.Event.SessionID = uuid.UUID(session.Bytes).String() + payload, err := json.Marshal(change) + if err != nil { + return err + } + return q.AppendSessionEvent(ctx, sqlc.AppendSessionEventParams{ID: session, Payload: payload}) +} + +func recordTurnChange(ctx context.Context, q *sqlc.Queries, row sqlc.Turn, created bool) error { + if row.Status == TurnWaiting { + return nil + } + if terminalStatus(row.Status) { + if err := settleTurnArtifacts(ctx, q, row); err != nil { + return err + } + unfinished, err := q.FinishSessionItems(ctx, sqlc.FinishSessionItemsParams{SessionID: row.SessionID, TurnID: row.ID}) + if err != nil { + return err + } + sort.Slice(unfinished, func(i, j int) bool { return unfinished[i].Position < unfinished[j].Position }) + for _, item := range unfinished { + var value v1.Item + if err := json.Unmarshal(item.Payload, &value); err != nil { + return err + } + previous := value + previous.Status = "in_progress" + if err := recordItemChange(ctx, q, row.SessionID, item.OutputIndex, previous, value, nil); err != nil { + return err + } + } + } + turn := turnFromRow(row) + turn.Outcome = nil + kind := row.Status + if created { + kind = "created" + } + change := SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, TurnID: turn.ID}, Turn: &turn} + if err := recordSessionChange(ctx, q, row.SessionID, change); err != nil { + return err + } + if !created && !terminalStatus(row.Status) { + return nil + } + return recordSessionActivity(ctx, q, row, nil) +} + +func (s *Store) SessionEventCursor(ctx context.Context, tenantID, sessionID string) (int64, error) { + tenant, err := parseID(tenantID) + if err != nil { + return 0, err + } + id, err := parseID(sessionID) + if err != nil { + return 0, err + } + cursor, err := s.queries.SessionEventCursor(ctx, sqlc.SessionEventCursorParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrNotFound + } + return cursor, err +} + +func (s *Store) ListSessionEvents(ctx context.Context, tenantID, sessionID string, after int64) ([]SessionChange, error) { + if after < 0 { + return nil, ErrInvalidInput + } + latest, err := s.SessionEventCursor(ctx, tenantID, sessionID) + if err != nil { + return nil, err + } + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + id, err := parseID(sessionID) + if err != nil { + return nil, err + } + rows, err := s.queries.ListSessionEvents(ctx, sqlc.ListSessionEventsParams{TenantID: tenant, SessionID: id, Sequence: after}) + if err != nil { + return nil, err + } + changes := make([]SessionChange, 0, len(rows)) + if len(rows) == 0 && latest > after { + return nil, ErrStreamGap + } + for _, row := range rows { + if row.Sequence != after+1 { + return nil, ErrStreamGap + } + var change SessionChange + decoder := json.NewDecoder(bytes.NewReader(row.Payload)) + decoder.UseNumber() + if err := decoder.Decode(&change); err != nil { + return nil, err + } + change.Sequence = row.Sequence + changes = append(changes, change) + after = row.Sequence + } + return changes, nil +} diff --git a/services/agents-api/internal/store/session_events_test.go b/services/agents-api/internal/store/session_events_test.go new file mode 100644 index 000000000..84405368c --- /dev/null +++ b/services/agents-api/internal/store/session_events_test.go @@ -0,0 +1,204 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "slices" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func TestSessionEventsAreVisibleOnlyAfterCommit(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "commit"}) + if err != nil { + t.Fatal(err) + } + for _, commit := range []bool{false, true} { + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(ctx) + if err = sqlc.New(tx).AppendSessionEvent(ctx, sqlc.AppendSessionEventParams{ID: pgtype.UUID{Bytes: uuid.MustParse(session.ID), Valid: true}, Payload: []byte(`{"event":{"type":"agent.session.idle"}}`)}); err != nil { + t.Fatal(err) + } + if changes, err := s.ListSessionEvents(ctx, tenant, session.ID, 0); err != nil || len(changes) != 0 { + t.Fatal("uncommitted events were visible", changes, err) + } + if commit { + err = tx.Commit(ctx) + } else { + err = tx.Rollback(ctx) + } + if err != nil { + t.Fatal(err) + } + } + if changes, err := s.ListSessionEvents(ctx, tenant, session.ID, 0); err != nil || len(changes) != 1 || changes[0].Sequence != 1 { + t.Fatal("commit or rollback changed sequence continuity", changes, err) + } +} + +func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "stream"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"question"}`)) + if err != nil { + t.Fatal(err) + } + before, err := s.SessionEventCursor(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if _, err = s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"question"}`)); err != nil { + t.Fatal(err) + } + after, _ := s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("input retry published duplicate events") + } + if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + batch := []store.ExecutionEvent{ + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"first","delta":"partial"}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"first","delta":" partial"}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"first","delta":" partial"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"cmd","stage":"before","observation":{"status":"in_progress","kind":"command","command":"sleep 10"}}`)}, + } + for range 2 { + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + before, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 5, []store.ExecutionEvent{ + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"discarded","delta":"rollback"}`)}, + {Kind: "output_message", Payload: json.RawMessage(`{"status":"invalid"}`)}, + }); err == nil { + t.Fatal("invalid projection accepted") + } + after, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("failed transaction published events") + } + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"private":"must not escape"}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + var all []store.SessionChange + cursor := int64(0) + for { + page, err := store.New(pool).ListSessionEvents(ctx, tenant, session.ID, cursor) + if err != nil { + t.Fatal(err) + } + if len(page) == 0 { + break + } + all = append(all, page...) + cursor = page[len(page)-1].Sequence + } + if all[0].Event.Type != "agent.session.turn.created" || all[0].Turn.Status != store.TurnQueued || all[len(all)-1].Event.Type != "agent.session.idle" || all[len(all)-1].Turn.Status != store.TurnCancelled { + t.Fatalf("transition snapshots changed: %+v", all) + } + counts := map[string]int{} + var deltas []string + for _, change := range all { + counts[change.Event.Type]++ + if change.Event.Type == "agent.session.turn.output_text.delta" { + deltas = append(deltas, *change.Event.Delta) + } + if change.Event.Type == "agent.session.turn.item.done" && change.Event.Item.Type == "message" && *change.Event.Item.Content[0].Text != "partial partial partial" { + t.Fatal("cancelled message lost accumulated text", change.Event.Item) + } + if change.Turn != nil && len(change.Turn.Outcome) > 0 && string(change.Turn.Outcome) != "null" { + t.Fatal("raw outcome retained in public notification") + } + if change.Event.Type == "agent.session.turn.item.done" && change.Event.Item.Status != "incomplete" { + t.Fatal("cancelled unfinished item reported complete") + } + } + if !slices.Equal(deltas, []string{"partial", " partial", " partial"}) { + t.Fatalf("public deltas were not incremental: %q", deltas) + } + if counts["agent.session.turn.output_text.delta"] != 3 || counts["agent.session.turn.item.done"] != 2 { + t.Fatal(counts) + } + if _, err = s.ListSessionEvents(ctx, uuid.NewString(), session.ID, 0); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign event access", err) + } + before, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if _, err = s.ListItems(ctx, tenant, session.ID, "", 100, true); err != nil { + t.Fatal(err) + } + after, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("history read published live events") + } +} + +func TestSessionEventsRetentionAndQueuedCancellation(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "retention"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(ctx, "DELETE FROM session_events WHERE session_id=$1", session.ID); err != nil { + t.Error(err) + } + }) + inputs := make([]store.Input, 64) + for i := range inputs { + inputs[i] = store.Input{Kind: "message", Payload: json.RawMessage(`{"text":"input"}`)} + } + for range 5 { + if _, err = s.SubmitInputs(ctx, tenant, session.ID, uuid.NewString(), inputs); err != nil { + t.Fatal(err) + } + } + var count int + if err = pool.QueryRow(ctx, "SELECT count(*) FROM session_events WHERE session_id=$1", session.ID).Scan(&count); err != nil || count != 256 { + t.Fatal(count, err) + } + if _, err = s.ListSessionEvents(ctx, tenant, session.ID, 0); !errors.Is(err, store.ErrStreamGap) { + t.Fatal("lagging reader did not detect missing events", err) + } + cursor, err := s.SessionEventCursor(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if _, err = s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { + t.Fatal(err) + } + changes, err := s.ListSessionEvents(ctx, tenant, session.ID, cursor) + if err != nil || len(changes) != 2 || changes[0].Event.Type != "agent.session.turn.cancelled" || changes[1].Event.Type != "agent.session.idle" { + t.Fatal(changes, err) + } + // Force byte retention independently of the event-count limit. + if _, err = pool.Exec(ctx, "UPDATE session_events SET payload=jsonb_build_object('padding',repeat('x',524288)) WHERE session_id=$1", session.ID); err != nil { + t.Fatal(err) + } + if _, err = s.ListItems(ctx, tenant, session.ID, "", 1, true); err != nil { + t.Fatal(err) + } + var bytes int64 + if err = pool.QueryRow(ctx, "SELECT sum(payload_bytes) FROM session_events WHERE session_id=$1", session.ID).Scan(&bytes); err != nil || bytes > 64*1024*1024 { + t.Fatal(bytes, err) + } +} diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go new file mode 100644 index 000000000..bc682932b --- /dev/null +++ b/services/agents-api/internal/store/session_initial_input.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { + for _, input := range inputs { + if input.Kind != "message" { + return nil, nil, ErrInvalidInput + } + } + return validateInputs(inputs) +} + +// The Session upsert locks retries. Only the new row reserves or admits work, so a +// retry after completion or later Turns cannot submit the original input again. +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput) (sqlc.Session, *Environment, error) { + var row sqlc.Session + var environment *Environment + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + row, err = q.CreateSession(ctx, params) + if err != nil { + return err + } + if row.ID == params.ID { + if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider); err != nil { + return err + } + if len(files) > 0 { + metadata, err := s.saveInitialFiles(ctx, q, tx, tenant, row.ID, files) + if err != nil { + return err + } + row, err = q.SetSessionInitialFileMetadata(ctx, sqlc.SetSessionInitialFileMetadataParams{ID: row.ID, Column2: metadata}) + if err != nil { + return err + } + } + if err := s.saveEnvironmentSetup(ctx, q, tenant, row.ID, setup); err != nil { + return err + } + if !setup.Empty() { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return err + } + row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Column2: packages}) + if err != nil { + return err + } + } + if err := createSessionEnvironment(ctx, q, row); err != nil { + return err + } + } + environment, err = sessionEnvironmentSnapshot(ctx, q, row) + if err != nil { + return err + } + if row.ID != params.ID || len(inputs) == 0 { + return nil + } + // Creation retries use the Session request hash. Keep the internal input key + // independent of caller-supplied keys at the events endpoint. + key := uuid.NewString() + if environment != nil { + // Environment input waits for the existing preparation and leased promotion. + if err := withEnvironmentInputActivity(ctx, q, row.ID, func() error { + _, err := q.CreateEnvironmentInputReservation(ctx, sqlc.CreateEnvironmentInputReservationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: row.ID, + IdempotencyKey: key, Batch: encodedInput, IsInitial: true, + }) + return err + }); err != nil { + return err + } + } else { + for position, input := range inputs { + if _, err := admitInput(ctx, q, tenant, row.ID, key, int32(position), input); err != nil { + return err + } + } + } + return q.PruneSessionEvents(ctx, row.ID) + }) + return row, environment, err +} diff --git a/services/agents-api/internal/store/session_initial_input_test.go b/services/agents-api/internal/store/session_initial_input_test.go new file mode 100644 index 000000000..b4b48935a --- /dev/null +++ b/services/agents-api/internal/store/session_initial_input_test.go @@ -0,0 +1,129 @@ +package store + +import ( + "context" + "errors" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" +) + +func TestInitialInputCreationRetriesAcrossConnectionsAndLaterTurns(t *testing.T) { + s, pool := testStore(t) + other, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "initial", InitialInputs: []Input{messageInput("first"), messageInput("second")}} + var wg sync.WaitGroup + results := make(chan Session, 8) + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + st := s + if i%2 == 0 { + st = other + } + session, err := st.CreateSession(ctx, tenant, input) + if err != nil { + t.Error(err) + return + } + results <- session + }() + } + wg.Wait() + close(results) + var first Session + for session := range results { + if first.ID == "" { + first = session + } + if session.ID != first.ID || session.LastTurn == nil || session.LastTurn.ID != first.LastTurn.ID { + t.Fatal("creation retry duplicated work", session) + } + } + if first.LastTurn == nil { + t.Fatal("missing initial Turn") + } + inputs, err := s.ListTurnInputs(ctx, tenant, first.ID, first.LastTurn.ID, 0, 100) + if err != nil || len(inputs) != 2 { + t.Fatal(inputs, err) + } + if !strings.Contains(string(inputs[0].Payload), "first") || !strings.Contains(string(inputs[1].Payload), "second") { + t.Fatal(inputs) + } + for _, changed := range [][]Input{nil, {messageInput("changed")}, {input.InitialInputs[1], input.InitialInputs[0]}} { + request := input + request.InitialInputs = changed + if _, err := s.CreateSession(ctx, tenant, request); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed initial input accepted", err) + } + } + transition(t, s, tenant, first.ID, first.LastTurn.ID, TurnQueued, TurnInProgress) + transition(t, s, tenant, first.ID, first.LastTurn.ID, TurnInProgress, TurnCompleted) + // The same caller key at the events endpoint is an independent request. + next, err := s.SubmitInputs(ctx, tenant, first.ID, input.IdempotencyKey, []Input{messageInput("later")}) + if err != nil || len(next) != 1 || next[0].TurnID == first.LastTurn.ID { + t.Fatal(next, err) + } + if _, err := s.RequestCancel(ctx, tenant, first.ID, "cancel"); err != nil { + t.Fatal(err) + } + if _, err := s.UpdateSessionMetadata(ctx, tenant, first.ID, map[string]string{"updated": "yes"}); err != nil { + t.Fatal(err) + } + events, err := s.ListSessionEvents(ctx, tenant, first.ID, 0) + if err != nil { + t.Fatal(err) + } + pool.Close() + restarted, _ := testStore(t) + retry, err := restarted.CreateSession(ctx, tenant, input) + if err != nil || retry.ID != first.ID || retry.LastTurn.ID != next[0].TurnID || retry.LastTurn.Status != TurnCancelled || retry.Metadata["updated"] != "yes" { + t.Fatal(retry, err) + } + after, err := restarted.ListSessionEvents(ctx, tenant, first.ID, 0) + if err != nil || !reflect.DeepEqual(events, after) { + t.Fatal("retry emitted more events", err) + } + foreign, err := restarted.CreateSession(ctx, uuid.NewString(), input) + if err != nil || foreign.ID == first.ID || foreign.LastTurn.ID == first.LastTurn.ID { + t.Fatal("tenant creation keys collided", foreign, err) + } +} + +func TestInitialInputFailureRollsBackSessionAndWork(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant, marker := uuid.NewString(), uuid.NewString() + constraint := "initial_failure_" + strings.ReplaceAll(marker, "-", "") + // Fail the second input insert after the Session, Turn and first Item exist. + _, err := pool.Exec(ctx, "ALTER TABLE turn_inputs ADD CONSTRAINT "+constraint+" CHECK (payload->>'text' <> '"+marker+"')") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "ALTER TABLE turn_inputs DROP CONSTRAINT IF EXISTS "+constraint) }) + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "rollback", InitialInputs: []Input{messageInput("first"), messageInput(marker)}} + if got, err := s.CreateSession(ctx, tenant, input); err == nil || got.ID != "" { + t.Fatal("partial creation succeeded", got, err) + } + page, err := s.ListSessions(ctx, tenant, "", 100, true, nil) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("partial Session survived", page, err) + } + if _, err := pool.Exec(ctx, "ALTER TABLE turn_inputs DROP CONSTRAINT "+constraint); err != nil { + t.Fatal(err) + } + got, err := s.CreateSession(ctx, tenant, input) + if err != nil || got.LastTurn == nil { + t.Fatal("retry after rollback failed", got, err) + } + items, err := s.ListItems(ctx, tenant, got.ID, "", 100, true) + if err != nil || len(items.Items) != 2 { + t.Fatal(items, err) + } +} diff --git a/services/agents-api/internal/store/session_initial_public_test.go b/services/agents-api/internal/store/session_initial_public_test.go new file mode 100644 index 000000000..619a89607 --- /dev/null +++ b/services/agents-api/internal/store/session_initial_public_test.go @@ -0,0 +1,56 @@ +package store_test + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestInitialSessionInputOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, _ := store.NewTestStore(t) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + // Exercise real worker admission with dispatch paused for deterministic reads. + worker, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + stopped, cancel := context.WithCancel(context.Background()) + cancel() + if err := worker.Run(stopped); err != context.Canceled { + t.Error(err) + } + }) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + unsupported, err := api.NewHandler(s, auth, "claude_code", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + other := httptest.NewServer(unsupported) + defer other.Close() + command := exec.CommandContext(t.Context(), python, "../../tests/official_session_initial_input.py", server.URL, token, foreign, other.URL) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("official initial input: %v %s", err, output) + } +} diff --git a/services/agents-api/internal/store/session_metadata.go b/services/agents-api/internal/store/session_metadata.go new file mode 100644 index 000000000..de72980d8 --- /dev/null +++ b/services/agents-api/internal/store/session_metadata.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, metadata map[string]string) (Session, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Session{}, err + } + id, err := parseID(sessionID) + if err != nil { + return Session{}, err + } + encoded, err := encodeMetadata(metadata) + if err != nil { + return Session{}, err + } + row, err := s.queries.UpdateSessionMetadata(ctx, sqlc.UpdateSessionMetadataParams{TenantID: tenant, ID: id, Metadata: encoded}) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("update session metadata: %w", err) + } + session, decodeErr := sessionFromRow(row) + return s.sessionActivity(ctx, session, decodeErr) +} + +func encodeMetadata(metadata map[string]string) ([]byte, error) { + if metadata == nil { + metadata = map[string]string{} + } + encoded, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("%w: metadata: %v", ErrInvalidInput, err) + } + if len(encoded) > 64*1024 { + return nil, fmt.Errorf("%w: metadata exceeds 64 KiB", ErrInvalidInput) + } + return encoded, nil +} diff --git a/services/agents-api/internal/store/session_metadata_test.go b/services/agents-api/internal/store/session_metadata_test.go new file mode 100644 index 000000000..8e50c4189 --- /dev/null +++ b/services/agents-api/internal/store/session_metadata_test.go @@ -0,0 +1,135 @@ +package store + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" +) + +func TestSessionMetadataPreservesCreationAndExecutionData(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "metadata-update", Metadata: map[string]string{"old": "value"}, Configuration: []byte(`{"agent":{"model":"test-model"},"environment":{"type":"none"}}`)} + first, err := s.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + snapshot := func() string { + t.Helper() + var row string + if err := pool.QueryRow(ctx, "SELECT (to_jsonb(s) - 'metadata')::text FROM sessions s WHERE tenant_id=$1 AND id=$2", tenant, first.ID).Scan(&row); err != nil { + t.Fatal(err) + } + return row + } + before := snapshot() + for _, metadata := range []map[string]string{{"new": "value"}, nil, {}, {"unicode": "中文🧪"}} { + updated, err := s.UpdateSessionMetadata(ctx, tenant, first.ID, metadata) + if metadata == nil { + metadata = map[string]string{} + } + if err != nil || !reflect.DeepEqual(updated.Metadata, metadata) { + t.Fatalf("update = %+v, %v", updated, err) + } + if snapshot() != before { + t.Fatal("metadata update changed other stored Session fields") + } + retry, err := s.CreateSession(ctx, tenant, input) + if err != nil || !reflect.DeepEqual(retry, updated) { + t.Fatalf("creation retry = %+v, %v", retry, err) + } + changed := input + changed.Metadata = metadata + if _, err := s.CreateSession(ctx, tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed creation request: %v", err) + } + } + current, err := s.GetSession(ctx, tenant, first.ID) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + tenant, session string + metadata map[string]string + want error + }{ + {uuid.NewString(), first.ID, nil, ErrNotFound}, + {tenant, uuid.NewString(), nil, ErrNotFound}, + {"invalid", first.ID, nil, ErrInvalidInput}, + {tenant, "invalid", nil, ErrInvalidInput}, + {tenant, first.ID, map[string]string{"large": strings.Repeat("x", 64*1024)}, ErrInvalidInput}, + } { + if _, err := s.UpdateSessionMetadata(ctx, test.tenant, test.session, test.metadata); !errors.Is(err, test.want) { + t.Fatalf("rejected update error = %v, want %v", err, test.want) + } + } + got, err := s.GetSession(ctx, tenant, first.ID) + if err != nil || !reflect.DeepEqual(got, current) { + t.Fatalf("rejected update changed Session: %+v, %v", got, err) + } +} + +func TestSessionMetadataConcurrentReplacement(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + first, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "concurrent-metadata"}) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + key := fmt.Sprint(i) + got, err := s.UpdateSessionMetadata(ctx, tenant, first.ID, map[string]string{key: key}) + if err != nil || len(got.Metadata) != 1 || got.Metadata[key] != key { + t.Errorf("concurrent update = %+v, %v", got, err) + } + }() + } + wg.Wait() + got, err := s.GetSession(ctx, tenant, first.ID) + if err != nil || len(got.Metadata) != 1 { + t.Fatalf("concurrent replacements merged or lost metadata: %+v, %v", got, err) + } +} + +func TestSessionMetadataPreservesTerminalActivity(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "terminal-metadata"}) + if err != nil { + t.Fatal(err) + } + for _, status := range []string{TurnCompleted, TurnFailed, TurnCancelled} { + receipt, err := s.SubmitMessage(ctx, tenant, session.ID, uuid.NewString(), []byte(`{"text":"metadata fixture"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: status}); err != nil { + t.Fatal(err) + } + before, err := s.GetSession(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + before.Metadata = map[string]string{"label": status} + updated, err := s.UpdateSessionMetadata(ctx, tenant, session.ID, before.Metadata) + if err != nil || !reflect.DeepEqual(updated, before) { + t.Fatalf("metadata changed %s activity: %+v, %v", status, updated, err) + } + } +} diff --git a/services/agents-api/internal/store/session_model_execution.go b/services/agents-api/internal/store/session_model_execution.go new file mode 100644 index 000000000..d4c50872c --- /dev/null +++ b/services/agents-api/internal/store/session_model_execution.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) saveSessionModelExecution(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, provider *v1.ModelProviderInput) error { + if provider == nil { + return nil + } + raw, err := json.Marshal(provider) + if err != nil { + return err + } + encrypted, err := s.credentialCipher.SealModelExecution(raw, tenant, uuid.UUID(session.Bytes).String()) + if err != nil { + return ErrCredentialStorageUnavailable + } + return q.SaveSessionModelExecution(ctx, sqlc.SaveSessionModelExecutionParams{SessionID: session, EncryptedConfig: encrypted}) +} + +func (s *Store) SessionModelExecution(ctx context.Context, tenant, session string) (*v1.ModelProviderInput, error) { + tenantID, err := parseID(tenant) + if err != nil { + return nil, err + } + sessionID, err := parseID(session) + if err != nil { + return nil, err + } + ciphertext, err := s.queries.GetSessionModelExecution(ctx, sqlc.GetSessionModelExecutionParams{TenantID: tenantID, SessionID: sessionID}) + if err != nil { + return nil, errors.New("session model execution configuration is unavailable") + } + raw, err := s.credentialCipher.OpenModelExecution(ciphertext, tenant, session) + if err != nil { + return nil, errors.New("session model execution decryption is unavailable") + } + var provider v1.ModelProviderInput + if json.Unmarshal(raw, &provider) != nil { + return nil, errors.New("invalid stored model execution configuration") + } + return &provider, provider.Validate() +} diff --git a/services/agents-api/internal/store/session_model_execution_http_test.go b/services/agents-api/internal/store/session_model_execution_http_test.go new file mode 100644 index 000000000..78c4d2f60 --- /dev/null +++ b/services/agents-api/internal/store/session_model_execution_http_test.go @@ -0,0 +1,65 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "net/http/httptest" + "strings" + "testing" +) + +func TestModelExecutionHTTPWriteOnlyAndStrictAdmission(t *testing.T) { + _, pool := store.NewTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{6}, 32)) + st := store.NewWithCredentialCipher(pool, cipher) + tenant, token := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "catalog-test", TokenSHA256: device.HashCredential(token), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(st, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(st)) + if err != nil { + t.Fatal(err) + } + call := func(method, path, body, key string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", key) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if strings.Contains(w.Body.String(), "model-http-canary") { + t.Fatal("credential echoed in public response") + } + return w + } + body := `{"agent":{"model":"actual-model","x_agents_core":{"harness":"codex"}},"environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://example.com/v1","api_key":"model-http-canary"}}}` + key := uuid.NewString() + w := call("POST", "/v1/agents/sessions", body, key) + if w.Code != 200 { + t.Fatalf("create: %d %s", w.Code, w.Body) + } + var session struct{ ID string } + if err := json.Unmarshal(w.Body.Bytes(), &session); err != nil || session.ID == "" { + t.Fatal("missing Session", err) + } + if w := call("GET", "/v1/agents/sessions/"+session.ID, "", ""); w.Code != 200 { + t.Fatal(w.Code) + } + if w := call("POST", "/v1/agents/sessions", body, key); w.Code != 200 { + t.Fatal("creation retry failed", w.Code) + } + if w := call("POST", "/v1/agents/sessions", strings.Replace(body, "model-http-canary", "changed-key", 1), key); w.Code != 409 { + t.Fatal("conflicting credentials accepted", w.Code) + } + for _, invalid := range []string{strings.Replace(body, `"protocol":"responses"`, `"protocol":"anthropic"`, 1), strings.Replace(body, `"api_key":"model-http-canary"`, `"api_key":"model-http-canary","unknown":true`, 1), strings.Replace(body, `"type":"openai_hosted"`, `"type":"none"`, 1), strings.Replace(body, `"api_key":"model-http-canary"`, `"api_key":null`, 1)} { + if w := call("POST", "/v1/agents/sessions", invalid, uuid.NewString()); w.Code != 400 { + t.Fatalf("invalid execution accepted: %d %s", w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/store/session_model_execution_test.go b/services/agents-api/internal/store/session_model_execution_test.go new file mode 100644 index 000000000..67481c70b --- /dev/null +++ b/services/agents-api/internal/store/session_model_execution_test.go @@ -0,0 +1,64 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" + "testing" +) + +func TestSessionModelExecutionEncryptedAndBound(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) + if err != nil { + t.Fatal(err) + } + st := NewWithCredentialCipher(pool, cipher) + ctx := t.Context() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "mcode", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"actual-model"},"environment":{"type":"openai_hosted"}}`), ModelProvider: &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.com", APIKey: "private-model-canary", ContextWindow: 100000, MaxOutputTokens: 8000}} + session, err := st.CreateSession(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(session.Configuration, []byte("private-model-canary")) || !bytes.Contains(session.Configuration, []byte(`"model_provider_configured":true`)) { + t.Fatal("unsafe or missing configuration marker") + } + var ciphertext []byte + if err := pool.QueryRow(ctx, "SELECT encrypted_config FROM session_model_execution WHERE session_id=$1", session.ID).Scan(&ciphertext); err != nil || bytes.Contains(ciphertext, []byte("private-model-canary")) { + t.Fatal("plaintext key in storage", err) + } + replay, err := st.CreateSession(ctx, tenant, input) + if err != nil || replay.ID != session.ID { + t.Fatal("retry changed snapshot", err) + } + input.ModelProvider.APIKey = "conflicting-key" + if _, err := st.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed credentials accepted", err) + } + restarted := NewWithCredentialCipher(pool, cipher) + provider, err := restarted.SessionModelExecution(ctx, tenant, session.ID) + if err != nil || provider.APIKey != "private-model-canary" { + t.Fatal("restart lost model credential", err) + } + if _, err := restarted.SessionModelExecution(ctx, uuid.NewString(), session.ID); err == nil { + t.Fatal("foreign tenant read credential") + } + if _, err := cipher.OpenModelExecution(ciphertext, tenant, uuid.NewString()); err == nil { + t.Fatal("ciphertext not Session-bound") + } + if _, err := New(pool).SessionModelExecution(ctx, tenant, session.ID); err == nil { + t.Fatal("missing cipher succeeded") + } + input.IdempotencyKey = uuid.NewString() + if _, err := New(pool).CreateSession(ctx, tenant, input); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("unencrypted create", err) + } + var count int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM sessions WHERE tenant_id=$1 AND idempotency_key=$2", tenant, input.IdempotencyKey).Scan(&count); err != nil || count != 0 { + t.Fatal("failed creation left partial Session", err) + } +} diff --git a/services/agents-api/internal/store/session_reference_retry_public_test.go b/services/agents-api/internal/store/session_reference_retry_public_test.go new file mode 100644 index 000000000..f9375e5a1 --- /dev/null +++ b/services/agents-api/internal/store/session_reference_retry_public_test.go @@ -0,0 +1,85 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSavedReferenceRetryOfficialClient(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, pool := store.NewTestStore(t) + tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + worker, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := worker.Run(ctx); err != context.Canceled { + t.Error(err) + } + }) + handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + recovered, err := api.NewHandler(store.New(pool), auth, "codex") + if err != nil { + t.Fatal(err) + } + restarted := httptest.NewServer(recovered) + defer restarted.Close() + // Source mutation is a controlled fixture until its public CRUD is implemented. + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var input struct { + ID string `json:"id"` + Delete bool `json:"delete"` + Patch json.RawMessage `json:"patch"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid fixture request", 400) + return + } + var err error + if input.Delete { + _, err = pool.Exec(r.Context(), `DELETE FROM agents WHERE tenant_id=$1 AND id=$2`, tenant, input.ID) + } else { + _, err = pool.Exec(r.Context(), `UPDATE agents SET configuration=configuration || $3::jsonb WHERE tenant_id=$1 AND id=$2`, tenant, input.ID, input.Patch) + } + if err != nil { + t.Error(err) + http.Error(w, "fixture mutation failed", 500) + return + } + w.WriteHeader(204) + })) + defer control.Close() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, python, "../../tests/official_agent_reference_retry.py", server.URL, token, foreign, control.URL, restarted.URL) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("official reference retry: %v %s", err, out) + } +} diff --git a/services/agents-api/internal/store/session_transaction.go b/services/agents-api/internal/store/session_transaction.go new file mode 100644 index 000000000..f16575d06 --- /dev/null +++ b/services/agents-api/internal/store/session_transaction.go @@ -0,0 +1,57 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// All Turn admission and lifecycle writes lock the tenant-scoped Session first. +// This orders inputs against completion/cancellation across service processes. +func (s *Store) withSession(ctx context.Context, tenantID, sessionID string, apply func(context.Context, *sqlc.Queries, pgtype.UUID) error) error { + return s.withSessionState(ctx, tenantID, sessionID, false, apply) +} + +func (s *Store) withPublicSession(ctx context.Context, tenantID, sessionID string, apply func(context.Context, *sqlc.Queries, pgtype.UUID) error) error { + return s.withSessionState(ctx, tenantID, sessionID, true, apply) +} + +func (s *Store) withSessionState(ctx context.Context, tenantID, sessionID string, public bool, apply func(context.Context, *sqlc.Queries, pgtype.UUID) error) error { + tenant, err := parseID(tenantID) + if err != nil { + return err + } + id, err := parseID(sessionID) + if err != nil { + return err + } + begin := func(ctx context.Context, apply func(pgx.Tx) error) error { + return pgx.BeginFunc(ctx, s.pool, apply) + } + if s.executionLease != nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + begin = s.executionLease.transaction + } + return begin(ctx, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + session, err := q.LockSession(ctx, sqlc.LockSessionParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if public && session.DeletedAt.Valid { + return ErrNotFound + } + if err := apply(ctx, q, id); err != nil { + return err + } + return q.PruneSessionEvents(ctx, id) + }) +} diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go new file mode 100644 index 000000000..d66a9b870 --- /dev/null +++ b/services/agents-api/internal/store/sessions.go @@ -0,0 +1,271 @@ +// Package store persists execution state independently of the Parsar product. +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" +) + +var ( + ErrInvalidInput = errors.New("invalid session input") + ErrEnvironmentUnavailable = errors.New("environment is no longer available") + ErrNotFound = errors.New("session not found") + ErrIdempotencyConflict = errors.New("idempotency key was already used with different input") + enginePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`) +) + +// Session is a durable execution context, separate from product conversations +// and from live daemon connections. Engine session IDs will be bound at execution. +type Session struct { + ID string + TenantID string + Creator *identity.Subject + Engine string + Metadata map[string]string + CreatedAt time.Time + Configuration json.RawMessage + LastTurn *Turn + Usage json.RawMessage + RequiredActions []v1.FunctionCallAction + Environment *Environment + EnvironmentInputActivity *EnvironmentInputActivity +} + +type CreateSessionInput struct { + ModelProvider *v1.ModelProviderInput + Initialization EnvironmentSetup + InitialFiles []InitialFile + Creator identity.Subject + CreationRequest json.RawMessage + Engine string + Metadata map[string]string + IdempotencyKey string + Configuration json.RawMessage + InitialInputs []Input +} + +type SessionPage struct { + Sessions []Session + NextCursor string +} + +type Store struct { + queries *sqlc.Queries + pool *pgxpool.Pool + executionLease *ExecutionLease + credentialCipher *credentialcrypto.Cipher +} + +func New(pool *pgxpool.Pool) *Store { return &Store{queries: sqlc.New(pool), pool: pool} } + +func ValidEngine(engine string) bool { return enginePattern.MatchString(engine) } + +// CreateSession uses a project-scoped key to make retries safe, including +// concurrent submissions. Different input or creator with the same key conflicts. +func (s *Store) CreateSession(ctx context.Context, tenantID string, input CreateSessionInput) (Session, error) { + result, err := s.createSession(ctx, tenantID, input) + return s.sessionActivity(ctx, result.Session, err) +} + +func (s *Store) createSession(ctx context.Context, tenantID string, input CreateSessionInput) (SessionCreation, error) { + if err := input.Creator.Validate(); err != nil { + return SessionCreation{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + tenant, err := parseID(tenantID) + if err != nil { + return SessionCreation{}, err + } + input.Engine = strings.TrimSpace(input.Engine) + if !ValidEngine(input.Engine) || strings.TrimSpace(input.IdempotencyKey) == "" || len(input.IdempotencyKey) > 128 { + return SessionCreation{}, fmt.Errorf("%w: engine and idempotency key are required", ErrInvalidInput) + } + if input.Metadata == nil { + input.Metadata = map[string]string{} + } + metadata, err := encodeMetadata(input.Metadata) + if err != nil { + return SessionCreation{}, err + } + if len(input.Configuration) > 512*1024 { + return SessionCreation{}, fmt.Errorf("%w: configuration exceeds 512 KiB", ErrInvalidInput) + } + configuration, err := canonicalJSONObject(input.Configuration) + if err != nil { + return SessionCreation{}, err + } + var batch []Input + var encodedInput json.RawMessage + if len(input.InitialInputs) > 0 { + batch, encodedInput, err = validateInitialInputs(input.InitialInputs) + if err != nil { + return SessionCreation{}, err + } + } + if input.ModelProvider != nil { + if err := input.ModelProvider.ValidateHarness(input.Engine); err != nil { + return SessionCreation{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) + } + var fields map[string]any + if json.Unmarshal(configuration, &fields) != nil { + return SessionCreation{}, ErrInvalidInput + } + environment, _ := fields["environment"].(map[string]any) + if environment["type"] != "openai_hosted" { + return SessionCreation{}, fmt.Errorf("%w: model credentials require a hosted environment", ErrInvalidInput) + } + fields["model_provider_configured"] = true + configuration, err = json.Marshal(fields) + if err != nil { + return SessionCreation{}, err + } + } + hashConfiguration := configuration + // Empty configuration retains the idempotency hashes from the first schema. + if string(configuration) == "{}" { + hashConfiguration = nil + } + var initialization *EnvironmentSetup + if !input.Initialization.Empty() { + initialization = &input.Initialization + } + // JSON map keys are sorted by encoding/json, so key order does not affect retries. + canonical, err := json.Marshal(struct { + ModelProvider *v1.ModelProviderInput `json:",omitempty"` + Engine string + Metadata map[string]string + Configuration json.RawMessage `json:",omitempty"` + InitialInputs json.RawMessage `json:",omitempty"` + InitialFiles []InitialFile `json:",omitempty"` + Initialization *EnvironmentSetup `json:",omitempty"` + }{input.ModelProvider, input.Engine, input.Metadata, hashConfiguration, encodedInput, input.InitialFiles, initialization}) + if err != nil { + return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) + } + creationHash, err := creationRequestHash(input.CreationRequest) + if err != nil { + return SessionCreation{}, err + } + hash := sha256.Sum256(canonical) + params := sqlc.CreateSessionParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, Engine: input.Engine, + Metadata: metadata, IdempotencyKey: input.IdempotencyKey, RequestHash: hex.EncodeToString(hash[:]), + Configuration: configuration, CreationRequestHash: creationHash, + CreatorKind: pgtype.Text{String: input.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: input.Creator.ID, Valid: true}, + } + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider) + if errors.Is(err, pgx.ErrNoRows) { + return SessionCreation{}, ErrIdempotencyConflict + } + if err != nil { + return SessionCreation{}, fmt.Errorf("create session: %w", err) + } + session, err := sessionFromRow(row) + session.Environment = environment + return SessionCreation{Session: session, Created: row.ID == params.ID, Cursor: row.EventSequence}, err +} + +// GetSession always scopes lookup to the authenticated caller's tenant. +func (s *Store) GetSession(ctx context.Context, tenantID, sessionID string) (Session, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Session{}, err + } + id, err := parseID(sessionID) + if err != nil { + return Session{}, err + } + row, err := s.queries.GetSession(ctx, sqlc.GetSessionParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("get session: %w", err) + } + session, decodeErr := sessionFromRow(row) + return s.sessionActivity(ctx, session, decodeErr) +} + +// ListSessions orders by creation time and ID. The cursor is the last returned +// session ID and must belong to the same tenant; it grants no additional access. +func (s *Store) ListSessions(ctx context.Context, tenantID, cursor string, limit int, ascending bool, agentID *string) (SessionPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SessionPage{}, err + } + if limit < 1 || limit > 100 { + return SessionPage{}, fmt.Errorf("%w: page size must be 1..100", ErrInvalidInput) + } + params := sqlc.ListSessionsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if agentID != nil { + params.AgentID = pgtype.Text{String: *agentID, Valid: true} + } + if cursor != "" { + after, err := s.GetSession(ctx, tenantID, cursor) + if err != nil { + return SessionPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListSessions(ctx, params) + if err != nil { + return SessionPage{}, fmt.Errorf("list sessions: %w", err) + } + page := SessionPage{Sessions: make([]Session, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + session, err := sessionFromRow(row) + session, err = s.sessionActivity(ctx, session, err) + if err != nil { + return SessionPage{}, err + } + page.Sessions = append(page.Sessions, session) + } + return page, nil +} + +func parseID(value string) (pgtype.UUID, error) { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil { + return pgtype.UUID{}, fmt.Errorf("%w: nonzero UUID required", ErrInvalidInput) + } + return pgtype.UUID{Bytes: id, Valid: true}, nil +} + +func sessionFromRow(row sqlc.Session) (Session, error) { + session := Session{ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), Engine: row.Engine, CreatedAt: row.CreatedAt.Time, RequiredActions: []v1.FunctionCallAction{}} + creator, err := sessionCreator(row.CreatorKind, row.CreatorID) + if err != nil { + return Session{}, err + } + session.Creator = creator + configuration, err := canonicalJSONObject(row.Configuration) + if err != nil { + return Session{}, fmt.Errorf("decode session configuration: %w", err) + } + session.Configuration = configuration + if err := json.Unmarshal(row.Metadata, &session.Metadata); err != nil { + return Session{}, fmt.Errorf("decode session metadata: %w", err) + } + return session, nil +} diff --git a/services/agents-api/internal/store/sessions_test.go b/services/agents-api/internal/store/sessions_test.go new file mode 100644 index 000000000..0edb0fe8e --- /dev/null +++ b/services/agents-api/internal/store/sessions_test.go @@ -0,0 +1,190 @@ +package store + +import ( + "context" + "errors" + "os" + "reflect" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" +) + +func testStore(t *testing.T) (*Store, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("PARSAR_AGENTS_API_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("PARSAR_AGENTS_API_TEST_DATABASE_URL is not set; dedicated PostgreSQL required") + } + cfg, err := testDatabaseConfig(dsn) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + // A separate database, not product fixtures or migrations, is sufficient. + var database string + var productTable *string + if err := pool.QueryRow(ctx, "SELECT current_database(), to_regclass('workspaces')::text").Scan(&database, &productTable); err != nil || productTable != nil || database != cfg.ConnConfig.Database { + t.Fatal("execution tests require a database without product workspace tables") + } + if err := migrations.Apply(ctx, dsn); err != nil { + t.Fatal(err) + } + return New(pool), pool +} + +// Validate the driver's effective database, including query parameters and DSNs. +func testDatabaseConfig(dsn string) (*pgxpool.Config, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, errors.New("invalid test database configuration") + } + database := cfg.ConnConfig.Database + if !strings.HasPrefix(database, "parsar_agents_api_") || !strings.HasSuffix(database, "_tests") { + return nil, errors.New("test database must be named parsar_agents_api_*_tests") + } + return cfg, nil +} + +func TestDatabaseGuardUsesEffectiveDatabase(t *testing.T) { + for _, dsn := range []string{ + "postgres://localhost/parsar_agents_api_local_tests?dbname=agents_api", + "host=localhost dbname=agents_api", + "postgres://localhost/agents_api", + } { + if _, err := testDatabaseConfig(dsn); err == nil { + t.Fatalf("unsafe database accepted: %s", dsn) + } + } + cfg, err := testDatabaseConfig("postgres://localhost/parsar_agents_api_local_tests") + if err != nil || cfg.ConnConfig.Database != "parsar_agents_api_local_tests" { + t.Fatalf("valid dedicated database rejected: %v", err) + } +} + +func TestSessionsPersistAndStayTenantScoped(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenantA, tenantB := uuid.NewString(), uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", Metadata: map[string]string{"source": "standalone"}, IdempotencyKey: "first", + Configuration: []byte(`{"agent":{"model":"test-model","instructions":"Keep the snapshot."},"environment":{"type":"none"}}`)} + first, err := s.CreateSession(ctx, tenantA, input) + if err != nil { + t.Fatal(err) + } + other, err := s.CreateSession(ctx, tenantB, input) + if err != nil { + t.Fatal(err) + } + if first.ID == other.ID { + t.Fatal("idempotency leaked across tenants") + } + if _, err := s.GetSession(ctx, tenantB, first.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant read: %v", err) + } + if _, err := s.ListSessions(ctx, tenantB, first.ID, 10, false, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant cursor: %v", err) + } + for _, key := range []string{"second", "third"} { + input.IdempotencyKey = key + if _, err := s.CreateSession(ctx, tenantA, input); err != nil { + t.Fatal(err) + } + } + // Recreate the pool and Store as a new service process would. + pool.Close() + recovered, _ := testStore(t) + got, err := recovered.GetSession(ctx, tenantA, first.ID) + if err != nil || !reflect.DeepEqual(got, first) { + t.Fatalf("restart read = %+v, %v; want %+v", got, err, first) + } + seen := map[string]bool{} + cursor := "" + for { + page, err := recovered.ListSessions(ctx, tenantA, cursor, 2, false, nil) + if err != nil { + t.Fatal(err) + } + for _, session := range page.Sessions { + if session.TenantID != tenantA || seen[session.ID] { + t.Fatalf("unexpected/duplicate session: %+v", session) + } + seen[session.ID] = true + } + if page.NextCursor == "" { + break + } + if page.NextCursor == cursor { + t.Fatal("cursor did not advance") + } + cursor = page.NextCursor + } + if len(seen) != 3 || !seen[first.ID] || seen[other.ID] { + t.Fatalf("pagination lost or leaked sessions: %+v", seen) + } + empty, err := recovered.ListSessions(ctx, uuid.NewString(), "", 10, false, nil) + if err != nil || empty.Sessions == nil || len(empty.Sessions) != 0 { + t.Fatalf("empty tenant = %+v, %v", empty, err) + } +} + +func TestConcurrentSessionCreationIsIdempotent(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "claude_code", Metadata: map[string]string{"b": "2", "a": "1"}, IdempotencyKey: "repeated"} + const count = 8 + ids := make(chan string, count) + errs := make(chan error, count) + var wg sync.WaitGroup + for range count { + wg.Add(1) + go func() { + defer wg.Done() + session, err := s.CreateSession(ctx, tenant, input) + ids <- session.ID + errs <- err + }() + } + wg.Wait() + close(ids) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + unique := map[string]bool{} + for id := range ids { + unique[id] = true + } + if len(unique) != 1 { + t.Fatalf("duplicate sessions: %+v", unique) + } + replay, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "claude_code", Metadata: map[string]string{"a": "1", "b": "2"}, IdempotencyKey: "repeated"}) + if err != nil || !unique[replay.ID] { + t.Fatalf("reordered metadata was not replayed: %+v %v", replay, err) + } + for _, changed := range []CreateSessionInput{ + {Creator: FixtureCreator(), Engine: "codex", Metadata: input.Metadata, IdempotencyKey: input.IdempotencyKey}, + {Creator: FixtureCreator(), Engine: input.Engine, Metadata: map[string]string{"a": "changed"}, IdempotencyKey: input.IdempotencyKey}, + } { + if _, err := s.CreateSession(ctx, tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed request = %v", err) + } + } + page, err := s.ListSessions(ctx, tenant, "", 10, false, nil) + if err != nil || len(page.Sessions) != 1 || !reflect.DeepEqual(page.Sessions[0], replay) { + t.Fatalf("retry changed stored session: %+v, %v", page, err) + } +} diff --git a/services/agents-api/internal/store/source_file_writer.go b/services/agents-api/internal/store/source_file_writer.go new file mode 100644 index 000000000..c75f63553 --- /dev/null +++ b/services/agents-api/internal/store/source_file_writer.go @@ -0,0 +1,47 @@ +package store + +import ( + "crypto/sha256" + "hash" + "io" +) + +const sourceFileChunkBytes = 256 << 10 + +type sourceFileWriter struct { + body io.Writer + hash hash.Hash + size int64 + err error +} + +func newSourceFileWriter(body io.Writer) *sourceFileWriter { + return &sourceFileWriter{body: body, hash: sha256.New()} +} + +func (w *sourceFileWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, w.err + } + if int64(len(p)) > MaxSourceFileBytes-w.size { + w.err = ErrSourceFileTooLarge + return 0, w.err + } + written := 0 + for len(p) > 0 { + chunk := p[:min(len(p), sourceFileChunkBytes)] + n, err := w.body.Write(chunk) + w.hash.Write(chunk[:n]) + w.size += int64(n) + written += n + if err == nil && n != len(chunk) { + err = io.ErrShortWrite + } + if err != nil { + w.err = err + return written, err + } + p = p[n:] + } + return written, nil +} diff --git a/services/agents-api/internal/store/source_files.go b/services/agents-api/internal/store/source_files.go new file mode 100644 index 000000000..d0269bba4 --- /dev/null +++ b/services/agents-api/internal/store/source_files.go @@ -0,0 +1,228 @@ +package store + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +const MaxSourceFileBytes int64 = 512 << 20 + +var ErrSourceFileTooLarge = errors.New("source file exceeds storage limit") + +type SourceFile struct { + ID string + Filename string + Purpose string + SizeBytes int64 + CreatedAt time.Time +} + +type SourceFileUpload struct { + Filename string + Purpose string +} + +type SourceFilePage struct { + Files []SourceFile + NextCursor string +} + +// CreateSourceFile commits only after the complete upload envelope has validated. +func (s *Store) CreateSourceFile(ctx context.Context, tenantID string, upload func(io.Writer) (SourceFileUpload, error)) (SourceFile, error) { + tenant, err := parseID(tenantID) + if err != nil || upload == nil { + return SourceFile{}, ErrInvalidInput + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return SourceFile{}, err + } + defer tx.Rollback(context.Background()) + objects := tx.LargeObjects() + oid, err := objects.Create(ctx, 0) + if err != nil { + return SourceFile{}, err + } + body, err := objects.Open(ctx, oid, pgx.LargeObjectModeWrite) + if err != nil { + return SourceFile{}, err + } + writer := newSourceFileWriter(body) + input, err := upload(writer) + if writer.err != nil { + return SourceFile{}, writer.err + } + if err != nil { + return SourceFile{}, err + } + if !validSourceFilename(input.Filename) || input.Purpose != "user_data" { + return SourceFile{}, ErrInvalidInput + } + if err := body.Close(); err != nil { + return SourceFile{}, err + } + row, err := s.queries.WithTx(tx).CreateSourceFile(ctx, sqlc.CreateSourceFileParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Filename: input.Filename, Purpose: input.Purpose, BodyOid: pgtype.Uint32{Uint32: oid, Valid: true}, + SizeBytes: writer.size, Sha256: hex.EncodeToString(writer.hash.Sum(nil)), + }) + if err != nil { + return SourceFile{}, fmt.Errorf("create source file: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return SourceFile{}, err + } + return sourceFileFromRow(row), nil +} + +func (s *Store) GetSourceFile(ctx context.Context, tenantID, fileID string) (SourceFile, error) { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return SourceFile{}, err + } + row, err := s.queries.GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return SourceFile{}, ErrNotFound + } + if err != nil { + return SourceFile{}, err + } + return sourceFileFromRow(row), nil +} + +func (s *Store) ListSourceFiles(ctx context.Context, tenantID, cursor string, limit int, ascending bool, purpose *string) (SourceFilePage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SourceFilePage{}, err + } + if limit < 1 || limit > 10000 { + return SourceFilePage{}, fmt.Errorf("%w: internal page size must be 1..10000", ErrInvalidInput) + } + params := sqlc.ListSourceFilesParams{ + TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, + AfterID: pgtype.UUID{Valid: true}, + } + if purpose != nil { + if !utf8.ValidString(*purpose) || strings.ContainsRune(*purpose, '\x00') { + return SourceFilePage{}, ErrInvalidInput + } + params.Purpose = pgtype.Text{String: *purpose, Valid: true} + } + if cursor != "" { + after, err := s.GetSourceFile(ctx, tenantID, cursor) + if err != nil { + return SourceFilePage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + _, params.AfterID, _ = sourceFileIDs(tenantID, after.ID) + } + rows, err := s.queries.ListSourceFiles(ctx, params) + if err != nil { + return SourceFilePage{}, fmt.Errorf("list source files: %w", err) + } + page := SourceFilePage{Files: make([]SourceFile, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = sourceFileFromRow(rows[limit-1]).ID + rows = rows[:limit] + } + for _, row := range rows { + page.Files = append(page.Files, sourceFileFromRow(row)) + } + return page, nil +} + +// ReadSourceFile retains an authorized immutable snapshot during concurrent deletion. +func (s *Store) ReadSourceFile(ctx context.Context, tenantID, fileID string, consume func(SourceFile, io.Reader) error) error { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return err + } + if consume == nil { + return ErrInvalidInput + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + row, err := s.queries.WithTx(tx).GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if err := consumeSourceFile(ctx, tx, row, consume); err != nil { + return err + } + return tx.Commit(ctx) +} + +func consumeSourceFile(ctx context.Context, tx pgx.Tx, row sqlc.SourceFile, consume func(SourceFile, io.Reader) error) error { + objects := tx.LargeObjects() + body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) + if err != nil { + return err + } + if err := consume(sourceFileFromRow(row), body); err != nil { + return err + } + return body.Close() +} + +func (s *Store) DeleteSourceFile(ctx context.Context, tenantID, fileID string) error { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + oid, err := s.queries.WithTx(tx).DeleteSourceFile(ctx, sqlc.DeleteSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + if err := objects.Unlink(ctx, oid.Uint32); err != nil { + return err + } + return tx.Commit(ctx) +} + +func sourceFileIDs(tenantID, fileID string) (pgtype.UUID, pgtype.UUID, error) { + tenant, err := parseID(tenantID) + if err != nil { + return tenant, pgtype.UUID{}, err + } + id, err := uuid.Parse(strings.TrimPrefix(fileID, "file-")) + if err != nil || id == uuid.Nil || fileID != "file-"+id.String() { + return tenant, pgtype.UUID{}, ErrNotFound + } + return tenant, pgtype.UUID{Bytes: id, Valid: true}, nil +} + +func validSourceFilename(name string) bool { + return len(name) >= 1 && len(name) <= 1024 && utf8.ValidString(name) && !strings.ContainsRune(name, '\x00') +} + +func sourceFileFromRow(row sqlc.SourceFile) SourceFile { + return SourceFile{ID: "file-" + uuid.UUID(row.ID.Bytes).String(), Filename: row.Filename, + Purpose: row.Purpose, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/agents-api/internal/store/source_files_list_test.go b/services/agents-api/internal/store/source_files_list_test.go new file mode 100644 index 000000000..6d8d5e965 --- /dev/null +++ b/services/agents-api/internal/store/source_files_list_test.go @@ -0,0 +1,128 @@ +package store + +import ( + "cmp" + "errors" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestSourceFileListPaginationIsolationAndReconnect(t *testing.T) { + s, pool := testStore(t) + tenant, other := uuid.NewString(), uuid.NewString() + empty, err := s.ListSourceFiles(t.Context(), tenant, "", 10000, false, nil) + if err != nil || empty.Files == nil || len(empty.Files) != 0 || empty.NextCursor != "" { + t.Fatalf("empty page: %+v, %v", empty, err) + } + + all := make([]SourceFile, 0, 105) + for i := range 105 { + file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource([]byte{byte(i)})) + if err != nil { + t.Fatal(err) + } + stamp := time.Unix(1700000000+int64(i%2), 0).UTC() + if _, err := pool.Exec(t.Context(), "UPDATE source_files SET created_at=$1 WHERE tenant_id=$2 AND id=$3", stamp, tenant, strings.TrimPrefix(file.ID, "file-")); err != nil { + t.Fatal(err) + } + file, err = s.GetSourceFile(t.Context(), tenant, file.ID) + if err != nil { + t.Fatal(err) + } + all = append(all, file) + } + slices.SortFunc(all, func(a, b SourceFile) int { + if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) + }) + foreign, err := s.CreateSourceFile(t.Context(), other, uploadSource([]byte("foreign"))) + if err != nil { + t.Fatal(err) + } + + read := func(current *Store, ascending bool, purpose *string, size int) []SourceFile { + t.Helper() + actual := []SourceFile{} + cursor := "" + for { + page, err := current.ListSourceFiles(t.Context(), tenant, cursor, size, ascending, purpose) + if err != nil || len(page.Files) == 0 || len(page.Files) > size { + t.Fatalf("page: %+v, %v", page, err) + } + actual = append(actual, page.Files...) + if len(actual) > len(all) { + t.Fatal("pagination repeated files") + } + if page.NextCursor == "" { + break + } + if page.NextCursor != page.Files[len(page.Files)-1].ID { + t.Fatal("cursor is not the last included file") + } + cursor = page.NextCursor + } + return actual + } + userData, otherPurpose := "user_data", "batch" + for _, ascending := range []bool{true, false} { + want := slices.Clone(all) + if !ascending { + slices.Reverse(want) + } + for _, purpose := range []*string{nil, &userData} { + for _, size := range []int{17, 10000} { + if got := read(s, ascending, purpose, size); !reflect.DeepEqual(got, want) { + t.Fatalf("ordered page mismatch: ascending=%t size=%d", ascending, size) + } + } + } + } + filtered, err := s.ListSourceFiles(t.Context(), tenant, "", 10000, false, &otherPurpose) + if err != nil || filtered.Files == nil || len(filtered.Files) != 0 || filtered.NextCursor != "" { + t.Fatalf("purpose filter: %+v, %v", filtered, err) + } + for _, cursor := range []string{foreign.ID, "file-" + uuid.NewString(), "invalid"} { + if _, err := s.ListSourceFiles(t.Context(), tenant, cursor, 20, true, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign/unknown cursor %q: %v", cursor, err) + } + } + for _, tc := range []struct { + tenant string + limit int + purpose *string + }{{"invalid", 20, nil}, {tenant, 0, nil}, {tenant, 10001, nil}, {tenant, 20, sourceFilePurposePtr("bad\x00purpose")}} { + if _, err := s.ListSourceFiles(t.Context(), tc.tenant, "", tc.limit, true, tc.purpose); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid store query: %v", err) + } + } + tail, err := s.ListSourceFiles(t.Context(), tenant, all[len(all)-1].ID, 10000, true, nil) + if err != nil || tail.Files == nil || len(tail.Files) != 0 || tail.NextCursor != "" { + t.Fatalf("terminal page: %+v, %v", tail, err) + } + foreignPage, err := s.ListSourceFiles(t.Context(), other, "", 10000, false, nil) + if err != nil || !reflect.DeepEqual(foreignPage.Files, []SourceFile{foreign}) || foreignPage.NextCursor != "" { + t.Fatalf("project isolation: %+v, %v", foreignPage, err) + } + deleted, err := s.CreateSourceFile(t.Context(), tenant, uploadSource(nil)) + if err != nil || s.DeleteSourceFile(t.Context(), tenant, deleted.ID) != nil { + t.Fatal(err) + } + if _, err := s.ListSourceFiles(t.Context(), tenant, deleted.ID, 20, false, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted cursor accepted: %v", err) + } + + pool.Close() + reopened, _ := testStore(t) + if got := read(reopened, true, nil, 17); !reflect.DeepEqual(got, all) { + t.Fatal("listing changed after reconnect") + } +} + +func sourceFilePurposePtr(value string) *string { return &value } diff --git a/services/agents-api/internal/store/source_files_test.go b/services/agents-api/internal/store/source_files_test.go new file mode 100644 index 000000000..bc06bccb9 --- /dev/null +++ b/services/agents-api/internal/store/source_files_test.go @@ -0,0 +1,191 @@ +package store + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func sourceObjectCount(t *testing.T, pool *pgxpool.Pool) int { + t.Helper() + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM pg_largeobject_metadata").Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func uploadSource(data []byte) func(io.Writer) (SourceFileUpload, error) { + return func(w io.Writer) (SourceFileUpload, error) { + _, err := w.Write(data) + return SourceFileUpload{Filename: "source.bin", Purpose: "user_data"}, err + } +} + +func TestSourceFilesPersistScopeAndDelete(t *testing.T) { + s, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + before := sourceObjectCount(t, pool) + for _, data := range [][]byte{{}, {0, 1, 255}, bytes.Repeat([]byte("binary\x00"), 300000)} { + file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource(data)) + if err != nil || file.SizeBytes != int64(len(data)) || file.Filename != "source.bin" || !strings.HasPrefix(file.ID, "file-") || file.CreatedAt.IsZero() { + t.Fatalf("create: %+v %v", file, err) + } + if _, err := s.GetSourceFile(t.Context(), foreign, file.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign metadata: %v", err) + } + if err := s.ReadSourceFile(t.Context(), foreign, file.ID, func(SourceFile, io.Reader) error { + t.Fatal("foreign content callback reached") + return nil + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign content: %v", err) + } + if err := s.DeleteSourceFile(t.Context(), foreign, file.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete: %v", err) + } + reopened := New(pool) + if got, err := reopened.GetSourceFile(t.Context(), tenant, file.ID); err != nil || got != file { + t.Fatalf("metadata: %+v %v", got, err) + } + if err := reopened.ReadSourceFile(t.Context(), tenant, file.ID, func(meta SourceFile, r io.Reader) error { + got, err := io.ReadAll(r) + if meta != file || !bytes.Equal(got, data) { + t.Error("persisted contents differ") + } + return err + }); err != nil { + t.Fatal(err) + } + if err := s.DeleteSourceFile(t.Context(), tenant, file.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetSourceFile(t.Context(), tenant, file.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted metadata: %v", err) + } + if err := s.DeleteSourceFile(t.Context(), tenant, file.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("repeated delete: %v", err) + } + } + if got := sourceObjectCount(t, pool); got != before { + t.Fatalf("orphaned objects: %d -> %d", before, got) + } +} + +func TestSourceFilesRollbackInvalidOrInterruptedUpload(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + before := sourceObjectCount(t, pool) + for _, reason := range []string{"body", "purpose", "filename", "cancel", "ignored-write-error"} { + t.Run(reason, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + _, err := s.CreateSourceFile(ctx, tenant, func(w io.Writer) (SourceFileUpload, error) { + if _, err := w.Write([]byte("not committed")); err != nil { + return SourceFileUpload{}, err + } + input := SourceFileUpload{Filename: "source.bin", Purpose: "user_data"} + switch reason { + case "body": + return input, io.ErrUnexpectedEOF + case "purpose": + input.Purpose = "not-supported" + case "filename": + input.Filename = "bad\x00name" + case "cancel": + cancel() + case "ignored-write-error": + bounded := w.(*sourceFileWriter) + bounded.size = MaxSourceFileBytes + _, _ = w.Write([]byte("over limit")) + } + return input, nil + }) + if err == nil { + t.Fatal("invalid upload committed") + } + if got := sourceObjectCount(t, pool); got != before { + t.Fatalf("rollback orphan: %d -> %d", before, got) + } + }) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM source_files WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatalf("failed upload left metadata: %d %v", count, err) + } +} + +func TestSourceFileReadAdmittedBeforeDeletionCompletes(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + data := bytes.Repeat([]byte("immutable\x00"), 10000) + file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource(data)) + if err != nil { + t.Fatal(err) + } + if err := s.ReadSourceFile(t.Context(), tenant, file.ID, func(_ SourceFile, r io.Reader) error { + prefix := make([]byte, 1) + if _, err := io.ReadFull(r, prefix); err != nil { + return err + } + if err := New(pool).DeleteSourceFile(t.Context(), tenant, file.ID); err != nil { + return err + } + if _, err := s.GetSourceFile(t.Context(), tenant, file.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("new read after delete: %v", err) + } + rest, err := io.ReadAll(r) + if !bytes.Equal(append(prefix, rest...), data) { + t.Error("deletion damaged admitted read") + } + return err + }); err != nil { + t.Fatal(err) + } +} + +func TestSourceFileLargeStream(t *testing.T) { + if os.Getenv("PARSAR_TEST_SOURCE_FILE_LARGE") != "1" { + t.Skip("opt-in 512 MiB source storage acceptance") + } + s, _ := testStore(t) + tenant := uuid.NewString() + chunk := bytes.Repeat([]byte("source\x00binary"), 20000) + want := sha256.New() + file, err := s.CreateSourceFile(t.Context(), tenant, func(w io.Writer) (SourceFileUpload, error) { + for left := MaxSourceFileBytes; left > 0; { + b := chunk[:min(int64(len(chunk)), left)] + if _, err := w.Write(b); err != nil { + return SourceFileUpload{}, err + } + want.Write(b) + left -= int64(len(b)) + } + return SourceFileUpload{Filename: "large.bin", Purpose: "user_data"}, nil + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := s.DeleteSourceFile(context.Background(), tenant, file.ID); err != nil { + t.Error(err) + } + }) + got := sha256.New() + if err := s.ReadSourceFile(t.Context(), tenant, file.ID, func(meta SourceFile, r io.Reader) error { + n, err := io.CopyBuffer(got, r, chunk) + if n != MaxSourceFileBytes || meta.SizeBytes != n { + t.Errorf("size: %d metadata: %d", n, meta.SizeBytes) + } + return err + }); err != nil || !bytes.Equal(got.Sum(nil), want.Sum(nil)) { + t.Fatalf("large stream mismatch: %v", err) + } +} diff --git a/services/agents-api/internal/store/steering_receipts_test.go b/services/agents-api/internal/store/steering_receipts_test.go new file mode 100644 index 000000000..9b330c3d0 --- /dev/null +++ b/services/agents-api/internal/store/steering_receipts_test.go @@ -0,0 +1,78 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionDurableInputReceiptLifetime(t *testing.T) { + for _, mode := range []string{"delayed-acceptance", "missing-at-done", "retry-after-write", "cancel-unknown-first"} { + t.Run(mode, func(t *testing.T) { + h := newDispatchHarness(t) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + first := h.message("first", "initial") + result := h.run(ctx, first.TurnID) + h.read(proto.TypePromptRequest) + extra := h.message("extra", "additional") + var input proto.PromptSteerPayload + if err := h.read(proto.TypePromptSteer).DecodePayload(&input); err != nil || !input.DurableReceipt { + t.Fatal("durable receipt was not requested", err) + } + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Written: true}) + status := store.TurnFailed + switch mode { + case "delayed-acceptance": + select { + case got := <-result: + t.Fatalf("write phase ended execution: %+v", got) + case <-time.After(31 * time.Second): + } + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "completed"}) + status = store.TurnCompleted + case "missing-at-done": + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "unconfirmed input"}) + case "retry-after-write": + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, ErrorCode: "not_ready"}) + case "cancel-unknown-first": + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + var request proto.PromptCancelPayload + if err := h.read(proto.TypePromptCancel).DecodePayload(&request); err != nil { + t.Fatal(err) + } + h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, ErrorCode: "outcome_unknown"}) + select { + case got := <-result: + t.Fatalf("input preempted cancellation: %+v", got) + case <-time.After(300 * time.Millisecond): + } + h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Content: "partial"}}) + status = store.TurnCancelled + } + done := h.finished(result, status) + var outcome execution.Result + if err := json.Unmarshal(done.Outcome, &outcome); err != nil { + t.Fatal(err) + } + want := first.Sequence + if mode == "delayed-acceptance" { + want = extra.Sequence + } + if outcome.AppliedThrough != want { + t.Fatalf("unconfirmed cursor advancement: %+v", outcome) + } + if mode == "missing-at-done" && outcome.ErrorCode != "input_outcome_unknown" { + t.Fatalf("missing receipt accepted: %+v", outcome) + } + }) + } +} diff --git a/services/agents-api/internal/store/subagent_dispatch_test.go b/services/agents-api/internal/store/subagent_dispatch_test.go new file mode 100644 index 000000000..42ec46546 --- /dev/null +++ b/services/agents-api/internal/store/subagent_dispatch_test.go @@ -0,0 +1,64 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { + for _, enabled := range []bool{false, true} { + t.Run(map[bool]string{false: "unrequested", true: "requested"}[enabled], func(t *testing.T) { + h := newDispatchHarness(t) + ctx := t.Context() + configuration, _ := json.Marshal(map[string]any{ + "agent": map[string]any{"model": "test-model", "multi_agent": map[string]bool{"enabled": enabled}}, + "daemon": map[string]string{"work_dir": "/tmp"}, + }) + var err error + h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "identity-dispatch", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + if err = h.s.BindSessionDevice(ctx, h.tenant, h.session.ID, h.device.ID); err != nil { + t.Fatal(err) + } + lease, err := h.s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + h.d.Store = lease.Store() + input := h.message("first", "root message") + running := h.run(ctx, input.TurnID) + var request proto.PromptRequestPayload + if err = h.read(proto.TypePromptRequest).DecodePayload(&request); err != nil || request.ObserveSubagentIdentities != enabled { + t.Fatal("private observation policy not carried", err) + } + identity := proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 100, ParentTurnID: "native-turn", SourceItemID: "spawn-item"} + h.write(input.TurnID, proto.TypeSubagentIdentity, identity) + if !enabled { + h.finished(running, store.TurnFailed) + if _, err = h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child"); !errors.Is(err, store.ErrNotFound) { + t.Fatal("unsolicited identity committed", err) + } + return + } + h.write(input.TurnID, proto.TypeSubagentIdentity, identity) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "root result", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "root"}}) + h.finished(running, store.TurnCompleted) + saved, err := h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child") + if err != nil || saved.NativeID != "child" || saved.ParentNativeID != "root" || saved.FirstTurnID != input.TurnID { + t.Fatal(saved, err) + } + events, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 4 || events[0].Kind != proto.TypeSubagentIdentity || events[1].Kind != proto.TypeSubagentIdentity || events[2].Kind != proto.TypeDone { + t.Fatal("journal lost identity provenance or terminal ordering", events, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/subagent_identities.go b/services/agents-api/internal/store/subagent_identities.go new file mode 100644 index 000000000..9d2b69b8c --- /dev/null +++ b/services/agents-api/internal/store/subagent_identities.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// SubagentIdentity is an internal execution binding, not a public Subagent resource. +type SubagentIdentity struct { + ID, SessionID, NativeID, ParentNativeID string + NativeCreatedAt int64 + FirstTurnID string + FirstEventOrdinal int32 + FirstObservedAt time.Time +} + +func projectSubagentIdentity(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, ordinal int32, raw json.RawMessage) error { + var identity proto.SubagentIdentityPayload + if json.Unmarshal(raw, &identity) != nil || identity.NativeCreatedAt <= 0 || identity.NativeID == identity.ParentNativeID { + return ErrInvalidInput + } + for _, value := range []string{identity.NativeID, identity.ParentNativeID, identity.ParentTurnID, identity.SourceItemID} { + if value == "" || len(value) > 512 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\x00\r\n") { + return ErrInvalidInput + } + } + _, err := q.PutSubagentIdentity(ctx, sqlc.PutSubagentIdentityParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session, + NativeID: identity.NativeID, ParentNativeID: identity.ParentNativeID, + NativeCreatedAt: identity.NativeCreatedAt, FirstTurnID: turn, FirstEventOrdinal: ordinal, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrIdempotencyConflict + } + return err +} + +// GetSubagentIdentity recovers a binding in its authorized, visible Session. +func (s *Store) GetSubagentIdentity(ctx context.Context, tenantID, sessionID, nativeID string) (SubagentIdentity, error) { + p, err := deviceLookup(tenantID, sessionID) + if err != nil { + return SubagentIdentity{}, err + } + row, err := s.queries.GetSubagentIdentity(ctx, sqlc.GetSubagentIdentityParams{TenantID: p.TenantID, SessionID: p.ID, NativeID: nativeID}) + if errors.Is(err, pgx.ErrNoRows) { + return SubagentIdentity{}, ErrNotFound + } + if err != nil { + return SubagentIdentity{}, err + } + return SubagentIdentity{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: sessionID, + NativeID: row.NativeID, ParentNativeID: row.ParentNativeID, NativeCreatedAt: row.NativeCreatedAt, + FirstTurnID: uuid.UUID(row.FirstTurnID.Bytes).String(), FirstEventOrdinal: row.FirstEventOrdinal, + FirstObservedAt: row.FirstObservedAt.Time}, nil +} diff --git a/services/agents-api/internal/store/subagent_identities_test.go b/services/agents-api/internal/store/subagent_identities_test.go new file mode 100644 index 000000000..86549b763 --- /dev/null +++ b/services/agents-api/internal/store/subagent_identities_test.go @@ -0,0 +1,162 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func subagentIdentityEvent(child, parent string, created int64) ExecutionEvent { + raw, _ := json.Marshal(proto.SubagentIdentityPayload{NativeID: child, ParentNativeID: parent, + NativeCreatedAt: created, ParentTurnID: "native-turn", SourceItemID: "native-spawn-item"}) + return ExecutionEvent{Kind: proto.TypeSubagentIdentity, Payload: raw} +} + +func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { + s, pool := testStore(t) + lease := executionLease(t, s) + w := lease.Store() + ctx := t.Context() + tenant, session := newTurnSession(t, s) + host, err := s.CreateDevice(ctx, tenant, "identity test", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = w.BindSessionDevice(ctx, tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, session.ID, "first") + transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + a, b := subagentIdentityEvent("child-a", "root", 102), subagentIdentityEvent("child-b", "root", 101) + batch := []ExecutionEvent{a, b, a} + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err == nil { + t.Fatal("unleased discovery accepted") + } + for range 2 { + if err = w.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + saved, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") + if err != nil || saved.ID == "" || saved.ID == saved.NativeID || saved.SessionID != session.ID || saved.FirstTurnID != input.TurnID || saved.FirstEventOrdinal != 1 || saved.NativeCreatedAt != 102 || saved.FirstObservedAt.IsZero() { + t.Fatal(saved, err) + } + other, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child-b") + if err != nil || other.ID == saved.ID || other.NativeCreatedAt != 101 || other.FirstEventOrdinal != 2 { + t.Fatal("discovery order replaced identity or creation", other, err) + } + for _, owner := range []struct{ tenant, session string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}} { + if _, err = s.GetSubagentIdentity(ctx, owner.tenant, owner.session, "child-a"); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign read", err) + } + if err = w.AppendTurnEvents(ctx, owner.tenant, owner.session, input.TurnID, 4, []ExecutionEvent{a}); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign write", err) + } + } + before, err := s.SessionEventCursor(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + for _, conflict := range []ExecutionEvent{ + subagentIdentityEvent("child-a", "other-root", 102), + subagentIdentityEvent("child-a", "root", 103), + subagentIdentityEvent("child-new", "other-root", 104), + } { + // A preceding new identity and public output must roll back with the conflict. + bad := []ExecutionEvent{subagentIdentityEvent("rollback-child", "root", 105), + {Kind: proto.TypeDelta, Payload: json.RawMessage(`{"delta":"must roll back","sequence":1}`)}, conflict} + if err = w.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, bad); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("conflicting facts accepted", err) + } + if _, err = s.GetSubagentIdentity(ctx, tenant, session.ID, "rollback-child"); !errors.Is(err, ErrNotFound) { + t.Fatal("partial identity survived", err) + } + events, err := s.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 3 { + t.Fatal("partial journal survived", len(events), err) + } + cursor, err := s.SessionEventCursor(ctx, tenant, session.ID) + if err != nil || cursor != before { + t.Fatal("partial public projection survived", cursor, err) + } + } + foreign, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "foreign"}) + if err != nil { + t.Fatal(err) + } + if err = w.BindSessionDevice(ctx, tenant, foreign.ID, host.ID); err != nil { + t.Fatal(err) + } + foreignInput := submitMessage(t, s, tenant, foreign.ID, "first") + transition(t, w, tenant, foreign.ID, foreignInput.TurnID, TurnQueued, TurnInProgress) + if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []ExecutionEvent{a}); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("same device/native child reassigned to another Session", err) + } + if _, err = pool.Exec(ctx, "UPDATE session_devices SET native_session_id='known-root' WHERE session_id=$1", foreign.ID); err != nil { + t.Fatal(err) + } + if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []ExecutionEvent{subagentIdentityEvent("other-child", "root", 101)}); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("known root binding ignored", err) + } + if _, err = w.CompleteExecution(ctx, tenant, session.ID, input.TurnID, TurnCompleted, json.RawMessage(`{}`), "root", input.Sequence); err != nil { + t.Fatal(err) + } + if err = lease.Close(ctx); err != nil { + t.Fatal(err) + } + reopened, _ := testStore(t) + nextOwner := executionLease(t, reopened) + again, err := reopened.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") + if err != nil || !reflect.DeepEqual(again, saved) { + t.Fatal("restart changed identity", again, err) + } + second := submitMessage(t, reopened, tenant, session.ID, "second") + transition(t, nextOwner.Store(), tenant, session.ID, second.TurnID, TurnQueued, TurnInProgress) + if err = w.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []ExecutionEvent{a}); err == nil { + t.Fatal("closed owner wrote identity") + } + continued := proto.SubagentIdentityPayload{NativeID: "child-a", ParentNativeID: "root", NativeCreatedAt: 102, ParentTurnID: "later-native-turn", SourceItemID: "resume-item"} + raw, _ := json.Marshal(continued) + if err = nextOwner.Store().AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []ExecutionEvent{{Kind: proto.TypeSubagentIdentity, Payload: raw}}); err != nil { + t.Fatal(err) + } + again, err = reopened.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") + if err != nil || !reflect.DeepEqual(again, saved) { + t.Fatal("continuation changed immutable first observation", again, err) + } + if err = reopened.DeleteSession(ctx, tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err = reopened.GetSubagentIdentity(ctx, tenant, session.ID, "child-a"); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session exposed identity", err) + } +} + +func TestSubagentIdentityRejectsLostLease(t *testing.T) { + s, pool := testStore(t) + old := executionLease(t, s) + tenant, session := newTurnSession(t, s) + input := submitMessage(t, s, tenant, session.ID, "first") + transition(t, old.Store(), tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + var killed bool + if err := pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", old.conn.Conn().PgConn().PID()).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + successor := executionLease(t, s) + if err := old.Store().AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []ExecutionEvent{subagentIdentityEvent("child", "root", 100)}); err == nil { + t.Fatal("lost owner committed identity") + } + if err := successor.Ping(context.Background()); err != nil { + t.Fatal(err) + } + events, err := s.ListTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 0, 100) + if err != nil || len(events) != 0 { + t.Fatal(events, err) + } +} diff --git a/services/agents-api/internal/store/token_usage.go b/services/agents-api/internal/store/token_usage.go new file mode 100644 index 000000000..71a202961 --- /dev/null +++ b/services/agents-api/internal/store/token_usage.go @@ -0,0 +1,79 @@ +package store + +import ( + "context" + "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" + "math" + "strings" +) + +func projectSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, kind string, sequence int64, raw json.RawMessage, created pgtype.Timestamptz) error { + if kind == proto.TypeSubagentIdentity { + return projectSubagentIdentity(ctx, q, session, turn, int32(sequence), raw) + } + if usage := measuredUsage(kind, raw); usage != nil { + payload, err := json.Marshal(usage) + if err != nil { + return err + } + if err = q.PutTurnUsage(ctx, sqlc.PutTurnUsageParams{SessionID: session, ID: turn, TokenUsage: payload}); err != nil { + return err + } + } + return projectItemSource(ctx, q, session, turn, kind, sequence, raw, created) +} + +func measuredUsage(kind string, raw json.RawMessage) *v1.TokenUsage { + var object map[string]json.RawMessage + if json.Unmarshal(raw, &object) != nil { + return nil + } + if kind == "cancel_receipt" { + var applied bool + if json.Unmarshal(object["applied"], &applied) != nil || !applied { + return nil + } + raw = object["outcome"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + kind = "done" + } + if strings.HasPrefix(kind, "execution_") { + raw = object["done"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + kind = "done" + } + if kind == "done" { + raw = object["usage"] + object = nil + if json.Unmarshal(raw, &object) != nil { + return nil + } + } else if kind != "usage" { + return nil + } + + var tokens map[string]*int64 + if json.Unmarshal(object["tokens"], &tokens) != nil { + return nil + } + for _, key := range []string{"input_tokens", "output_tokens", "cached_input_tokens", "reasoning_output_tokens", "total_tokens"} { + if tokens[key] == nil || *tokens[key] < 0 { + return nil + } + } + input, output, cached, reasoning, total := *tokens["input_tokens"], *tokens["output_tokens"], *tokens["cached_input_tokens"], *tokens["reasoning_output_tokens"], *tokens["total_tokens"] + if cached > input || reasoning > output || input > math.MaxInt64-output || total != input+output { + return nil + } + return &v1.TokenUsage{InputTokens: input, OutputTokens: output, TotalTokens: total, InputTokensDetails: v1.InputTokenDetails{CachedTokens: cached}, OutputTokensDetails: v1.OutputTokenDetails{ReasoningTokens: reasoning}} +} diff --git a/services/agents-api/internal/store/token_usage_integration_test.go b/services/agents-api/internal/store/token_usage_integration_test.go new file mode 100644 index 000000000..af140a30a --- /dev/null +++ b/services/agents-api/internal/store/token_usage_integration_test.go @@ -0,0 +1,134 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "testing" +) + +func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "usage"}) + if err != nil { + t.Fatal(err) + } + usage := func(input int) json.RawMessage { + return json.RawMessage(fmt.Sprintf(`{"tokens":{"input_tokens":%d,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":%d}}`, input, input+3)) + } + check := func(raw json.RawMessage, input int) { + t.Helper() + var got v1.TokenUsage + if json.Unmarshal(raw, &got) != nil || got.InputTokens != int64(input) || got.TotalTokens != int64(input+3) || got.InputTokensDetails.CachedTokens != 4 || got.OutputTokensDetails.ReasoningTokens != 2 { + t.Fatalf("unexpected usage: %s", raw) + } + } + for n, status := range []string{store.TurnFailed, store.TurnCancelled} { + admission, err := s.SubmitMessage(ctx, tenant, session.ID, fmt.Sprint(n), json.RawMessage(`{"text":"measure"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + batch := []store.ExecutionEvent{{Kind: "usage", Payload: usage(10)}} + for range 2 { + if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + // A later snapshot replaces the earlier measurement; it is not a delta. + if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 2, []store.ExecutionEvent{{Kind: "usage", Payload: usage(20)}}); err != nil { + t.Fatal(err) + } + measured, err := s.GetTurn(ctx, tenant, session.ID, admission.TurnID) + if err != nil { + t.Fatal(err) + } + check(measured.Usage, 20) + if _, err = s.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, store.TurnCompleted, json.RawMessage(`{"done":{"usage":`+string(usage(99))+`}}`), "missing-binding", admission.Sequence); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + rolledBack, err := s.GetTurn(ctx, tenant, session.ID, admission.TurnID) + if err != nil || rolledBack.Status != store.TurnInProgress { + t.Fatalf("rollback: %+v %v", rolledBack, err) + } + check(rolledBack.Usage, 20) + // Completion without usage retains the last persisted measurement. + completed, err := s.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, status, json.RawMessage(`{"done":{"content":"partial"}}`), "", admission.Sequence) + if err != nil { + t.Fatal(err) + } + check(completed.Usage, 20) + if _, err = s.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, status, json.RawMessage(`{"done":{"usage":`+string(usage(99))+`}}`), "", admission.Sequence); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal(err) + } + if _, err = s.GetTurn(ctx, uuid.NewString(), session.ID, admission.TurnID); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + } + // A separate connection pool must recover the committed totals without engine state. + restored, err := pgxpool.NewWithConfig(ctx, pool.Config()) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + fresh := store.New(restored) + got, err := fresh.GetSession(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + var total v1.TokenUsage + if err = json.Unmarshal(got.Usage, &total); err != nil { + t.Fatal(err) + } + if total.InputTokens != 40 || total.OutputTokens != 6 || total.TotalTokens != 46 || total.InputTokensDetails.CachedTokens != 8 || total.OutputTokensDetails.ReasoningTokens != 4 { + t.Fatalf("double counted totals: %+v", total) + } + page, err := fresh.ListSessions(ctx, tenant, "", 100, true, nil) + if err != nil || len(page.Sessions) != 1 || string(page.Sessions[0].Usage) != string(got.Usage) { + t.Fatalf("list totals: %+v %v", page, err) + } + if _, err = fresh.GetSession(ctx, uuid.NewString(), session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } +} + +func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { + ctx := context.Background() + s, _ := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "cancel-recovery"}) + if err != nil { + t.Fatal(err) + } + admission, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"measure"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + receipt := json.RawMessage(`{"applied":true,"outcome":{"usage":{"tokens":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":13}}}}`) + if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, []store.ExecutionEvent{{Kind: "cancel_receipt", Payload: receipt}}); err != nil { + t.Fatal(err) + } + // Startup recovery has no in-memory cancellation outcome. + recovered, err := s.TransitionTurn(ctx, tenant, session.ID, admission.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_interrupted"}`)}) + if err != nil { + t.Fatal(err) + } + var got v1.TokenUsage + if json.Unmarshal(recovered.Usage, &got) != nil || got.TotalTokens != 13 { + t.Fatalf("recovery lost receipt usage: %s", recovered.Usage) + } +} diff --git a/services/agents-api/internal/store/token_usage_test.go b/services/agents-api/internal/store/token_usage_test.go new file mode 100644 index 000000000..e66ff741e --- /dev/null +++ b/services/agents-api/internal/store/token_usage_test.go @@ -0,0 +1,41 @@ +package store + +import "testing" + +func TestMeasuredUsageRequiresCompleteConsistentCounts(t *testing.T) { + good := `{"tokens":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":13}}` + for _, kind := range []string{"usage", "done", "execution_failed", "execution_cancelled", "cancel_receipt"} { + raw := good + if kind != "usage" { + raw = `{"usage":` + raw + `}` + } + if kind == "cancel_receipt" { + raw = `{"applied":true,"outcome":` + raw + `}` + } else if kind != "usage" && kind != "done" { + raw = `{"done":` + raw + `}` + } + got := measuredUsage(kind, []byte(raw)) + if got == nil || got.InputTokens != 10 || got.InputTokensDetails.CachedTokens != 4 || got.OutputTokensDetails.ReasoningTokens != 2 { + t.Fatalf("%s: %+v", kind, got) + } + } + for _, raw := range []string{ + `{}`, `{"input_tokens":1}`, `{"tokens":{}}`, + `{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"total_tokens":0}}`, + `{"tokens":{"input_tokens":1,"cached_input_tokens":2,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":1}}`, + `{"tokens":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":2,"total_tokens":2}}`, + `{"tokens":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":3}}`, + `{"tokens":{"input_tokens":9223372036854775807,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":0}}`, + } { + if got := measuredUsage("usage", []byte(raw)); got != nil { + t.Fatalf("invalid measurement accepted: %s", raw) + } + } + if got := measuredUsage("cancel_receipt", []byte(`{"applied":false,"outcome":{"usage":`+good+`}}`)); got != nil { + t.Fatal("unapplied receipt projected") + } + zero := measuredUsage("usage", []byte(`{"tokens":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":0}}`)) + if zero == nil { + t.Fatal("explicit zero is measured") + } +} diff --git a/services/agents-api/internal/store/turn_completion.go b/services/agents-api/internal/store/turn_completion.go new file mode 100644 index 000000000..e829aeaa6 --- /dev/null +++ b/services/agents-api/internal/store/turn_completion.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrUnappliedInputs = errors.New("turn has messages without an executor receipt") + +// CompleteExecution commits the outcome and native continuity under the admission lock. +func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turnID, status string, outcome json.RawMessage, nativeID string, appliedThrough int64) (Turn, error) { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return Turn{}, err + } + if !terminalStatus(status) || len(outcome) > 512*1024 || len(nativeID) > 512 || appliedThrough < 0 { + return Turn{}, ErrInvalidInput + } + outcome, err = canonicalJSONObject(outcome) + if err != nil { + return Turn{}, err + } + var row sqlc.Turn + err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + current, err := q.GetTurn(ctx, p) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if current.Status != TurnInProgress && (current.Status != TurnWaiting || status == TurnCompleted) { + return ErrTurnConflict + } + if status == TurnCompleted { + pending, err := q.HasUnappliedMessages(ctx, sqlc.HasUnappliedMessagesParams{SessionID: session, TurnID: p.ID, Sequence: appliedThrough}) + if err != nil { + return err + } + if pending { + return ErrUnappliedInputs + } + } + row, err = q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ID: p.ID, SessionID: session, ExpectedStatus: current.Status, NewStatus: status, Outcome: outcome}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrTurnConflict + } + if err != nil { + return err + } + if err = insertTurnEvent(ctx, q, row, "execution_"+status, outcome); err != nil { + return err + } + if nativeID != "" { + n, err := q.RememberNativeSession(ctx, sqlc.RememberNativeSessionParams{SessionID: session, NativeSessionID: nativeID}) + if err != nil { + return err + } + if n != 1 { + return ErrNotFound + } + } + row, err = q.GetTurn(ctx, p) + if err != nil { + return err + } + return recordTurnChange(ctx, q, row, false) + }) + if err != nil { + return Turn{}, err + } + return turnFromRow(row), nil +} diff --git a/services/agents-api/internal/store/turn_events.go b/services/agents-api/internal/store/turn_events.go new file mode 100644 index 000000000..96eab6f7b --- /dev/null +++ b/services/agents-api/internal/store/turn_events.go @@ -0,0 +1,126 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrEventLimit = errors.New("execution event storage limit exceeded") + +type TurnEvent struct { + Ordinal int32 + Kind string + Payload json.RawMessage + CreatedAt time.Time +} + +type ExecutionEvent struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// AppendTurnEvents records an ordered batch atomically, not public SSE replay events. +func (s *Store) AppendTurnEvents(ctx context.Context, tenantID, sessionID, turnID string, first int32, events []ExecutionEvent) error { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return err + } + if first < 1 || len(events) == 0 || len(events) > 64 { + return ErrInvalidInput + } + normalized := make([]ExecutionEvent, len(events)) + payloadBytes := 0 + for i, event := range events { + if event.Kind == proto.TypeSubagentIdentity && s.executionLease == nil { + return errors.New("subagent discovery requires a leased Store") + } + if len(event.Payload) > 512*1024 || !enginePattern.MatchString(event.Kind) { + return ErrInvalidInput + } + payload, err := canonicalJSONObject(event.Payload) + if err != nil { + return err + } + normalized[i] = ExecutionEvent{Kind: event.Kind, Payload: payload} + payloadBytes += len(payload) + } + if payloadBytes > 1024*1024 { + return ErrEventLimit + } + batch, err := json.Marshal(normalized) + if err != nil { + return err + } + return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, _ pgtype.UUID) error { + turn, err := q.GetTurn(ctx, p) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if first <= turn.EventCount { + matches, err := q.MatchTurnEventBatch(ctx, sqlc.MatchTurnEventBatchParams{SessionID: p.SessionID, TurnID: p.ID, FirstOrdinal: first, EventCount: int32(len(events)), Batch: batch}) + if err != nil { + return err + } + if !matches { + return ErrIdempotencyConflict + } + return nil + } + if (turn.Status != TurnInProgress && turn.Status != TurnWaiting) || first != turn.EventCount+1 { + return ErrTurnConflict + } + if turn.EventCount+int32(len(events)) > 65536 || turn.EventBytes+int64(payloadBytes) > 32*1024*1024 { + return ErrEventLimit + } + if err = q.InsertTurnEventBatch(ctx, sqlc.InsertTurnEventBatchParams{SessionID: p.SessionID, TurnID: p.ID, FirstOrdinal: first, Batch: batch}); err != nil { + return err + } + if err := indexEvents(ctx, q, p.SessionID, p.ID, first); err != nil { + return err + } + return q.CountTurnEvent(ctx, sqlc.CountTurnEventParams{SessionID: p.SessionID, ID: p.ID, EventCount: int32(len(events)), PayloadBytes: int64(payloadBytes)}) + }) +} + +func insertTurnEvent(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, kind string, payload json.RawMessage) error { + err := q.InsertTurnEvent(ctx, sqlc.InsertTurnEventParams{SessionID: turn.SessionID, TurnID: turn.ID, Ordinal: turn.EventCount + 1, Kind: kind, Payload: payload}) + if err != nil { + return err + } + if err = q.CountTurnEvent(ctx, sqlc.CountTurnEventParams{SessionID: turn.SessionID, ID: turn.ID, EventCount: 1, PayloadBytes: int64(len(payload))}); err != nil { + return err + } + return indexEvents(ctx, q, turn.SessionID, turn.ID, turn.EventCount+1) +} + +func (s *Store) ListTurnEvents(ctx context.Context, tenantID, sessionID, turnID string, after int32, limit int) ([]TurnEvent, error) { + p, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return nil, err + } + if after < 0 || limit < 1 || limit > 100 { + return nil, ErrInvalidInput + } + if _, err = s.GetTurn(ctx, tenantID, sessionID, turnID); err != nil { + return nil, err + } + rows, err := s.queries.ListTurnEvents(ctx, sqlc.ListTurnEventsParams{TenantID: p.TenantID, SessionID: p.SessionID, TurnID: p.ID, Ordinal: after, Limit: int32(limit)}) + if err != nil { + return nil, err + } + events := make([]TurnEvent, 0, len(rows)) + for _, row := range rows { + events = append(events, TurnEvent{Ordinal: row.Ordinal, Kind: row.Kind, Payload: row.Payload, CreatedAt: row.CreatedAt.Time}) + } + return events, nil +} diff --git a/services/agents-api/internal/store/turn_events_test.go b/services/agents-api/internal/store/turn_events_test.go new file mode 100644 index 000000000..ff4739d30 --- /dev/null +++ b/services/agents-api/internal/store/turn_events_test.go @@ -0,0 +1,116 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { + ctx := context.Background() + s, _ := store.NewTestStore(t) + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "events"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"test"}`)) + if err != nil { + t.Fatal(err) + } + _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + batch := []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"部分内容","sequence":1}`)}, {Kind: "usage", Payload: json.RawMessage(`{"input_tokens":10}`)}} + var wg sync.WaitGroup + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { defer wg.Done(); errs <- s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch) }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + conflict := []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"changed"}`)}} + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, conflict); !errors.Is(err, store.ErrIdempotencyConflict) { + t.Fatal(err) + } + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal(err) + } + for _, owner := range []string{uuid.NewString()} { + if err := s.AppendTurnEvents(ctx, owner, session.ID, input.TurnID, 1, batch); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + if _, err := s.ListTurnEvents(ctx, owner, session.ID, input.TurnID, 0, 100); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + } + other, _ := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "other"}) + if _, err := s.ListTurnEvents(ctx, tenant, other.ID, input.TurnID, 0, 100); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + // A failed native binding write must roll back both the terminal event and status. + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCompleted, json.RawMessage(`{}`), "missing-binding", input.Sequence); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + events, _ := s.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 100) + if len(events) != 2 { + t.Fatal("terminal event survived rollback") + } + if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{"done":{"content":""}}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal("retry after terminal", err) + } + if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, store.ErrTurnConflict) { + t.Fatal(err) + } + reopened, pool := store.NewTestStore(t) + defer pool.Close() + page, err := reopened.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 1) + if err != nil || len(page) != 1 || page[0].Ordinal != 1 { + t.Fatalf("page=%+v error=%v", page, err) + } + page, err = reopened.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, page[0].Ordinal, 100) + if err != nil || len(page) != 2 || page[1].Kind != "execution_cancelled" || page[1].Ordinal != 3 { + t.Fatalf("page=%+v error=%v", page, err) + } +} + +func TestEventLimitStillAllowsTerminalFailure(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("start", "Test output budget") + _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}) + if err != nil { + t.Fatal(err) + } + _, pool := store.NewTestStore(t) + defer pool.Close() + if _, err := pool.Exec(ctx, "UPDATE turns SET event_bytes=33554432 WHERE id=$1", input.TurnID); err != nil { + t.Fatal(err) + } + events := []store.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"more"}`)}} + if err = h.s.AppendTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 1, events); !errors.Is(err, store.ErrEventLimit) { + t.Fatal(err) + } + if _, err = h.s.CompleteExecution(ctx, h.tenant, h.session.ID, input.TurnID, store.TurnFailed, json.RawMessage(`{"error_code":"event_limit"}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + got, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) + if err != nil || len(got) != 1 || got[0].Kind != "execution_failed" { + t.Fatalf("events=%+v error=%v", got, err) + } +} diff --git a/services/agents-api/internal/store/turn_inputs.go b/services/agents-api/internal/store/turn_inputs.go new file mode 100644 index 000000000..d52bb4f61 --- /dev/null +++ b/services/agents-api/internal/store/turn_inputs.go @@ -0,0 +1,217 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +type InputReceipt struct { + Sequence int64 + TurnID string // Empty for a cancellation accepted while the Session was idle. + Replayed bool +} + +type TurnInput struct { + Sequence int64 + Kind string + Payload json.RawMessage + CreatedAt time.Time +} + +// Input is a validated execution command, not an upstream wire type. +// The API validates event fields before constructing this storage input. +type Input struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// SubmitMessage and RequestCancel use the same request-level admission as batches. +func (s *Store) SubmitMessage(ctx context.Context, tenantID, sessionID, key string, payload json.RawMessage) (InputReceipt, error) { + return s.submitOne(ctx, tenantID, sessionID, key, Input{Kind: "message", Payload: payload}) +} + +func (s *Store) RequestCancel(ctx context.Context, tenantID, sessionID, key string) (InputReceipt, error) { + return s.submitOne(ctx, tenantID, sessionID, key, Input{Kind: "cancel", Payload: json.RawMessage(`{}`)}) +} + +func (s *Store) submitOne(ctx context.Context, tenantID, sessionID, key string, input Input) (InputReceipt, error) { + receipts, err := s.SubmitInputs(ctx, tenantID, sessionID, key, []Input{input}) + if err != nil { + return InputReceipt{}, err + } + return receipts[0], nil +} + +// SubmitInputs commits a request in order under one Session lock. The entire +// batch is the retry identity; replay never re-evaluates a cancellation target. +// Internal receipts are not the response body of the public events endpoint. +func (s *Store) SubmitInputs(ctx context.Context, tenantID, sessionID, key string, inputs []Input) ([]InputReceipt, error) { + if err := validateInputKey(key); err != nil { + return nil, err + } + batch, encoded, err := validateInputs(inputs) + if err != nil { + return nil, err + } + receipts := make([]InputReceipt, 0, len(batch)) + err = s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + previous, err := inputBatchReceipts(ctx, q, session, key, encoded) + if err != nil { + return err + } + if len(previous) > 0 { + receipts = previous + return nil + } + if slices.ContainsFunc(batch, func(input Input) bool { return input.Kind == "message" }) { + if err := checkEnvironmentFileWriteGate(ctx, q, session); err != nil { + return err + } + } + if err := checkEnvironmentInputGate(ctx, q, session, key, encoded); err != nil { + return err + } + for position, input := range batch { + receipt, err := admitInput(ctx, q, tenantID, session, key, int32(position), input) + if err != nil { + return err + } + receipts = append(receipts, receipt) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("submit turn inputs: %w", err) + } + return receipts, nil +} + +func validateInputKey(key string) error { + if strings.TrimSpace(key) == "" || len(key) > 128 { + return fmt.Errorf("%w: idempotency key is required and limited to 128 bytes", ErrInvalidInput) + } + return nil +} + +func inputBatchReceipts(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, key string, batch json.RawMessage) ([]InputReceipt, error) { + rows, err := q.FindInputBatch(ctx, sqlc.FindInputBatchParams{SessionID: session, IdempotencyKey: key, Batch: batch}) + if err != nil { + return nil, err + } + receipts := make([]InputReceipt, 0, len(rows)) + for _, row := range rows { + if !row.Matches { + return nil, ErrIdempotencyConflict + } + receipts = append(receipts, inputReceipt(row.Sequence, row.TurnID, true)) + } + return receipts, nil +} + +func validateInputs(inputs []Input) ([]Input, json.RawMessage, error) { + if len(inputs) == 0 || len(inputs) > 64 { + return nil, nil, fmt.Errorf("%w: input batch must contain 1..64 events", ErrInvalidInput) + } + batch := make([]Input, len(inputs)) + size := 0 + for i, input := range inputs { + size += len(input.Payload) + if size > 512*1024 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { + return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 512 KiB", ErrInvalidInput) + } + payload, err := canonicalJSONObject(input.Payload) + if err != nil { + return nil, nil, err + } + if input.Kind == "cancel" && string(payload) != "{}" { + return nil, nil, fmt.Errorf("%w: cancel payload must be empty", ErrInvalidInput) + } + if input.Kind == "tool_result" { + if _, err := functionInput(payload); err != nil { + return nil, nil, err + } + } + batch[i] = Input{Kind: input.Kind, Payload: payload} + } + encoded, err := json.Marshal(batch) + return batch, encoded, err +} + +func admitInput(ctx context.Context, q *sqlc.Queries, tenantID string, session pgtype.UUID, key string, position int32, input Input) (InputReceipt, error) { + if input.Kind == "tool_result" { + return admitFunctionResult(ctx, q, tenantID, session, key, position, input) + } + turn, err := q.GetActiveTurn(ctx, session) + if errors.Is(err, pgx.ErrNoRows) { + if input.Kind == "message" { + turn, err = q.CreateTurn(ctx, sqlc.CreateTurnParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session}) + if err == nil { + err = recordTurnChange(ctx, q, turn, true) + } + } else { + err = nil // Retain even an idle cancellation's retry identity. + } + } + if err != nil { + return InputReceipt{}, err + } + sequence, err := q.CreateTurnInput(ctx, sqlc.CreateTurnInputParams{ + SessionID: session, TurnID: turn.ID, IdempotencyKey: key, Kind: input.Kind, Payload: input.Payload, BatchPosition: position, + }) + if err != nil { + return InputReceipt{}, err + } + if input.Kind == "cancel" && turn.ID.Valid { + if err := requestTurnCancel(ctx, q, session, turn); err != nil { + return InputReceipt{}, err + } + } + if err := indexInput(ctx, q, session, sequence); err != nil { + return InputReceipt{}, err + } + return inputReceipt(sequence, turn.ID, false), nil +} + +// ListTurnInputs is an internal ordered recovery query, not the public SSE stream. +func (s *Store) ListTurnInputs(ctx context.Context, tenantID, sessionID, turnID string, after int64, limit int) ([]TurnInput, error) { + params, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return nil, err + } + if after < 0 || limit < 1 || limit > 100 { + return nil, fmt.Errorf("%w: nonnegative cursor and page size 1..100 required", ErrInvalidInput) + } + if _, err := s.GetTurn(ctx, tenantID, sessionID, turnID); err != nil { + return nil, err + } + rows, err := s.queries.ListTurnInputs(ctx, sqlc.ListTurnInputsParams{ + TenantID: params.TenantID, SessionID: params.SessionID, TurnID: params.ID, Sequence: after, Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("list turn inputs: %w", err) + } + inputs := make([]TurnInput, 0, len(rows)) + for _, row := range rows { + inputs = append(inputs, TurnInput{Sequence: row.Sequence, Kind: row.Kind, Payload: row.Payload, CreatedAt: row.CreatedAt.Time}) + } + return inputs, nil +} + +func inputReceipt(sequence int64, turn pgtype.UUID, replayed bool) InputReceipt { + receipt := InputReceipt{Sequence: sequence, Replayed: replayed} + if turn.Valid { + receipt.TurnID = uuid.UUID(turn.Bytes).String() + } + return receipt +} diff --git a/services/agents-api/internal/store/turn_inputs_test.go b/services/agents-api/internal/store/turn_inputs_test.go new file mode 100644 index 000000000..e7b918a51 --- /dev/null +++ b/services/agents-api/internal/store/turn_inputs_test.go @@ -0,0 +1,215 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sync" + "testing" + + "github.com/google/uuid" +) + +var messagePayload = json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`) + +func newTurnSession(t *testing.T, s *Store) (string, Session) { + t.Helper() + tenant := uuid.NewString() + session, err := s.CreateSession(context.Background(), tenant, CreateSessionInput{Creator: FixtureCreator(), + Engine: "codex", IdempotencyKey: "session", Configuration: json.RawMessage(`{"agent":{"model":"test","instructions":"original"}}`), + }) + if err != nil { + t.Fatal(err) + } + return tenant, session +} + +func submitMessage(t *testing.T, s *Store, tenant, session, key string) InputReceipt { + t.Helper() + receipt, err := s.SubmitMessage(context.Background(), tenant, session, key, messagePayload) + if err != nil { + t.Fatal(err) + } + return receipt +} + +func transition(t *testing.T, s *Store, tenant, session, turn, from, to string) Turn { + t.Helper() + got, err := s.TransitionTurn(context.Background(), tenant, session, turn, TurnTransition{ExpectedStatus: from, Status: to}) + if err != nil { + t.Fatal(err) + } + return got +} + +func TestConcurrentInputsUseOneTurnAndOneRetryReceipt(t *testing.T) { + s, _ := testStore(t) + otherStore, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + const count = 8 + for _, repeatedKey := range []bool{true, false} { + t.Run(fmt.Sprintf("repeated-key-%v", repeatedKey), func(t *testing.T) { + var wg sync.WaitGroup + receipts := make(chan InputReceipt, count) + errs := make(chan error, count) + for i := range count { + wg.Add(1) + go func() { + defer wg.Done() + key := "same" + if !repeatedKey { + key = fmt.Sprintf("distinct-%d", i) + } + store := s + if i%2 == 0 { + store = otherStore + } + r, err := store.SubmitMessage(ctx, tenant, session.ID, key, messagePayload) + receipts <- r + errs <- err + }() + } + wg.Wait() + close(receipts) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + turns, sequences := map[string]bool{}, map[int64]bool{} + newReceipts := 0 + for r := range receipts { + turns[r.TurnID], sequences[r.Sequence] = true, true + if !r.Replayed { + newReceipts++ + } + } + want := count + if repeatedKey { + want = 1 + } + if len(turns) != 1 || turns[""] || len(sequences) != want || newReceipts != want { + t.Fatalf("turns=%v sequences=%v new=%d", turns, sequences, newReceipts) + } + }) + } + first := submitMessage(t, s, tenant, session.ID, "same") + inputs, err := s.ListTurnInputs(ctx, tenant, session.ID, first.TurnID, 0, 100) + if err != nil || len(inputs) != count+1 { + t.Fatalf("lost or duplicated inputs: %d, %v", len(inputs), err) + } +} + +func TestTurnInputRetriesAndRestart(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + first := submitMessage(t, s, tenant, session.ID, "first") + transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + steer := submitMessage(t, s, tenant, session.ID, "steer") + if steer.TurnID != first.TurnID || steer.Sequence <= first.Sequence { + t.Fatalf("active message did not steer: %+v", steer) + } + reordered := json.RawMessage(`{ "input": [{"content":[{"text":"hello","type":"input_text"}],"role":"user"}] }`) + retry, err := s.SubmitMessage(ctx, tenant, session.ID, "first", reordered) + if err != nil || !retry.Replayed || retry.Sequence != first.Sequence || retry.TurnID != first.TurnID { + t.Fatalf("equivalent retry = %+v, %v", retry, err) + } + if _, err := s.SubmitMessage(ctx, tenant, session.ID, "first", json.RawMessage(`{"text":"changed"}`)); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed payload accepted: %v", err) + } + if _, err := s.RequestCancel(ctx, tenant, session.ID, "first"); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("changed input kind accepted: %v", err) + } + completed := transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnCompleted) + next := submitMessage(t, s, tenant, session.ID, "next") + if next.TurnID == first.TurnID { + t.Fatal("idle message did not start a new Turn") + } + pool.Close() + recovered, _ := testStore(t) + retry = submitMessage(t, recovered, tenant, session.ID, "first") + if !retry.Replayed || retry.TurnID != first.TurnID || retry.Sequence != first.Sequence { + t.Fatalf("restart retry changed target: %+v", retry) + } + got, err := recovered.GetTurn(ctx, tenant, session.ID, first.TurnID) + if err != nil || !reflect.DeepEqual(got, completed) { + t.Fatalf("restart turn: %+v, %v", got, err) + } + var all []TurnInput + var cursor int64 + for { + page, err := recovered.ListTurnInputs(ctx, tenant, session.ID, first.TurnID, cursor, 1) + if err != nil { + t.Fatal(err) + } + if len(page) == 0 { + break + } + if page[0].Sequence <= cursor || page[0].CreatedAt.IsZero() { + t.Fatalf("invalid ordered input: %+v", page) + } + cursor = page[0].Sequence + all = append(all, page...) + } + if len(all) != 2 || all[0].Sequence != first.Sequence || all[1].Sequence != steer.Sequence { + t.Fatalf("recovered inputs = %+v", all) + } + snapshot, err := recovered.GetSession(ctx, tenant, session.ID) + if err != nil || snapshot.LastTurn == nil || snapshot.LastTurn.ID != next.TurnID || snapshot.LastTurn.Status != TurnQueued { + t.Fatal("latest Session activity did not survive restart", err) + } + snapshot.LastTurn = nil + if !reflect.DeepEqual(snapshot, session) { + t.Fatal("turn submission mutated the Session snapshot", err) + } +} + +func TestTurnOperationsAreTenantAndSessionScoped(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + otherTenant, other := newTurnSession(t, s) + ctx := context.Background() + first := submitMessage(t, s, tenant, session.ID, "input") + for _, scope := range []struct{ tenant, session string }{{otherTenant, session.ID}, {tenant, other.ID}, {tenant, uuid.NewString()}} { + for name, call := range map[string]func() error{ + "submit": func() error { + _, err := s.SubmitMessage(ctx, scope.tenant, scope.session, "input", messagePayload) + return err + }, + "cancel": func() error { _, err := s.RequestCancel(ctx, scope.tenant, scope.session, "cancel"); return err }, + "read": func() error { _, err := s.GetTurn(ctx, scope.tenant, scope.session, first.TurnID); return err }, + "inputs": func() error { + _, err := s.ListTurnInputs(ctx, scope.tenant, scope.session, first.TurnID, 0, 10) + return err + }, + "transition": func() error { + _, err := s.TransitionTurn(ctx, scope.tenant, scope.session, first.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed}) + return err + }, + } { + if err := call(); !errors.Is(err, ErrNotFound) { + t.Fatalf("%s escaped scope: %v", name, err) + } + } + } + // Turn IDs cannot be used with another valid Session in the same tenant either. + second, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "second"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.GetTurn(ctx, tenant, second.ID, first.TurnID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-session turn read: %v", err) + } + if _, err := s.TransitionTurn(ctx, tenant, second.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnFailed}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-session turn write: %v", err) + } + otherInput := submitMessage(t, s, otherTenant, other.ID, "input") + if otherInput.TurnID == first.TurnID { + t.Fatal("retry identity leaked across Sessions") + } +} diff --git a/services/agents-api/internal/store/turn_reads.go b/services/agents-api/internal/store/turn_reads.go new file mode 100644 index 000000000..6d34efb57 --- /dev/null +++ b/services/agents-api/internal/store/turn_reads.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type TurnPage struct { + Turns []Turn + NextCursor string +} + +func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor string, limit int, ascending bool) (TurnPage, error) { + if limit < 1 || limit > 100 { + return TurnPage{}, fmt.Errorf("%w: page size must be 1..100", ErrInvalidInput) + } + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return TurnPage{}, err + } + tenant, _ := parseID(tenantID) + session, _ := parseID(sessionID) + params := sqlc.ListTurnsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetTurn(ctx, tenantID, sessionID, cursor) + if err != nil { + return TurnPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListTurns(ctx, params) + if err != nil { + return TurnPage{}, fmt.Errorf("list turns: %w", err) + } + page := TurnPage{Turns: make([]Turn, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + page.Turns = append(page.Turns, turnFromRow(row)) + } + return page, nil +} diff --git a/services/agents-api/internal/store/turn_reads_test.go b/services/agents-api/internal/store/turn_reads_test.go new file mode 100644 index 000000000..10a13383b --- /dev/null +++ b/services/agents-api/internal/store/turn_reads_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "errors" + "sort" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestTurnPaginationRetainsScopeAndOrder(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + ids := make([]string, 0, 4) + for i := 0; i < 4; i++ { + receipt := submitMessage(t, s, tenant, session.ID, uuid.NewString()) + transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnCancelled) + ids = append(ids, receipt.TurnID) + } + // Equal creation times exercise the ID tie-breaker across page boundaries. + if _, err := pool.Exec(ctx, "UPDATE turns SET created_at=$1 WHERE session_id=$2", time.Unix(1700000000, 0), session.ID); err != nil { + t.Fatal(err) + } + sort.Strings(ids) + s = New(pool) + for _, ascending := range []bool{true, false} { + cursor := "" + for i := 0; i < len(ids); i++ { + page, err := s.ListTurns(ctx, tenant, session.ID, cursor, 1, ascending) + at := i + if !ascending { + at = len(ids) - 1 - i + } + if err != nil || len(page.Turns) != 1 || page.Turns[0].ID != ids[at] { + t.Fatalf("page %d: %+v %v", i, page, err) + } + if (page.NextCursor != "") != (i < len(ids)-1) { + t.Fatalf("incorrect has_more: %+v", page) + } + cursor = page.Turns[0].ID + } + page, err := s.ListTurns(ctx, tenant, session.ID, cursor, 1, ascending) + if err != nil || len(page.Turns) != 0 || page.Turns == nil || page.NextCursor != "" { + t.Fatalf("end: %+v %v", page, err) + } + } + otherTenant, otherSession := newTurnSession(t, s) + sameTenantSession, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) + if err != nil { + t.Fatal(err) + } + for _, scope := range [][2]string{{otherTenant, session.ID}, {tenant, otherSession.ID}, {tenant, uuid.NewString()}, {tenant, sameTenantSession.ID}} { + _, err := s.ListTurns(ctx, scope[0], scope[1], ids[0], 1, true) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign cursor/session accepted: %v", err) + } + } + empty, err := s.ListTurns(ctx, tenant, sameTenantSession.ID, "", 20, false) + if err != nil || empty.Turns == nil || len(empty.Turns) != 0 { + t.Fatalf("empty session: %+v %v", empty, err) + } + for _, limit := range []int{0, 101} { + if _, err := s.ListTurns(ctx, tenant, session.ID, "", limit, false); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("limit accepted: %v", err) + } + } +} diff --git a/services/agents-api/internal/store/turns.go b/services/agents-api/internal/store/turns.go new file mode 100644 index 000000000..e301acfa8 --- /dev/null +++ b/services/agents-api/internal/store/turns.go @@ -0,0 +1,160 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +var ErrTurnConflict = errors.New("turn state changed or cancellation was requested") + +const ( + TurnQueued = "queued" + TurnInProgress = "in_progress" + TurnWaiting = "waiting" + TurnCompleted = "completed" + TurnFailed = "failed" + TurnCancelled = "cancelled" +) + +// Turn uses its Session's immutable execution configuration. Zero timestamps +// mean the corresponding event has not occurred. Outcome is adapter-owned data, +// not an upstream response; the API must project supported wire types explicitly. +type Turn struct { + ID, SessionID, Status string + CreatedAt time.Time + StartedAt time.Time + CompletedAt time.Time + CancelRequestedAt time.Time + Usage json.RawMessage + Outcome json.RawMessage + // ArtifactCaptureStarted is private Runtime coordination, never a wire field. + ArtifactCaptureStarted bool `json:"-"` +} + +type TurnTransition struct { + ExpectedStatus string + Status string + Outcome json.RawMessage +} + +func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (Turn, error) { + params, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return Turn{}, err + } + row, err := s.queries.GetTurn(ctx, params) + if errors.Is(err, pgx.ErrNoRows) { + return Turn{}, ErrNotFound + } + if err != nil { + return Turn{}, fmt.Errorf("get turn: %w", err) + } + return turnFromRow(row), nil +} + +// TransitionTurn is a compare-and-set for execution callbacks. Once terminal, +// a Turn cannot be reopened or have its outcome overwritten, including by retries. +// A dispatcher must claim queued -> in_progress before sending work to a daemon. +func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID string, input TurnTransition) (Turn, error) { + params, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return Turn{}, err + } + if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { + return Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) + } + outcome, err := canonicalJSONObject(input.Outcome) + if err != nil { + return Turn{}, err + } + if !terminalStatus(input.Status) && string(outcome) != "{}" { + return Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) + } + input.Outcome = outcome + var row sqlc.Turn + err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, _ pgtype.UUID) error { + var err error + row, err = transitionTurn(ctx, q, params, input) + return err + }) + if err != nil { + return Turn{}, fmt.Errorf("transition turn: %w", err) + } + return turnFromRow(row), nil +} + +func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnParams, input TurnTransition) (sqlc.Turn, error) { + if _, err := q.GetTurn(ctx, params); errors.Is(err, pgx.ErrNoRows) { + return sqlc.Turn{}, ErrNotFound + } else if err != nil { + return sqlc.Turn{}, err + } + row, err := q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ + ID: params.ID, SessionID: params.SessionID, ExpectedStatus: input.ExpectedStatus, + NewStatus: input.Status, Outcome: input.Outcome, + }) + if errors.Is(err, pgx.ErrNoRows) { + return sqlc.Turn{}, ErrTurnConflict + } + if err == nil && terminalStatus(row.Status) { + if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { + return sqlc.Turn{}, err + } + row, err = q.GetTurn(ctx, params) + } + if err != nil { + return sqlc.Turn{}, err + } + if err := recordTurnChange(ctx, q, row, false); err != nil { + return sqlc.Turn{}, err + } + return row, nil +} + +func validTransition(from, to string) bool { + switch from { + case TurnQueued: + return to == TurnInProgress || to == TurnFailed || to == TurnCancelled + case TurnInProgress: + return to == TurnWaiting || terminalStatus(to) + case TurnWaiting: + return to == TurnInProgress || terminalStatus(to) + default: + return false + } +} + +func terminalStatus(status string) bool { + return status == TurnCompleted || status == TurnFailed || status == TurnCancelled +} + +func turnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { + var p sqlc.GetTurnParams + var err error + if p.TenantID, err = parseID(tenantID); err != nil { + return p, err + } + if p.SessionID, err = parseID(sessionID); err != nil { + return p, err + } + p.ID, err = parseID(turnID) + return p, err +} + +func turnFromRow(row sqlc.Turn) Turn { + return Turn{ + ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), Status: row.Status, + CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, + CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: json.RawMessage(row.Outcome), Usage: json.RawMessage(row.TokenUsage), + ArtifactCaptureStarted: row.ArtifactCaptureStarted, + } +} diff --git a/services/agents-api/internal/store/turns_test.go b/services/agents-api/internal/store/turns_test.go new file mode 100644 index 000000000..4811d5a3e --- /dev/null +++ b/services/agents-api/internal/store/turns_test.go @@ -0,0 +1,166 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" +) + +func TestCancellationStaysBoundToItsOriginalTurn(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + idle, err := s.RequestCancel(ctx, tenant, session.ID, "idle-cancel") + if err != nil || idle.TurnID != "" || idle.Sequence == 0 { + t.Fatalf("idle cancellation: %+v, %v", idle, err) + } + first := submitMessage(t, s, tenant, session.ID, "first") + started := transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + if started.StartedAt.IsZero() || !started.CompletedAt.IsZero() { + t.Fatalf("started timestamps: %+v", started) + } + cancel, err := s.RequestCancel(ctx, tenant, session.ID, "cancel") + if err != nil || cancel.TurnID != first.TurnID { + t.Fatalf("cancellation target: %+v, %v", cancel, err) + } + pending, err := s.GetTurn(ctx, tenant, session.ID, first.TurnID) + if err != nil || pending.Status != TurnInProgress || pending.CancelRequestedAt.IsZero() || !pending.CompletedAt.IsZero() { + t.Fatalf("running cancellation falsely completed: %+v, %v", pending, err) + } + transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnCancelled) + next := submitMessage(t, s, tenant, session.ID, "next") + for key, original := range map[string]InputReceipt{"cancel": cancel, "idle-cancel": idle} { + retry, err := s.RequestCancel(ctx, tenant, session.ID, key) + if err != nil || !retry.Replayed || retry.TurnID != original.TurnID || retry.Sequence != original.Sequence { + t.Fatalf("cancellation retargeted: %+v, %v", retry, err) + } + } + queued, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) + if err != nil || queued.Status != TurnQueued || !queued.CancelRequestedAt.IsZero() { + t.Fatalf("old cancellation affected later Turn: %+v, %v", queued, err) + } + if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel-queued"); err != nil { + t.Fatal(err) + } + stopped, err := s.GetTurn(ctx, tenant, session.ID, next.TurnID) + if err != nil || stopped.Status != TurnCancelled || stopped.CompletedAt.IsZero() || !stopped.StartedAt.IsZero() { + t.Fatalf("queued work did not stop: %+v, %v", stopped, err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("cancelled queued work was started: %v", err) + } +} + +func TestWaitingTurnRetainsInputsAndStartTime(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + first := submitMessage(t, s, tenant, session.ID, "first") + started := transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnWaiting) + steer := submitMessage(t, s, tenant, session.ID, "steer") + if steer.TurnID != first.TurnID { + t.Fatal("waiting input started another Turn") + } + resumed := transition(t, s, tenant, session.ID, first.TurnID, TurnWaiting, TurnInProgress) + if !started.StartedAt.Equal(resumed.StartedAt) { + t.Fatal("resume reset the start time") + } + transition(t, s, tenant, session.ID, first.TurnID, TurnInProgress, TurnWaiting) + if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnWaiting, Status: TurnInProgress}); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("cancelling Turn resumed: %v", err) + } + transition(t, s, tenant, session.ID, first.TurnID, TurnWaiting, TurnCancelled) +} + +func TestTerminalOutcomeIsImmutableDuringConcurrentCallbacks(t *testing.T) { + s, pool := testStore(t) + other, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + first := submitMessage(t, s, tenant, session.ID, "first") + transition(t, s, tenant, session.ID, first.TurnID, TurnQueued, TurnInProgress) + if _, err := s.RequestCancel(ctx, tenant, session.ID, "cancel"); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + winners := make(chan Turn, 3) + errs := make(chan error, 3) + for _, status := range []string{TurnCompleted, TurnFailed, TurnCancelled} { + wg.Add(1) + go func() { + defer wg.Done() + outcome, _ := json.Marshal(map[string]string{"reported": status}) + turn, err := other.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: status, Outcome: outcome}) + if err == nil { + winners <- turn + } else { + errs <- err + } + }() + } + wg.Wait() + close(winners) + close(errs) + if len(winners) != 1 || len(errs) != 2 { + t.Fatalf("winners=%d errors=%d", len(winners), len(errs)) + } + for err := range errs { + if !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + } + winner := <-winners + if winner.CompletedAt.IsZero() || winner.CancelRequestedAt.IsZero() || winner.CompletedAt.Before(winner.StartedAt) { + t.Fatalf("terminal timestamps: %+v", winner) + } + if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed, Outcome: json.RawMessage(`{"late":true}`)}); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("late terminal callback accepted: %v", err) + } + pool.Close() + recovered, _ := testStore(t) + got, err := recovered.GetTurn(ctx, tenant, session.ID, first.TurnID) + if err != nil || !reflect.DeepEqual(got, winner) { + t.Fatalf("terminal outcome changed on restart: %+v, %v", got, err) + } +} + +func TestTurnInputValidationHasNoSideEffects(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + ctx := context.Background() + for _, raw := range []json.RawMessage{nil, json.RawMessage(`[]`), json.RawMessage(`null`), json.RawMessage(`{} {}`), json.RawMessage(`{"text":"` + string(make([]byte, 512*1024)) + `"}`)} { + if _, err := s.SubmitMessage(ctx, tenant, session.ID, "first", raw); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid input accepted: %v", err) + } + } + cancelled, stop := context.WithCancel(ctx) + stop() + if _, err := s.SubmitMessage(cancelled, tenant, session.ID, "first", messagePayload); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled context: %v", err) + } + first := submitMessage(t, s, tenant, session.ID, "first") + if first.Replayed { + t.Fatal("failed submission persisted a receipt") + } + for _, input := range []TurnTransition{ + {ExpectedStatus: TurnQueued, Status: TurnCompleted}, + {ExpectedStatus: TurnInProgress, Status: TurnQueued}, + {ExpectedStatus: TurnCompleted, Status: TurnInProgress}, + {ExpectedStatus: TurnQueued, Status: TurnInProgress, Outcome: json.RawMessage(`{"premature":true}`)}, + } { + if _, err := s.TransitionTurn(ctx, tenant, session.ID, first.TurnID, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid transition accepted: %v", err) + } + } + got, err := s.GetTurn(ctx, tenant, session.ID, first.TurnID) + if err != nil || got.Status != TurnQueued || !got.StartedAt.IsZero() { + t.Fatalf("invalid transition changed Turn: %+v, %v", got, err) + } +} diff --git a/services/agents-api/internal/store/vault_credentials.go b/services/agents-api/internal/store/vault_credentials.go new file mode 100644 index 000000000..6b46ce024 --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrCredentialStorageUnavailable = errors.New("credential encryption is not configured") + +// Credential contains only public metadata. Secret ciphertext is never selected +// by resource reads; decryption belongs to scoped execution lookup only. +type Credential struct { + ID, VaultID, Name, AuthType, MCPServerURL string + CreatedAt, UpdatedAt time.Time +} + +type CreateStaticCredentialInput struct { + Name, MCPServerURL, Token string +} + +// NewWithCredentialCipher configures immutable credential encryption before the +// Store is published. A nil cipher leaves non-secret resource operations available. +func NewWithCredentialCipher(pool *pgxpool.Pool, cipher *credentialcrypto.Cipher) *Store { + s := New(pool) + s.credentialCipher = cipher + return s +} + +func (s *Store) CreateStaticCredential(ctx context.Context, tenantID, vaultID string, input CreateStaticCredentialInput) (Credential, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Credential{}, err + } + vault, err := parseID(vaultID) + if err != nil { + return Credential{}, err + } + if !validVaultName(input.Name) || input.MCPServerURL == "" { + return Credential{}, ErrInvalidInput + } + if s.credentialCipher == nil { + return Credential{}, ErrCredentialStorageUnavailable + } + id := uuid.New() + binding := credentialcrypto.Binding{TenantID: uuid.UUID(tenant.Bytes).String(), VaultID: uuid.UUID(vault.Bytes).String(), CredentialID: id.String(), AuthType: "static_bearer", Destination: input.MCPServerURL} + ciphertext, err := s.credentialCipher.Seal([]byte(input.Token), binding) + if err != nil { + return Credential{}, errors.New("credential encryption failed") + } + row, err := s.queries.CreateStaticCredential(ctx, sqlc.CreateStaticCredentialParams{ + ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, VaultID: vault, + Name: input.Name, McpServerUrl: input.MCPServerURL, TokenCiphertext: ciphertext, + }) + if errors.Is(err, pgx.ErrNoRows) { + return Credential{}, ErrNotFound + } + if err != nil { + return Credential{}, fmt.Errorf("create credential: %w", err) + } + return credentialFromRow(sqlc.GetCredentialRow(row)), nil +} + +func (s *Store) GetCredential(ctx context.Context, tenantID, vaultID, credentialID string) (Credential, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Credential{}, err + } + vault, err := parseID(vaultID) + if err != nil { + return Credential{}, err + } + id, err := parseID(credentialID) + if err != nil { + return Credential{}, err + } + row, err := s.queries.GetCredential(ctx, sqlc.GetCredentialParams{TenantID: tenant, VaultID: vault, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Credential{}, ErrNotFound + } + if err != nil { + return Credential{}, fmt.Errorf("get credential: %w", err) + } + return credentialFromRow(row), nil +} + +func credentialFromRow(row sqlc.GetCredentialRow) Credential { + return Credential{ + ID: uuid.UUID(row.ID.Bytes).String(), VaultID: uuid.UUID(row.VaultID.Bytes).String(), + Name: row.Name, AuthType: row.AuthType, MCPServerURL: row.McpServerUrl, + CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } +} diff --git a/services/agents-api/internal/store/vault_credentials_delete.go b/services/agents-api/internal/store/vault_credentials_delete.go new file mode 100644 index 000000000..4ee62331a --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_delete.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// DeleteCredential removes the stored secret without reading or decrypting it. +func (s *Store) DeleteCredential(ctx context.Context, tenantID, vaultID, credentialID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", ErrNotFound + } + vault, err := parseID(vaultID) + if err != nil { + return "", ErrNotFound + } + id, err := parseID(credentialID) + if err != nil { + return "", ErrNotFound + } + deleted, err := s.queries.DeleteCredential(ctx, sqlc.DeleteCredentialParams{TenantID: tenant, VaultID: vault, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", errors.New("credential deletion failed") + } + return uuid.UUID(deleted.Bytes).String(), nil +} diff --git a/services/agents-api/internal/store/vault_credentials_delete_test.go b/services/agents-api/internal/store/vault_credentials_delete_test.go new file mode 100644 index 000000000..b7cb76b59 --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_delete_test.go @@ -0,0 +1,158 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := bytes.Repeat([]byte{41}, 32) + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + vault, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + wrong, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + create := func(name string) Credential { + t.Helper() + value, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: name, MCPServerURL: "https://mcp.example/tools", Token: name + "-secret"}) + if err != nil { + t.Fatal(err) + } + return value + } + original := create("original") + attached := []string{vault.ID} + selected, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL}}) + if err != nil || len(selected) != 1 { + t.Fatal("initial automatic selection failed", err) + } + configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "model"}, "vault_ids": attached, "mcp_credentials": selected}) + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "retained", Configuration: configuration} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + retained, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]) + if err != nil || retained != "original-secret" { + t.Fatal("pre-delete dispatch lookup failed") + } + sibling := create("sibling") + for _, scope := range []struct{ tenant, vault, id string }{ + {foreign, vault.ID, original.ID}, {tenant, wrong.ID, original.ID}, + {tenant, vault.ID, uuid.NewString()}, {tenant, "invalid", original.ID}, {tenant, vault.ID, "invalid"}, + } { + if _, err := public.DeleteCredential(t.Context(), scope.tenant, scope.vault, scope.id); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign or invalid delete was accepted", err) + } + } + // An actual database write failure must leave the resource and token intact. + tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) + if err != nil { + t.Fatal(err) + } + readOnly := *public + readOnly.queries = public.queries.WithTx(tx) + _, deletionErr := readOnly.DeleteCredential(t.Context(), tenant, vault.ID, original.ID) + _ = tx.Rollback(t.Context()) + if deletionErr == nil || deletionErr.Error() != "credential deletion failed" { + t.Fatal("failed mutation was accepted or exposed") + } + if value, err := public.GetCredential(t.Context(), tenant, vault.ID, original.ID); err != nil || !reflect.DeepEqual(value, original) { + t.Fatal("rejected deletion changed the resource", err) + } + if token, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); err != nil || token != retained { + t.Fatal("rejected deletion changed the stored token") + } + // Delete through a keyless Store, even if the stored payload is damaged. + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=decode('00','hex') WHERE id=$1", original.ID); err != nil { + t.Fatal(err) + } + if id, err := public.DeleteCredential(t.Context(), tenant, vault.ID, original.ID); err != nil || id != original.ID { + t.Fatal("keyless deletion failed", err) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM vault_credentials WHERE id=$1", original.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("deleted row or ciphertext remains") + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + if _, err := public.DeleteCredential(t.Context(), tenant, vault.ID, original.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("repeat deletion did not stay absent") + } + if _, err := public.GetCredential(t.Context(), tenant, vault.ID, original.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted metadata reappeared after restart") + } + if _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, original.ID, UpdateStaticCredentialInput{Token: "replacement"}); !errors.Is(err, ErrNotFound) { + t.Fatal("replacement resurrected a deleted credential") + } + if _, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); !errors.Is(err, ErrNotFound) { + t.Fatal("frozen selection fell back to another token") + } + if _, err := s.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL, CredentialID: &original.ID}}); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted explicit selection was admitted") + } + page, err := public.ListCredentials(t.Context(), tenant, vault.ID, "", 100, true, []string{"active", "archived"}) + if err != nil || len(page.Credentials) != 1 || !reflect.DeepEqual(page.Credentials[0], sibling) { + t.Fatal("deletion changed a sibling or list membership", err) + } + if value, err := public.GetVault(t.Context(), tenant, vault.ID); err != nil || !reflect.DeepEqual(value, vault) { + t.Fatal("deletion changed its parent Vault") + } + if retry, err := public.CreateSession(t.Context(), tenant, input); err != nil || retry.ID != session.ID || !bytes.Equal(retry.Configuration, session.Configuration) { + t.Fatal("deletion changed frozen creation identity", err) + } +} + +func TestCredentialDeletionConcurrentReplacementCannotResurrect(t *testing.T) { + public, pool := testStore(t) + tenant := uuid.NewString() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{42}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + vault, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + for range 8 { + value, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: "competing", MCPServerURL: "https://mcp.example/tools", Token: "before"}) + if err != nil { + t.Fatal(err) + } + start, updated := make(chan struct{}), make(chan error, 1) + go func() { + <-start + _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, value.ID, UpdateStaticCredentialInput{Token: "after"}) + updated <- err + }() + close(start) + _, deleted := public.DeleteCredential(t.Context(), tenant, vault.ID, value.ID) + updateErr := <-updated + if deleted != nil || updateErr != nil && !errors.Is(updateErr, ErrNotFound) { + t.Fatal("competing update/delete failed unexpectedly", deleted, updateErr) + } + if _, err := public.GetCredential(t.Context(), tenant, vault.ID, value.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("concurrent update resurrected deleted metadata") + } + } +} diff --git a/services/agents-api/internal/store/vault_credentials_list.go b/services/agents-api/internal/store/vault_credentials_list.go new file mode 100644 index 000000000..07b79322e --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_list.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type CredentialPage struct { + Credentials []Credential + NextCursor string +} + +func (s *Store) ListCredentials(ctx context.Context, tenantID, vaultID, cursor string, limit int, ascending bool, statuses []string) (CredentialPage, error) { + // An inaccessible parent is not an authorized empty collection. + vault, err := s.GetVault(ctx, tenantID, vaultID) + if err != nil { + return CredentialPage{}, err + } + if limit < 1 || limit > 100 { + return CredentialPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) + } + if len(statuses) == 0 { + statuses = []string{"active", "archived"} + } + for _, status := range statuses { + if status != "active" && status != "archived" { + return CredentialPage{}, fmt.Errorf("%w: invalid Credential status", ErrInvalidInput) + } + } + tenant, _ := parseID(vault.TenantID) + parent, _ := parseID(vault.ID) + params := sqlc.ListCredentialsParams{TenantID: tenant, VaultID: parent, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} + if cursor != "" { + after, err := s.GetCredential(ctx, tenantID, vaultID, cursor) + if err != nil { + return CredentialPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListCredentials(ctx, params) + if err != nil { + return CredentialPage{}, fmt.Errorf("list credentials: %w", err) + } + page := CredentialPage{Credentials: make([]Credential, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + page.Credentials = append(page.Credentials, credentialFromRow(sqlc.GetCredentialRow(row))) + } + return page, nil +} diff --git a/services/agents-api/internal/store/vault_credentials_list_test.go b/services/agents-api/internal/store/vault_credentials_list_test.go new file mode 100644 index 000000000..62f64d889 --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_list_test.go @@ -0,0 +1,146 @@ +package store + +import ( + "cmp" + "errors" + "reflect" + "slices" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestCredentialListFilteringOwnershipAndKeylessReconnect(t *testing.T) { + reader, pool := testStore(t) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + var vaults []Vault + for _, owner := range []string{tenant, tenant, foreign, tenant} { + vault, err := reader.CreateVault(ctx, owner, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + vaults = append(vaults, vault) + } + // Parent classification does not classify its Credentials. + if _, err := pool.Exec(ctx, "UPDATE vaults SET status='archived' WHERE id=$1", vaults[0].ID); err != nil { + t.Fatal(err) + } + cipher, err := credentialcrypto.New(make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + writer := NewWithCredentialCipher(pool, cipher) + create := func(owner, vault string) Credential { + t.Helper() + c, err := writer.CreateStaticCredential(ctx, owner, vault, CreateStaticCredentialInput{Name: "List fixture", MCPServerURL: "https://example.invalid/mcp", Token: "synthetic-token-not-public"}) + if err != nil { + t.Fatal(err) + } + return c + } + var all []Credential + archived := map[string]bool{} + for i := range 105 { + c := create(tenant, vaults[0].ID) + status := "active" + if i%3 == 0 { + status, archived[c.ID] = "archived", true + } + if _, err := pool.Exec(ctx, "UPDATE vault_credentials SET status=$1, created_at=$2 WHERE id=$3", status, time.Unix(1700000000+int64(i%2), 0), c.ID); err != nil { + t.Fatal(err) + } + c, err = reader.GetCredential(ctx, tenant, vaults[0].ID, c.ID) + if err != nil { + t.Fatal(err) + } + all = append(all, c) + } + slices.SortFunc(all, func(a, b Credential) int { + if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) + }) + otherVault, otherProject := create(tenant, vaults[1].ID), create(foreign, vaults[2].ID) + snapshot := func(s *Store) string { + t.Helper() + var value string + if err := s.pool.QueryRow(ctx, "SELECT jsonb_agg(to_jsonb(c) ORDER BY id)::text FROM vault_credentials c WHERE vault_id=$1", vaults[0].ID).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + before := snapshot(reader) + read := func(s *Store, ascending bool, statuses []string, size int) []Credential { + t.Helper() + actual := []Credential{} + cursor := "" + for { + page, err := s.ListCredentials(ctx, tenant, vaults[0].ID, cursor, size, ascending, statuses) + if err != nil || len(page.Credentials) == 0 || len(page.Credentials) > size { + t.Fatal("invalid page", err) + } + actual = append(actual, page.Credentials...) + if len(actual) > len(all) { + t.Fatal("repeated pagination") + } + if page.NextCursor == "" { + break + } + if page.NextCursor != page.Credentials[len(page.Credentials)-1].ID { + t.Fatal("cursor is not last included Credential") + } + cursor = page.NextCursor + } + return actual + } + for _, ascending := range []bool{true, false} { + for _, statuses := range [][]string{nil, {"active"}, {"archived"}, {"active", "archived"}} { + want := []Credential{} + for _, c := range all { + if len(statuses) != 1 || archived[c.ID] == (statuses[0] == "archived") { + want = append(want, c) + } + } + if !ascending { + slices.Reverse(want) + } + for _, size := range []int{20, 100} { + if got := read(reader, ascending, statuses, size); !reflect.DeepEqual(got, want) { + t.Fatalf("metadata/filter/order mismatch: ascending=%t statuses=%v size=%d", ascending, statuses, size) + } + } + } + } + for _, tc := range []struct{ owner, vault, cursor string }{ + {tenant, vaults[0].ID, otherVault.ID}, {tenant, vaults[0].ID, otherProject.ID}, {tenant, vaults[0].ID, uuid.NewString()}, + {tenant, vaults[2].ID, ""}, {foreign, vaults[0].ID, ""}, {tenant, uuid.NewString(), ""}, + } { + if _, err := reader.ListCredentials(ctx, tc.owner, tc.vault, tc.cursor, 20, false, nil); !errors.Is(err, ErrNotFound) { + t.Fatal("unowned/unknown parent or cursor accepted", err) + } + } + for _, tc := range []struct{ vault, cursor string }{{vaults[0].ID, all[len(all)-1].ID}, {vaults[3].ID, ""}} { + page, err := reader.ListCredentials(ctx, tenant, tc.vault, tc.cursor, 20, true, nil) + if err != nil || page.Credentials == nil || len(page.Credentials) != 0 || page.NextCursor != "" { + t.Fatal("empty/terminal page", err) + } + } + for _, tc := range []struct { + owner, vault, cursor string + limit int + statuses []string + }{{"invalid", vaults[0].ID, "", 20, nil}, {tenant, "invalid", "", 20, nil}, {tenant, vaults[0].ID, "invalid", 20, nil}, {tenant, vaults[0].ID, "", 0, nil}, {tenant, vaults[0].ID, "", 101, nil}, {tenant, vaults[0].ID, "", 20, []string{"deleted"}}} { + if _, err := reader.ListCredentials(ctx, tc.owner, tc.vault, tc.cursor, tc.limit, false, tc.statuses); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid internal query accepted", err) + } + } + pool.Close() + reopened, _ := testStore(t) + if got := read(reopened, true, nil, 20); !reflect.DeepEqual(got, all) || snapshot(reopened) != before { + t.Fatal("keyless reads/restart changed metadata, classification or ciphertext") + } +} diff --git a/services/agents-api/internal/store/vault_credentials_test.go b/services/agents-api/internal/store/vault_credentials_test.go new file mode 100644 index 000000000..f36f1edaa --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestStaticCredentialsPersistEncryptedAndRemainScoped(t *testing.T) { + withoutKey, pool := testStore(t) + ctx := t.Context() + tenant, foreignTenant := uuid.NewString(), uuid.NewString() + var vaults []Vault + for _, owner := range []string{tenant, tenant, foreignTenant} { + vault, err := withoutKey.CreateVault(ctx, owner, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + vaults = append(vaults, vault) + } + key, randomToken := make([]byte, 32), make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(randomToken); err != nil { + t.Fatal(err) + } + newCipher := func(key []byte) *credentialcrypto.Cipher { + c, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + return c + } + s := NewWithCredentialCipher(pool, newCipher(key)) + canary := hex.EncodeToString(randomToken) + opaque := " \t" + canary + " 凭据\n" + strings.Repeat("x", 300) + " " + tokens := []string{opaque, opaque, ""} + var records []Credential + before := time.Now().Add(-time.Second) + for _, token := range tokens { + input := CreateStaticCredentialInput{Name: "MCP credential", MCPServerURL: "https://mcp.example/tools", Token: token} + record, err := s.CreateStaticCredential(ctx, tenant, vaults[0].ID, input) + if err != nil { + t.Fatal(err) + } + if _, err := uuid.Parse(record.ID); err != nil || record.VaultID != vaults[0].ID || record.Name != input.Name || record.AuthType != "static_bearer" || record.MCPServerURL != input.MCPServerURL || record.CreatedAt.Before(before) || record.CreatedAt.After(time.Now().Add(time.Second)) || !record.CreatedAt.Equal(record.UpdatedAt) { + t.Fatal("credential metadata or database timestamps differ") + } + metadata, err := json.Marshal(record) + if err != nil || bytes.Contains(metadata, []byte(canary)) { + t.Fatal("credential metadata contains token plaintext") + } + records = append(records, record) + } + if records[0].ID == records[1].ID { + t.Fatal("separate creates reused a credential identity") + } + valid := CreateStaticCredentialInput{Name: "Rejected", MCPServerURL: "https://mcp.example/tools", Token: opaque} + if _, err := withoutKey.CreateStaticCredential(ctx, tenant, vaults[0].ID, valid); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("missing encryption key did not fail writes closed") + } + for _, target := range []struct{ tenant, vault string }{{tenant, vaults[2].ID}, {foreignTenant, vaults[0].ID}, {tenant, uuid.NewString()}} { + if _, err := s.CreateStaticCredential(ctx, target.tenant, target.vault, valid); !errors.Is(err, ErrNotFound) { + t.Fatal("creation admitted an unowned or missing Vault") + } + } + for _, target := range []struct{ tenant, vault, credential string }{ + {tenant, vaults[1].ID, records[0].ID}, {foreignTenant, vaults[0].ID, records[0].ID}, + {tenant, vaults[2].ID, records[0].ID}, {tenant, uuid.NewString(), records[0].ID}, {tenant, vaults[0].ID, uuid.NewString()}, + } { + if _, err := s.GetCredential(ctx, target.tenant, target.vault, target.credential); !errors.Is(err, ErrNotFound) { + t.Fatal("unowned, wrong-Vault or missing credential was disclosed") + } + } + pool.Close() + withoutKey, pool = testStore(t) + restartedCipher := newCipher(bytes.Clone(key)) + wrongKey := bytes.Clone(key) + wrongKey[0] ^= 1 + wrongCipher := newCipher(wrongKey) + s = NewWithCredentialCipher(pool, restartedCipher) + var ciphertexts [][]byte + for i, record := range records { + for _, reader := range []*Store{withoutKey, s, NewWithCredentialCipher(pool, wrongCipher)} { + got, err := reader.GetCredential(ctx, tenant, record.VaultID, record.ID) + if err != nil || !reflect.DeepEqual(got, record) { + t.Fatal("safe metadata recovery depended on the encryption key", err) + } + } + var ciphertext []byte + var storageType string + if err := pool.QueryRow(ctx, "SELECT token_ciphertext, pg_typeof(token_ciphertext)::text FROM vault_credentials WHERE id=$1 AND vault_id=$2", record.ID, record.VaultID).Scan(&ciphertext, &storageType); err != nil || storageType != "bytea" || len(ciphertext) < 29 || bytes.Contains(ciphertext, []byte(canary)) { + t.Fatal("credential ciphertext was not stored as private bytea", err) + } + binding := credentialcrypto.Binding{TenantID: tenant, VaultID: record.VaultID, CredentialID: record.ID, AuthType: record.AuthType, Destination: record.MCPServerURL} + plaintext, err := restartedCipher.Open(ciphertext, binding) + if err != nil || !bytes.Equal(plaintext, []byte(tokens[i])) { + t.Fatal("private restart decryption did not preserve token bytes", err) + } + if plaintext, err := wrongCipher.Open(ciphertext, binding); err == nil || plaintext != nil { + t.Fatal("wrong key decrypted persisted ciphertext") + } + ciphertexts = append(ciphertexts, ciphertext) + } + // Version 1 prefixes the standard library's 12-byte random nonce. + if bytes.Equal(ciphertexts[0], ciphertexts[1]) || bytes.Equal(ciphertexts[0][1:13], ciphertexts[1][1:13]) { + t.Fatal("same token in distinct records reused ciphertext or nonce") + } + // Change one persisted binding field. Metadata GET must still work without a + // key, while private decryption must reject the altered stored destination. + if _, err := pool.Exec(ctx, "UPDATE vault_credentials SET mcp_server_url=$1 WHERE id=$2", "https://other.example/tools", records[0].ID); err != nil { + t.Fatal(err) + } + changed, err := withoutKey.GetCredential(ctx, tenant, vaults[0].ID, records[0].ID) + if err != nil || changed.MCPServerURL != "https://other.example/tools" { + t.Fatal("metadata GET unexpectedly required decryption", err) + } + binding := credentialcrypto.Binding{TenantID: tenant, VaultID: changed.VaultID, CredentialID: changed.ID, AuthType: changed.AuthType, Destination: changed.MCPServerURL} + if plaintext, err := restartedCipher.Open(ciphertexts[0], binding); err == nil || plaintext != nil { + t.Fatal("persisted destination substitution authenticated") + } + if _, err := pool.Exec(ctx, "UPDATE vault_credentials SET mcp_server_url=$1 WHERE id=$2", records[0].MCPServerURL, records[0].ID); err != nil { + t.Fatal(err) + } + for _, vault := range vaults { + got, err := withoutKey.GetVault(ctx, vault.TenantID, vault.ID) + if err != nil || !reflect.DeepEqual(got, vault) { + t.Fatal("credential operations changed an owning Vault", err) + } + } + var count, sessions int + if err := pool.QueryRow(ctx, "SELECT (SELECT count(*) FROM vault_credentials WHERE vault_id=ANY($1::uuid[])), (SELECT count(*) FROM sessions WHERE tenant_id=ANY($2::uuid[]))", []string{vaults[0].ID, vaults[1].ID, vaults[2].ID}, []string{tenant, foreignTenant}).Scan(&count, &sessions); err != nil || count != len(records) || sessions != 0 { + t.Fatal("rejected requests wrote rows or credential operations created Sessions", err) + } +} diff --git a/services/agents-api/internal/store/vault_credentials_update.go b/services/agents-api/internal/store/vault_credentials_update.go new file mode 100644 index 000000000..a85d75126 --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_update.go @@ -0,0 +1,60 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type UpdateStaticCredentialInput struct { + Token string +} + +// UpdateStaticCredential replaces only the secret and update time. Safe metadata +// supplies immutable AAD; the mutation independently checks that same scope. +// A subsequent dispatch reads the replacement through the existing frozen binding. +func (s *Store) UpdateStaticCredential(ctx context.Context, tenantID, vaultID, credentialID string, input UpdateStaticCredentialInput) (Credential, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Credential{}, ErrNotFound + } + vault, err := parseID(vaultID) + if err != nil { + return Credential{}, ErrNotFound + } + id, err := parseID(credentialID) + if err != nil { + return Credential{}, ErrNotFound + } + current, err := s.GetCredential(ctx, tenantID, vaultID, credentialID) + if err != nil { + return Credential{}, err + } + if current.AuthType != "static_bearer" { + return Credential{}, ErrInvalidInput + } + if s.credentialCipher == nil { + return Credential{}, ErrCredentialStorageUnavailable + } + ciphertext, err := s.credentialCipher.Seal([]byte(input.Token), credentialcrypto.Binding{ + TenantID: uuid.UUID(tenant.Bytes).String(), VaultID: current.VaultID, + CredentialID: current.ID, AuthType: current.AuthType, Destination: current.MCPServerURL, + }) + if err != nil { + return Credential{}, errors.New("credential encryption failed") + } + row, err := s.queries.UpdateStaticCredential(ctx, sqlc.UpdateStaticCredentialParams{ + TenantID: tenant, VaultID: vault, ID: id, McpServerUrl: current.MCPServerURL, TokenCiphertext: ciphertext, + }) + if errors.Is(err, pgx.ErrNoRows) { + return Credential{}, ErrNotFound + } + if err != nil { + return Credential{}, errors.New("credential update failed") + } + return credentialFromRow(sqlc.GetCredentialRow(row)), nil +} diff --git a/services/agents-api/internal/store/vault_credentials_update_test.go b/services/agents-api/internal/store/vault_credentials_update_test.go new file mode 100644 index 000000000..37ddbd459 --- /dev/null +++ b/services/agents-api/internal/store/vault_credentials_update_test.go @@ -0,0 +1,189 @@ +package store + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestStaticCredentialUpdatePreservesBindingsAndReplacesCurrentSecret(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var vaults []Vault + for _, owner := range []string{tenant, tenant, foreign} { + vault, err := s.CreateVault(t.Context(), owner, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + vaults = append(vaults, vault) + } + firstToken, endpoint := uuid.NewString(), "https://mcp.example/tools" + original, err := s.CreateStaticCredential(t.Context(), tenant, vaults[0].ID, CreateStaticCredentialInput{Name: "Retained name", MCPServerURL: endpoint, Token: firstToken}) + if err != nil { + t.Fatal(err) + } + unrelated, err := s.CreateStaticCredential(t.Context(), tenant, vaults[1].ID, CreateStaticCredentialInput{Name: "Unrelated", MCPServerURL: endpoint, Token: firstToken}) + if err != nil { + t.Fatal(err) + } + attached := []string{vaults[0].ID} + var sessions []Session + var bindings []MCPCredentialBinding + for index, id := range []*string{nil, &original.ID} { + selected, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: endpoint, CredentialID: id}}) + if err != nil || len(selected) != 1 { + t.Fatal("binding setup failed", err) + } + configuration, _ := json.Marshal(map[string]any{ + "agent": map[string]any{"model": "model", "tools": []any{map[string]any{"type": "mcp", "server_label": "tools", "transport": map[string]string{"type": "http", "server_url": endpoint}, "connection_origin": "service", "credential_id": id}}}, + "environment": map[string]string{"type": "none"}, "vault_ids": attached, "mcp_credentials": selected, + }) + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: []string{"implicit", "explicit"}[index], Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + sessions, bindings = append(sessions, session), append(bindings, selected[0]) + } + readCiphertext := func(id string) []byte { + t.Helper() + var ciphertext []byte + if err := pool.QueryRow(t.Context(), "SELECT token_ciphertext FROM vault_credentials WHERE id=$1", id).Scan(&ciphertext); err != nil { + t.Fatal("private ciphertext observation failed") + } + return ciphertext + } + prior, unrelatedCiphertext := readCiphertext(original.ID), readCiphertext(unrelated.ID) + lastToken := " \t" + uuid.NewString() + " 雪\n" + for _, token := range []string{"", lastToken, lastToken} { + updated, err := s.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: token}) + if err != nil { + t.Fatal("token replacement failed") + } + want := original + want.UpdatedAt = updated.UpdatedAt + if !reflect.DeepEqual(updated, want) || updated.UpdatedAt.Before(original.UpdatedAt) { + t.Fatal("replacement changed immutable metadata") + } + current := readCiphertext(original.ID) + if bytes.Equal(prior, current) || len(token) > 0 && bytes.Contains(current, []byte(token)) { + t.Fatal("replacement reused ciphertext or stored plaintext") + } + for _, binding := range bindings { + got, err := s.MCPBearerToken(t.Context(), tenant, attached, binding) + if err != nil || got != token { + t.Fatal("existing selection did not read the exact committed replacement") + } + } + prior = current + } + before, err := public.GetCredential(t.Context(), tenant, original.VaultID, original.ID) + if err != nil { + t.Fatal(err) + } + assertUnchanged := func() { + t.Helper() + after, err := public.GetCredential(t.Context(), tenant, original.VaultID, original.ID) + if err != nil || !reflect.DeepEqual(after, before) || !bytes.Equal(readCiphertext(original.ID), prior) { + t.Fatal("failed replacement changed the existing row") + } + } + for _, scope := range []struct{ tenant, vault, id string }{ + {foreign, original.VaultID, original.ID}, {tenant, vaults[1].ID, original.ID}, + {tenant, vaults[2].ID, original.ID}, {tenant, original.VaultID, uuid.NewString()}, {tenant, "invalid", original.ID}, + } { + if _, err := s.UpdateStaticCredential(t.Context(), scope.tenant, scope.vault, scope.id, UpdateStaticCredentialInput{Token: "rejected"}); !errors.Is(err, ErrNotFound) { + t.Fatal("unowned or invalid replacement was admitted") + } + assertUnchanged() + } + for _, writer := range []*Store{public, NewWithCredentialCipher(pool, &credentialcrypto.Cipher{})} { + if _, err := writer.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: "rejected"}); err == nil { + t.Fatal("missing or unusable cipher admitted replacement") + } + assertUnchanged() + } + // A real PostgreSQL mutation failure must preserve both ciphertext and time. + tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) + if err != nil { + t.Fatal(err) + } + readOnly := *s + readOnly.queries = s.queries.WithTx(tx) + _, updateErr := readOnly.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: "rejected"}) + _ = tx.Rollback(t.Context()) + if updateErr == nil || updateErr.Error() != "credential update failed" { + t.Fatal("database write failure was accepted or exposed") + } + assertUnchanged() + // A stale destination from a prior metadata read cannot authorize the UPDATE. + tenantID, _ := parseID(tenant) + vaultID, _ := parseID(original.VaultID) + credentialID, _ := parseID(original.ID) + _, err = s.queries.UpdateStaticCredential(t.Context(), sqlc.UpdateStaticCredentialParams{TenantID: tenantID, VaultID: vaultID, ID: credentialID, McpServerUrl: endpoint + "/other", TokenCiphertext: prior}) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatal("mutation failed to recheck immutable destination") + } + assertUnchanged() + // Replacing a damaged old payload needs no old-token decryption. + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext, 15, get_byte(token_ciphertext,15) # 1) WHERE id=$1", original.ID); err != nil { + t.Fatal(err) + } + if _, err := s.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: lastToken}); err != nil { + t.Fatal("replacement tried to decrypt the old token") + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + for index, session := range sessions { + got, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || !bytes.Equal(got.Configuration, session.Configuration) || got.LastTurn != nil { + t.Fatal("replacement changed an existing Session") + } + current, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[index]) + if err != nil || current != lastToken { + t.Fatal("reopened dispatch lookup lost the replacement") + } + } + // Competing whole-secret replacements may win in either order, never tear. + left, right := uuid.NewString()+strings.Repeat("L", 513), uuid.NewString()+strings.Repeat("R", 1025) + start, results := make(chan struct{}), make(chan error, 2) + for _, token := range []string{left, right} { + go func() { + <-start + _, err := s.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: token}) + results <- err + }() + } + close(start) + for range 2 { + if err := <-results; err != nil { + t.Fatal("concurrent replacement failed") + } + } + current, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]) + if err != nil || current != left && current != right { + t.Fatal("concurrent replacements produced an incomplete secret") + } + if !bytes.Equal(readCiphertext(unrelated.ID), unrelatedCiphertext) { + t.Fatal("replacement changed an unrelated Credential") + } +} diff --git a/services/agents-api/internal/store/vault_status_migration_test.go b/services/agents-api/internal/store/vault_status_migration_test.go new file mode 100644 index 000000000..e506d118b --- /dev/null +++ b/services/agents-api/internal/store/vault_status_migration_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestVaultStatusMigrationPreservesResources(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "vault_status_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 31); err != nil { + t.Fatal(err) + } + id, tenant := uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `INSERT INTO vaults(id,tenant_id,name,metadata) VALUES ($1,$2,'Existing Vault','{"purpose":"retained"}')`, id, tenant); err != nil { + t.Fatal(err) + } + snapshot := func() string { + t.Helper() + var value string + if err := db.QueryRowContext(ctx, "SELECT (to_jsonb(v)-'status')::text FROM vaults v WHERE id=$1", id).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + before := snapshot() + if _, err := provider.UpTo(ctx, 32); err != nil { + t.Fatal(err) + } + var status string + if err := db.QueryRowContext(ctx, "SELECT status FROM vaults WHERE id=$1", id).Scan(&status); err != nil || status != "active" || snapshot() != before { + t.Fatal("migration changed historical resource", status, err) + } + if err := db.QueryRowContext(ctx, "INSERT INTO vaults(id,tenant_id,metadata) VALUES ($1,$2,'{}') RETURNING status", uuid.NewString(), tenant).Scan(&status); err != nil || status != "active" { + t.Fatal("new Vault default", status, err) + } + for _, invalid := range []any{nil, "deleted"} { + if _, err := db.ExecContext(ctx, "UPDATE vaults SET status=$1 WHERE id=$2", invalid, id); err == nil { + t.Fatal("invalid classification accepted") + } + } + if _, err := provider.DownTo(ctx, 31); err != nil || snapshot() != before { + t.Fatal("reversible active-only migration", err) + } + if _, err := provider.UpTo(ctx, 32); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "UPDATE vaults SET status='archived' WHERE id=$1", id); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 31); err == nil || !strings.Contains(err.Error(), "Cannot remove archived Vault classification") { + t.Fatal("downgrade lost archived classification", err) + } + if err := db.QueryRowContext(ctx, "SELECT status FROM vaults WHERE id=$1", id).Scan(&status); err != nil || status != "archived" || snapshot() != before { + t.Fatal("failed downgrade changed data", status, err) + } +} diff --git a/services/agents-api/internal/store/vaults.go b/services/agents-api/internal/store/vaults.go new file mode 100644 index 000000000..63ce5f862 --- /dev/null +++ b/services/agents-api/internal/store/vaults.go @@ -0,0 +1,96 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" +) + +// Vault is a tenant-owned resource, independent of Sessions and engine execution. +type Vault struct { + ID string + TenantID string + Name *string + Metadata map[string]string + CreatedAt time.Time +} + +type CreateVaultInput struct { + Name *string + Metadata map[string]string +} + +// CreateVault persists the public layer's normalized name. Each call creates a +// distinct resource; this primitive does not define create retry semantics. +func (s *Store) CreateVault(ctx context.Context, tenantID string, input CreateVaultInput) (Vault, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Vault{}, err + } + var name pgtype.Text + if input.Name != nil { + if !validVaultName(*input.Name) { + return Vault{}, fmt.Errorf("%w: vault name must contain 1–256 UTF-8 bytes", ErrInvalidInput) + } + name = pgtype.Text{String: *input.Name, Valid: true} + } + metadata, err := encodeMetadata(input.Metadata) + if err != nil { + return Vault{}, err + } + row, err := s.queries.CreateVault(ctx, sqlc.CreateVaultParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Name: name, Metadata: metadata, + }) + if err != nil { + return Vault{}, fmt.Errorf("create vault: %w", err) + } + return vaultFromRow(row) +} + +func validVaultName(name string) bool { + return len(name) >= 1 && len(name) <= 256 && utf8.ValidString(name) +} + +// GetVault scopes every lookup to the authenticated caller's tenant. +func (s *Store) GetVault(ctx context.Context, tenantID, vaultID string) (Vault, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Vault{}, err + } + id, err := parseID(vaultID) + if err != nil { + return Vault{}, err + } + row, err := s.queries.GetVault(ctx, sqlc.GetVaultParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Vault{}, ErrNotFound + } + if err != nil { + return Vault{}, fmt.Errorf("get vault: %w", err) + } + return vaultFromRow(row) +} + +func vaultFromRow(row sqlc.Vault) (Vault, error) { + vault := Vault{ + ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), + CreatedAt: row.CreatedAt.Time, + } + if row.Name.Valid { + vault.Name = &row.Name.String + } + if err := json.Unmarshal(row.Metadata, &vault.Metadata); err != nil { + return Vault{}, fmt.Errorf("decode vault metadata: %w", err) + } + return vault, nil +} diff --git a/services/agents-api/internal/store/vaults_delete.go b/services/agents-api/internal/store/vaults_delete.go new file mode 100644 index 000000000..b960cf862 --- /dev/null +++ b/services/agents-api/internal/store/vaults_delete.go @@ -0,0 +1,30 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// DeleteVault relies on the owning foreign key to remove every stored Credential. +func (s *Store) DeleteVault(ctx context.Context, tenantID, vaultID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", ErrNotFound + } + id, err := parseID(vaultID) + if err != nil { + return "", ErrNotFound + } + deleted, err := s.queries.DeleteVault(ctx, sqlc.DeleteVaultParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", errors.New("vault deletion failed") + } + return uuid.UUID(deleted.Bytes).String(), nil +} diff --git a/services/agents-api/internal/store/vaults_delete_test.go b/services/agents-api/internal/store/vaults_delete_test.go new file mode 100644 index 000000000..830066a6c --- /dev/null +++ b/services/agents-api/internal/store/vaults_delete_test.go @@ -0,0 +1,205 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := bytes.Repeat([]byte{43}, 32) + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + createVault := func() Vault { + t.Helper() + v, err := public.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + return v + } + vault, retained, empty := createVault(), createVault(), createVault() + create := func(id, name, url string) Credential { + t.Helper() + v, err := s.CreateStaticCredential(t.Context(), tenant, id, CreateStaticCredentialInput{Name: name, MCPServerURL: url, Token: name + "-secret"}) + if err != nil { + t.Fatal(err) + } + return v + } + original := create(vault.ID, "original", "https://mcp.example/tools") + attached := []string{vault.ID, retained.ID} + selected, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL}}) + if err != nil || len(selected) != 1 { + t.Fatal("initial unique selection failed", err) + } + configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "model"}, "vault_ids": attached, "mcp_credentials": selected}) + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "retained", Configuration: configuration} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + extra := create(vault.ID, "archived", "https://mcp.example/other") + sibling := create(retained.ID, "sibling", original.MCPServerURL) + if _, err := pool.Exec(t.Context(), "UPDATE vaults SET status='archived' WHERE id=$1", vault.ID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET status='archived' WHERE id=$1", extra.ID); err != nil { + t.Fatal(err) + } + for _, scope := range []struct{ tenant, id string }{{foreign, vault.ID}, {tenant, uuid.NewString()}, {tenant, "invalid"}, {"invalid", vault.ID}} { + if _, err := public.DeleteVault(t.Context(), scope.tenant, scope.id); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign or invalid deletion was accepted", err) + } + } + tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) + if err != nil { + t.Fatal(err) + } + transactional := *public + transactional.queries = public.queries.WithTx(tx) + _, deletionErr := transactional.DeleteVault(t.Context(), tenant, vault.ID) + _ = tx.Rollback(t.Context()) + if deletionErr == nil || deletionErr.Error() != "vault deletion failed" { + t.Fatal("failed mutation was accepted or exposed") + } + tx, err = pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(t.Context()) }() + transactional.queries = public.queries.WithTx(tx) + if _, err := transactional.DeleteVault(t.Context(), tenant, vault.ID); err != nil { + t.Fatal(err) + } + var count int + if err := tx.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("cascade was not visible in the deletion transaction", err) + } + if err := tx.Rollback(t.Context()); err != nil { + t.Fatal(err) + } + if value, err := public.GetVault(t.Context(), tenant, vault.ID); err != nil || !reflect.DeepEqual(value, vault) { + t.Fatal("rollback changed the Vault", err) + } + for _, expected := range []Credential{original, extra} { + if value, err := public.GetCredential(t.Context(), tenant, vault.ID, expected.ID); err != nil || !reflect.DeepEqual(value, expected) { + t.Fatal("rollback changed a child", err) + } + } + if token, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); err != nil || token != "original-secret" { + t.Fatal("rejected deletion changed the stored token") + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=decode('00','hex') WHERE vault_id=$1", vault.ID); err != nil { + t.Fatal(err) + } + for _, target := range []Vault{empty, vault} { + if id, err := public.DeleteVault(t.Context(), tenant, target.ID); err != nil || id != target.ID { + t.Fatal("keyless deletion failed", err) + } + } + if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("committed parent or encrypted children remain", err) + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + for _, target := range []Vault{empty, vault} { + if _, err := public.GetVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Vault reappeared after restart") + } + if _, err := public.DeleteVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("repeated deletion did not remain absent") + } + } + for _, child := range []Credential{original, extra} { + if _, err := public.GetCredential(t.Context(), tenant, vault.ID, child.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted child reappeared") + } + if _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, child.ID, UpdateStaticCredentialInput{Token: "replacement"}); !errors.Is(err, ErrNotFound) { + t.Fatal("replacement recreated a deleted child") + } + } + if _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: "late", MCPServerURL: original.MCPServerURL, Token: "late"}); !errors.Is(err, ErrNotFound) { + t.Fatal("new child was admitted under a deleted Vault") + } + if _, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); !errors.Is(err, ErrNotFound) { + t.Fatal("frozen binding reselected a credential in another attached Vault") + } + if _, err := public.ListCredentials(t.Context(), tenant, vault.ID, "", 100, true, []string{"active", "archived"}); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted parent remained listable") + } + if value, err := public.GetVault(t.Context(), tenant, retained.ID); err != nil || !reflect.DeepEqual(value, retained) { + t.Fatal("deletion changed another Vault") + } + if value, err := public.GetCredential(t.Context(), tenant, retained.ID, sibling.ID); err != nil || !reflect.DeepEqual(value, sibling) { + t.Fatal("deletion changed another Vault's credential") + } + if retry, err := public.CreateSession(t.Context(), tenant, input); err != nil || retry.ID != session.ID || !bytes.Equal(retry.Configuration, session.Configuration) { + t.Fatal("deletion changed frozen creation identity", err) + } +} + +func TestVaultDeletionConcurrentChildMutations(t *testing.T) { + public, pool := testStore(t) + tenant := uuid.NewString() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{44}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + for range 8 { + vault, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + input := CreateStaticCredentialInput{Name: "competing", MCPServerURL: "https://mcp.example/tools", Token: "before"} + value, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) + if err != nil { + t.Fatal(err) + } + start, created, updated, removed := make(chan struct{}), make(chan error, 1), make(chan error, 1), make(chan error, 1) + go func() { + <-start + _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) + created <- err + }() + go func() { + <-start + _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, value.ID, UpdateStaticCredentialInput{Token: "after"}) + updated <- err + }() + go func() { + <-start + _, err := public.DeleteCredential(t.Context(), tenant, vault.ID, value.ID) + removed <- err + }() + close(start) + _, deleted := public.DeleteVault(t.Context(), tenant, vault.ID) + createErr, updateErr, removeErr := <-created, <-updated, <-removed + var constraint *pgconn.PgError + if createErr != nil && !errors.Is(createErr, ErrNotFound) && !(errors.As(createErr, &constraint) && constraint.Code == "23503") { + t.Fatal("competing creation failed unexpectedly", createErr) + } + if deleted != nil || updateErr != nil && !errors.Is(updateErr, ErrNotFound) || removeErr != nil && !errors.Is(removeErr, ErrNotFound) { + t.Fatal("competing mutation failed unexpectedly", deleted, updateErr, removeErr) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("concurrent mutation resurrected deleted resources", err) + } + } +} diff --git a/services/agents-api/internal/store/vaults_list.go b/services/agents-api/internal/store/vaults_list.go new file mode 100644 index 000000000..cb2c30033 --- /dev/null +++ b/services/agents-api/internal/store/vaults_list.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type VaultPage struct { + Vaults []Vault + NextCursor string +} + +func (s *Store) ListVaults(ctx context.Context, tenantID, cursor string, limit int, ascending bool, statuses []string) (VaultPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return VaultPage{}, err + } + if limit < 1 || limit > 100 { + return VaultPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) + } + if len(statuses) == 0 { + statuses = []string{"active", "archived"} + } + for _, status := range statuses { + if status != "active" && status != "archived" { + return VaultPage{}, fmt.Errorf("%w: invalid Vault status", ErrInvalidInput) + } + } + params := sqlc.ListVaultsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} + if cursor != "" { + after, err := s.GetVault(ctx, tenantID, cursor) + if err != nil { + return VaultPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListVaults(ctx, params) + if err != nil { + return VaultPage{}, fmt.Errorf("list vaults: %w", err) + } + page := VaultPage{Vaults: make([]Vault, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + vault, err := vaultFromRow(row) + if err != nil { + return VaultPage{}, err + } + page.Vaults = append(page.Vaults, vault) + } + return page, nil +} diff --git a/services/agents-api/internal/store/vaults_list_test.go b/services/agents-api/internal/store/vaults_list_test.go new file mode 100644 index 000000000..7ca6bd067 --- /dev/null +++ b/services/agents-api/internal/store/vaults_list_test.go @@ -0,0 +1,122 @@ +package store + +import ( + "cmp" + "errors" + "reflect" + "slices" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestVaultListFilteringPaginationAndReconnect(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + tenant, other := uuid.NewString(), uuid.NewString() + empty, err := s.ListVaults(ctx, tenant, "", 20, false, nil) + if err != nil || empty.Vaults == nil || len(empty.Vaults) != 0 || empty.NextCursor != "" { + t.Fatalf("empty page: %+v, %v", empty, err) + } + var all []Vault + archived := map[string]bool{} + for i := range 105 { + vault, err := s.CreateVault(ctx, tenant, CreateVaultInput{Metadata: map[string]string{"purpose": "safe list fixture"}}) + if err != nil { + t.Fatal(err) + } + status := "active" + if i%3 == 0 { + status, archived[vault.ID] = "archived", true + } + vault.CreatedAt = time.Unix(1700000000+int64(i%2), 0).UTC() + // Synthetic classifications exercise reads, not a public archive lifecycle. + if _, err := pool.Exec(ctx, "UPDATE vaults SET created_at=$1, status=$2 WHERE tenant_id=$3 AND id=$4", vault.CreatedAt, status, tenant, vault.ID); err != nil { + t.Fatal(err) + } + vault, err = s.GetVault(ctx, tenant, vault.ID) + if err != nil { + t.Fatal(err) + } + all = append(all, vault) + } + slices.SortFunc(all, func(a, b Vault) int { + if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) + }) + foreign, err := s.CreateVault(ctx, other, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + read := func(s *Store, ascending bool, statuses []string, size int) []Vault { + t.Helper() + actual := []Vault{} + cursor := "" + for { + page, err := s.ListVaults(ctx, tenant, cursor, size, ascending, statuses) + if err != nil || len(page.Vaults) == 0 || len(page.Vaults) > size { + t.Fatalf("page: %+v, %v", page, err) + } + actual = append(actual, page.Vaults...) + if len(actual) > len(all) { + t.Fatal("pagination repeated records") + } + if page.NextCursor == "" { + break + } + if page.NextCursor != page.Vaults[len(page.Vaults)-1].ID { + t.Fatal("cursor is not the last included resource") + } + cursor = page.NextCursor + } + return actual + } + for _, ascending := range []bool{true, false} { + for _, statuses := range [][]string{nil, {"active"}, {"archived"}, {"active", "archived"}} { + want := []Vault{} + for _, vault := range all { + if len(statuses) != 1 || archived[vault.ID] == (statuses[0] == "archived") { + want = append(want, vault) + } + } + if !ascending { + slices.Reverse(want) + } + for _, size := range []int{20, 100} { + if got := read(s, ascending, statuses, size); !reflect.DeepEqual(got, want) { + t.Fatalf("filtered ordering/projection mismatch: ascending=%t statuses=%v size=%d got=%d want=%d", ascending, statuses, size, len(got), len(want)) + } + } + } + } + for _, cursor := range []string{foreign.ID, uuid.NewString()} { + if _, err := s.ListVaults(ctx, tenant, cursor, 20, true, []string{"archived"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign/unknown cursor: %v", err) + } + } + for _, tc := range []struct { + tenant, cursor string + limit int + statuses []string + }{{"invalid", "", 20, nil}, {tenant, "invalid", 20, nil}, {tenant, "", 0, nil}, {tenant, "", 101, nil}, {tenant, "", 20, []string{"deleted"}}} { + if _, err := s.ListVaults(ctx, tc.tenant, tc.cursor, tc.limit, true, tc.statuses); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid store query: %v", err) + } + } + tail, err := s.ListVaults(ctx, tenant, all[len(all)-1].ID, 100, true, nil) + if err != nil || tail.Vaults == nil || len(tail.Vaults) != 0 || tail.NextCursor != "" { + t.Fatalf("terminal page: %+v, %v", tail, err) + } + page, err := s.ListVaults(ctx, other, "", 100, false, nil) + if err != nil || !reflect.DeepEqual(page.Vaults, []Vault{foreign}) || page.NextCursor != "" { + t.Fatalf("project isolation: %+v, %v", page, err) + } + pool.Close() + reopened, _ := testStore(t) + if got := read(reopened, true, nil, 20); !reflect.DeepEqual(got, all) { + t.Fatal("listing changed after reconnect") + } +} diff --git a/services/agents-api/internal/store/vaults_test.go b/services/agents-api/internal/store/vaults_test.go new file mode 100644 index 000000000..09e50a090 --- /dev/null +++ b/services/agents-api/internal/store/vaults_test.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestVaultsPersistAndStayTenantScoped(t *testing.T) { + s, pool := testStore(t) + ctx := context.Background() + tenantA, tenantB := uuid.NewString(), uuid.NewString() + before := time.Now().Add(-time.Second) + unnamed, err := s.CreateVault(ctx, tenantA, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + if _, err := uuid.Parse(unnamed.ID); err != nil || unnamed.TenantID != tenantA || unnamed.Name != nil || unnamed.Metadata == nil || len(unnamed.Metadata) != 0 || unnamed.CreatedAt.Before(before) || unnamed.CreatedAt.After(time.Now().Add(time.Second)) { + t.Fatalf("unexpected unnamed vault: %+v, %v", unnamed, err) + } + // Validate the byte boundary with multibyte text, without Session metadata + // count or character limits. Public name trimming belongs to the API layer. + name := strings.Repeat("é", 128) + input := CreateVaultInput{Name: &name, Metadata: map[string]string{"": "", "purpose": "保存 configuration"}} + named, err := s.CreateVault(ctx, tenantA, input) + if err != nil || named.ID == unnamed.ID || named.Name == nil || *named.Name != name || !reflect.DeepEqual(named.Metadata, input.Metadata) { + t.Fatalf("unexpected named vault: %+v, %v", named, err) + } + for _, tenant := range []string{tenantA, tenantB} { + other, err := s.CreateVault(ctx, tenant, input) + if err != nil || other.ID == named.ID || other.TenantID != tenant { + t.Fatalf("distinct resource creation: %+v, %v", other, err) + } + } + for _, lookup := range []struct{ tenant, id string }{{tenantB, named.ID}, {tenantA, uuid.NewString()}} { + if _, err := s.GetVault(ctx, lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { + t.Fatalf("unowned/unknown vault lookup: %v", err) + } + } + // Recreate the pool and Store as a restarted standalone service would. + pool.Close() + reopened, _ := testStore(t) + for _, want := range []Vault{unnamed, named} { + got, err := reopened.GetVault(ctx, tenantA, want.ID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("durable read: %+v, %v; want %+v", got, err, want) + } + } + var sessions int + if err := reopened.pool.QueryRow(ctx, "SELECT count(*) FROM sessions WHERE tenant_id = $1", tenantA).Scan(&sessions); err != nil || sessions != 0 { + t.Fatalf("Vault creation produced %d Sessions: %v", sessions, err) + } +} + +func TestVaultsRejectInvalidStoreInputWithoutWrites(t *testing.T) { + s, _ := testStore(t) + ctx := context.Background() + tenant := uuid.NewString() + for _, name := range []string{"", strings.Repeat("x", 257), strings.Repeat("é", 129), string([]byte{0xff})} { + if _, err := s.CreateVault(ctx, tenant, CreateVaultInput{Name: &name}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid name length %d: %v", len(name), err) + } + } + for _, invalid := range []string{"", "not-a-uuid", uuid.Nil.String()} { + if _, err := s.CreateVault(ctx, invalid, CreateVaultInput{}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid create tenant accepted: %v", err) + } + if _, err := s.GetVault(ctx, invalid, uuid.NewString()); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid read tenant accepted: %v", err) + } + if _, err := s.GetVault(ctx, tenant, invalid); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid vault ID accepted: %v", err) + } + } + if _, err := s.CreateVault(ctx, tenant, CreateVaultInput{Metadata: map[string]string{"large": strings.Repeat("x", 64*1024)}}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("oversized metadata accepted: %v", err) + } + var count int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM vaults WHERE tenant_id = $1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatalf("invalid input created %d Vaults: %v", count, err) + } +} diff --git a/services/agents-api/internal/store/worker_lease_loss_test.go b/services/agents-api/internal/store/worker_lease_loss_test.go new file mode 100644 index 000000000..d54918a11 --- /dev/null +++ b/services/agents-api/internal/store/worker_lease_loss_test.go @@ -0,0 +1,94 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { + h := newDispatchHarness(t) + _, pool := store.NewTestStore(t) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true}}}}) + h.session = publicSession(t, h, "active") + queued := publicSession(t, h, "queued") + worker, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Error("worker did not stop") + } + }) + inputs := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"execute"}]}]}`)}} + receipts, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "active", inputs) + if err != nil { + t.Fatal(err) + } + request := h.read(proto.TypePromptRequest) + if request.ID != receipts[0].TurnID { + t.Fatal("unexpected dispatch", request.ID) + } + // This worker is the only holder of the specific execution advisory lock. + // Select it within the dedicated test DB, never terminate pooled backends. + var pid uint32 + err = pool.QueryRow(t.Context(), `SELECT pid FROM pg_locks WHERE locktype='advisory' + AND database=(SELECT oid FROM pg_database WHERE datname=current_database()) + AND classid=(706172736172::bigint >> 32)::oid + AND objid=(706172736172::bigint & 4294967295)::oid AND objsubid=1 AND granted`).Scan(&pid) + if err != nil { + t.Fatal(err) + } + var killed bool + if err = pool.QueryRow(t.Context(), "SELECT pg_terminate_backend($1, 1000)", pid).Scan(&killed); err != nil || !killed { + t.Fatal(killed, err) + } + // Admission is independent of the lost execution writer. No new model request + // is required to preserve this queued input for the successor. + pending, err := worker.SubmitInputs(t.Context(), h.tenant, queued.ID, "queued", inputs) + if err != nil { + t.Fatal("pooled admission failed after lease loss", err) + } + select { + case err = <-done: + done <- err // Retain the completion for cleanup. + if err == nil { + t.Fatal("worker ignored lease loss") + } + case <-time.After(12 * time.Second): + t.Fatal("worker ignored lease loss") + } + active, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, request.ID) + if err != nil || active.Status != store.TurnInProgress { + t.Fatal("lost owner persisted fallback completion", active, err) + } + successor, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + stopped, stop := context.WithCancel(t.Context()) + stop() + if err = successor.Run(stopped); err != context.Canceled { + t.Fatal(err) + } + active, err = h.s.GetTurn(t.Context(), h.tenant, h.session.ID, request.ID) + if err != nil || active.Status != store.TurnFailed { + t.Fatal("successor did not reconcile", active, err) + } + next, err := h.s.GetTurn(t.Context(), h.tenant, queued.ID, pending[0].TurnID) + if err != nil || next.Status != store.TurnQueued { + t.Fatal("successor lost queued work", next, err) + } +} diff --git a/services/agents-api/migrations/000001_sessions.sql b/services/agents-api/migrations/000001_sessions.sql new file mode 100644 index 000000000..1ceeb5e7c --- /dev/null +++ b/services/agents-api/migrations/000001_sessions.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE sessions ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + engine text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + idempotency_key text NOT NULL, + request_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (tenant_id, idempotency_key) +); +CREATE INDEX sessions_tenant_created_idx ON sessions (tenant_id, created_at DESC, id DESC); + +-- +goose Down +DROP TABLE sessions; diff --git a/services/agents-api/migrations/000002_session_configuration.sql b/services/agents-api/migrations/000002_session_configuration.sql new file mode 100644 index 000000000..f97c2d629 --- /dev/null +++ b/services/agents-api/migrations/000002_session_configuration.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE sessions ADD COLUMN configuration jsonb NOT NULL DEFAULT '{}'::jsonb + CHECK (jsonb_typeof(configuration) = 'object'); + +-- +goose Down +ALTER TABLE sessions DROP COLUMN configuration; diff --git a/services/agents-api/migrations/000003_turns.sql b/services/agents-api/migrations/000003_turns.sql new file mode 100644 index 000000000..879bd9213 --- /dev/null +++ b/services/agents-api/migrations/000003_turns.sql @@ -0,0 +1,35 @@ +-- +goose Up +CREATE TABLE turns ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES sessions(id), + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'in_progress', 'waiting', 'completed', 'failed', 'cancelled')), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + started_at timestamptz, + completed_at timestamptz, + cancel_requested_at timestamptz, + outcome jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(outcome) = 'object'), + UNIQUE (session_id, id), + CHECK ((completed_at IS NOT NULL) = (status IN ('completed', 'failed', 'cancelled'))) +); +CREATE UNIQUE INDEX turns_one_active_idx ON turns(session_id) + WHERE status IN ('queued', 'in_progress', 'waiting'); + +-- Inputs also retain retry identity after their target Turn has ended. +CREATE TABLE turn_inputs ( + sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id uuid NOT NULL REFERENCES sessions(id), + turn_id uuid, + idempotency_key text NOT NULL, + kind text NOT NULL CHECK (kind IN ('message', 'cancel')), + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (session_id, idempotency_key), + FOREIGN KEY (session_id, turn_id) REFERENCES turns(session_id, id), + CHECK (kind <> 'message' OR turn_id IS NOT NULL) +); +CREATE INDEX turn_inputs_turn_sequence_idx ON turn_inputs(turn_id, sequence); + +-- +goose Down +DROP TABLE turn_inputs; +DROP TABLE turns; diff --git a/services/agents-api/migrations/000004_input_batches.sql b/services/agents-api/migrations/000004_input_batches.sql new file mode 100644 index 000000000..a6b2845ec --- /dev/null +++ b/services/agents-api/migrations/000004_input_batches.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Existing single-event requests retain their retry identity at position zero. +ALTER TABLE turn_inputs ADD COLUMN batch_position integer NOT NULL DEFAULT 0 + CHECK (batch_position >= 0); +ALTER TABLE turn_inputs DROP CONSTRAINT turn_inputs_session_id_idempotency_key_key; +ALTER TABLE turn_inputs ADD UNIQUE (session_id, idempotency_key, batch_position); + +-- +goose Down +-- A multi-event request cannot be represented by the previous schema without +-- losing inputs or retry identities. Let the uniqueness check stop such a downgrade. +ALTER TABLE turn_inputs ADD UNIQUE (session_id, idempotency_key); +ALTER TABLE turn_inputs DROP COLUMN batch_position; diff --git a/services/agents-api/migrations/000005_devices.sql b/services/agents-api/migrations/000005_devices.sql new file mode 100644 index 000000000..514bfc0fe --- /dev/null +++ b/services/agents-api/migrations/000005_devices.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE devices ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + name text NOT NULL, + credential_hash text NOT NULL CHECK (credential_hash ~ '^[0-9a-f]{64}$'), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_seen_at timestamptz, + revoked_at timestamptz +); +CREATE INDEX devices_tenant_idx ON devices (tenant_id); + +CREATE TABLE session_devices ( + session_id uuid PRIMARY KEY REFERENCES sessions(id), + device_id uuid NOT NULL REFERENCES devices(id) +); + +-- +goose Down +DROP TABLE session_devices; +DROP TABLE devices; diff --git a/services/agents-api/migrations/000006_native_sessions.sql b/services/agents-api/migrations/000006_native_sessions.sql new file mode 100644 index 000000000..05ca6bf5e --- /dev/null +++ b/services/agents-api/migrations/000006_native_sessions.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE session_devices ADD COLUMN native_session_id text NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE session_devices DROP COLUMN native_session_id; diff --git a/services/agents-api/migrations/000007_turn_events.sql b/services/agents-api/migrations/000007_turn_events.sql new file mode 100644 index 000000000..2a73d02b9 --- /dev/null +++ b/services/agents-api/migrations/000007_turn_events.sql @@ -0,0 +1,19 @@ +-- +goose Up +ALTER TABLE turns ADD COLUMN event_count integer NOT NULL DEFAULT 0; +ALTER TABLE turns ADD COLUMN event_bytes bigint NOT NULL DEFAULT 0; + +CREATE TABLE turn_events ( + session_id uuid NOT NULL, + turn_id uuid NOT NULL, + ordinal integer NOT NULL CHECK (ordinal > 0), + kind text NOT NULL, + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (turn_id, ordinal), + FOREIGN KEY (session_id, turn_id) REFERENCES turns(session_id, id) +); + +-- +goose Down +DROP TABLE turn_events; +ALTER TABLE turns DROP COLUMN event_bytes; +ALTER TABLE turns DROP COLUMN event_count; diff --git a/services/agents-api/migrations/000008_session_items.sql b/services/agents-api/migrations/000008_session_items.sql new file mode 100644 index 000000000..c0e680bea --- /dev/null +++ b/services/agents-api/migrations/000008_session_items.sql @@ -0,0 +1,26 @@ +-- +goose Up +ALTER TABLE turns ADD COLUMN items_indexed boolean NOT NULL DEFAULT false; +ALTER TABLE turns ALTER COLUMN items_indexed SET DEFAULT true; + +CREATE TABLE session_items ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL, + turn_id uuid NOT NULL, + created_at timestamptz NOT NULL, + position integer NOT NULL DEFAULT 0, + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + FOREIGN KEY (session_id, turn_id) REFERENCES turns(session_id, id) +); +CREATE INDEX session_items_page_idx ON session_items(session_id, created_at, position, id); +CREATE INDEX session_items_turn_idx ON session_items(turn_id); + +CREATE INDEX turns_unindexed_items_idx ON turns(session_id, created_at, id) WHERE NOT items_indexed; +CREATE INDEX turn_inputs_item_history_idx ON turn_inputs(turn_id, created_at, sequence) WHERE kind = 'message'; +CREATE INDEX turn_events_item_history_idx ON turn_events(turn_id, created_at, ordinal); + +-- +goose Down +DROP INDEX turn_events_item_history_idx; +DROP INDEX turn_inputs_item_history_idx; +DROP INDEX turns_unindexed_items_idx; +DROP TABLE session_items; +ALTER TABLE turns DROP COLUMN items_indexed; diff --git a/services/agents-api/migrations/000009_execution_queue.sql b/services/agents-api/migrations/000009_execution_queue.sql new file mode 100644 index 000000000..60bc6284e --- /dev/null +++ b/services/agents-api/migrations/000009_execution_queue.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE INDEX turns_execution_queue_idx ON turns (status, id) +WHERE status IN ('queued', 'in_progress', 'waiting'); + +-- +goose Down +DROP INDEX turns_execution_queue_idx; diff --git a/services/agents-api/migrations/000010_token_usage.sql b/services/agents-api/migrations/000010_token_usage.sql new file mode 100644 index 000000000..c9bad6629 --- /dev/null +++ b/services/agents-api/migrations/000010_token_usage.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE turns ADD COLUMN token_usage jsonb; + +-- +goose Down +ALTER TABLE turns DROP COLUMN token_usage; diff --git a/services/agents-api/migrations/000011_item_order.sql b/services/agents-api/migrations/000011_item_order.sql new file mode 100644 index 000000000..0b6ac41c2 --- /dev/null +++ b/services/agents-api/migrations/000011_item_order.sql @@ -0,0 +1,23 @@ +-- +goose Up +ALTER TABLE session_items ADD COLUMN output_index integer CHECK (output_index >= 0); + +WITH ordered AS ( + SELECT id, + (row_number() OVER (PARTITION BY session_id ORDER BY created_at, position, id) - 1)::integer AS item_position, + CASE WHEN payload->>'role' IS DISTINCT FROM 'user' THEN + (count(*) FILTER (WHERE payload->>'role' IS DISTINCT FROM 'user') OVER ( + PARTITION BY turn_id ORDER BY created_at, position, id ROWS UNBOUNDED PRECEDING + ) - 1)::integer + END AS output_index + FROM session_items +) +UPDATE session_items i SET position = ordered.item_position, output_index = ordered.output_index +FROM ordered WHERE i.id = ordered.id; + +CREATE UNIQUE INDEX session_items_position_idx ON session_items(session_id, position); +CREATE UNIQUE INDEX session_items_output_idx ON session_items(turn_id, output_index); + +-- +goose Down +DROP INDEX session_items_output_idx; +DROP INDEX session_items_position_idx; +ALTER TABLE session_items DROP COLUMN output_index; diff --git a/services/agents-api/migrations/000012_session_events.sql b/services/agents-api/migrations/000012_session_events.sql new file mode 100644 index 000000000..a23e6c664 --- /dev/null +++ b/services/agents-api/migrations/000012_session_events.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE sessions ADD COLUMN event_sequence bigint NOT NULL DEFAULT 0; +CREATE TABLE session_events ( + session_id uuid NOT NULL REFERENCES sessions(id), + sequence bigint NOT NULL, + payload jsonb NOT NULL, + payload_bytes integer GENERATED ALWAYS AS (octet_length(payload::text)) STORED, + PRIMARY KEY (session_id, sequence) +); + +-- +goose Down +DROP TABLE session_events; +ALTER TABLE sessions DROP COLUMN event_sequence; diff --git a/services/agents-api/migrations/000013_function_calls.sql b/services/agents-api/migrations/000013_function_calls.sql new file mode 100644 index 000000000..f514e456b --- /dev/null +++ b/services/agents-api/migrations/000013_function_calls.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE function_calls ( + session_id uuid NOT NULL, + turn_id uuid NOT NULL, + call_id text NOT NULL, + executor_call_id text NOT NULL, + name text NOT NULL, + arguments jsonb NOT NULL, + result jsonb, + applied boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (session_id, turn_id, call_id), + UNIQUE (session_id, turn_id, executor_call_id), + FOREIGN KEY (session_id, turn_id) REFERENCES turns(session_id, id), + CHECK (result IS NULL OR jsonb_typeof(result) = 'object'), + CHECK (NOT applied OR result IS NOT NULL) +); +CREATE INDEX function_calls_pending_idx ON function_calls(session_id, turn_id) WHERE NOT applied; + +-- +goose Down +DROP TABLE function_calls; diff --git a/services/agents-api/migrations/000014_function_inputs.sql b/services/agents-api/migrations/000014_function_inputs.sql new file mode 100644 index 000000000..e185df786 --- /dev/null +++ b/services/agents-api/migrations/000014_function_inputs.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE turn_inputs DROP CONSTRAINT turn_inputs_kind_check; +ALTER TABLE turn_inputs ADD CONSTRAINT turn_inputs_kind_check + CHECK (kind IN ('message', 'cancel', 'tool_result')); +ALTER TABLE turn_inputs ADD CONSTRAINT turn_inputs_function_turn_check + CHECK (kind <> 'tool_result' OR turn_id IS NOT NULL); + +-- +goose Down +-- Refuse a downgrade with saved function inputs rather than delete retry history. +ALTER TABLE turn_inputs DROP CONSTRAINT turn_inputs_kind_check; +ALTER TABLE turn_inputs ADD CONSTRAINT turn_inputs_kind_check + CHECK (kind IN ('message', 'cancel')); +ALTER TABLE turn_inputs DROP CONSTRAINT turn_inputs_function_turn_check; diff --git a/services/agents-api/migrations/000015_retire_item_backfill.sql b/services/agents-api/migrations/000015_retire_item_backfill.sql new file mode 100644 index 000000000..16ad3e312 --- /dev/null +++ b/services/agents-api/migrations/000015_retire_item_backfill.sql @@ -0,0 +1,16 @@ +-- +goose Up +LOCK TABLE turns IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM turns WHERE NOT items_indexed) THEN + RAISE EXCEPTION 'Unindexed Agents API history: prepare all Session Items with release 906069e before upgrading; see services/agents-api/README.md'; + END IF; +END +$$; +-- +goose StatementEnd +ALTER TABLE turns DROP COLUMN items_indexed; + +-- +goose Down +ALTER TABLE turns ADD COLUMN items_indexed boolean NOT NULL DEFAULT true; +CREATE INDEX turns_unindexed_items_idx ON turns(session_id, created_at, id) WHERE NOT items_indexed; diff --git a/services/agents-api/migrations/000016_agents.sql b/services/agents-api/migrations/000016_agents.sql new file mode 100644 index 000000000..0a72c5b91 --- /dev/null +++ b/services/agents-api/migrations/000016_agents.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE agents ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + metadata jsonb NOT NULL CHECK (jsonb_typeof(metadata) = 'object'), + configuration jsonb NOT NULL CHECK (jsonb_typeof(configuration) = 'object'), + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + updated_at timestamptz NOT NULL DEFAULT statement_timestamp() +); +CREATE INDEX agents_tenant_created_idx ON agents (tenant_id, created_at DESC, id DESC); + +-- +goose Down +DROP TABLE agents; diff --git a/services/agents-api/migrations/000017_session_creation_identity.sql b/services/agents-api/migrations/000017_session_creation_identity.sql new file mode 100644 index 000000000..d2a72e176 --- /dev/null +++ b/services/agents-api/migrations/000017_session_creation_identity.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE sessions ADD COLUMN creation_request_hash text + CHECK (creation_request_hash ~ '^[0-9a-f]{64}$'); + +-- +goose Down +ALTER TABLE sessions DROP COLUMN creation_request_hash; diff --git a/services/agents-api/migrations/000018_session_agent_filter.sql b/services/agents-api/migrations/000018_session_agent_filter.sql new file mode 100644 index 000000000..acad6ec8c --- /dev/null +++ b/services/agents-api/migrations/000018_session_agent_filter.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE INDEX sessions_tenant_agent_created_idx +ON sessions (tenant_id, (configuration #>> '{agent,id}'), created_at DESC, id DESC); + +-- +goose Down +DROP INDEX sessions_tenant_agent_created_idx; diff --git a/services/agents-api/migrations/000019_session_deletion.sql b/services/agents-api/migrations/000019_session_deletion.sql new file mode 100644 index 000000000..ec0388c3b --- /dev/null +++ b/services/agents-api/migrations/000019_session_deletion.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE sessions ADD COLUMN deleted_at timestamptz; + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM sessions WHERE deleted_at IS NOT NULL) THEN + RAISE EXCEPTION 'Cannot remove Session deletion markers while deleted Sessions exist'; + END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE sessions DROP COLUMN deleted_at; diff --git a/services/agents-api/migrations/000020_environments.sql b/services/agents-api/migrations/000020_environments.sql new file mode 100644 index 000000000..91a41dd7d --- /dev/null +++ b/services/agents-api/migrations/000020_environments.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE environments ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL UNIQUE REFERENCES sessions(id), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'connected', 'disconnected', 'expired', 'failed')), + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +-- +goose Down +LOCK TABLE environments IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environments) THEN + RAISE EXCEPTION 'Cannot remove durable Environment identities while Environments exist'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE environments; diff --git a/services/agents-api/migrations/000021_environment_executor_credentials.sql b/services/agents-api/migrations/000021_environment_executor_credentials.sql new file mode 100644 index 000000000..aa2aa38c5 --- /dev/null +++ b/services/agents-api/migrations/000021_environment_executor_credentials.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE environment_executor_credentials ( + environment_id uuid PRIMARY KEY REFERENCES environments(id), + token_sha256 text NOT NULL UNIQUE CHECK (token_sha256 ~ '^[0-9a-f]{64}$'), + issued_at timestamptz NOT NULL DEFAULT clock_timestamp(), + revoked_at timestamptz +); + +-- +goose Down +DROP TABLE environment_executor_credentials; diff --git a/services/agents-api/migrations/000022_environment_input_reservations.sql b/services/agents-api/migrations/000022_environment_input_reservations.sql new file mode 100644 index 000000000..ac7ae1dbf --- /dev/null +++ b/services/agents-api/migrations/000022_environment_input_reservations.sql @@ -0,0 +1,29 @@ +-- +goose Up +CREATE TABLE environment_input_reservations ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES environments(session_id), + idempotency_key text NOT NULL, + batch jsonb NOT NULL CHECK (CASE WHEN jsonb_typeof(batch) = 'array' + THEN jsonb_array_length(batch) BETWEEN 1 AND 64 ELSE false END), + state text NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'admitted', 'expired', 'cancelled')), + created_at timestamptz NOT NULL, + deadline timestamptz NOT NULL, + settled_at timestamptz, + UNIQUE (session_id, idempotency_key), + CHECK ((settled_at IS NOT NULL) = (state <> 'pending')) +); +CREATE UNIQUE INDEX environment_input_reservations_one_pending_idx + ON environment_input_reservations(session_id) WHERE state = 'pending'; + +-- +goose Down +LOCK TABLE environment_input_reservations IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environment_input_reservations) THEN + RAISE EXCEPTION 'Cannot remove durable Environment input identities while reservations exist'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE environment_input_reservations; diff --git a/services/agents-api/migrations/000023_environment_input_expiry_index.sql b/services/agents-api/migrations/000023_environment_input_expiry_index.sql new file mode 100644 index 000000000..1054e6d32 --- /dev/null +++ b/services/agents-api/migrations/000023_environment_input_expiry_index.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE INDEX environment_input_reservations_pending_deadline_idx + ON environment_input_reservations(deadline, id) WHERE state = 'pending'; + +-- +goose Down +DROP INDEX environment_input_reservations_pending_deadline_idx; diff --git a/services/agents-api/migrations/000024_execution_project_scopes.sql b/services/agents-api/migrations/000024_execution_project_scopes.sql new file mode 100644 index 000000000..479fb3099 --- /dev/null +++ b/services/agents-api/migrations/000024_execution_project_scopes.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE execution_project_scopes ( + tenant_id uuid PRIMARY KEY CHECK (tenant_id <> '00000000-0000-0000-0000-000000000000'), + organization_id text NOT NULL CHECK (organization_id <> ''), + project_id text NOT NULL CHECK (project_id <> ''), + UNIQUE (organization_id, project_id) +); + +-- +goose Down +DROP TABLE execution_project_scopes; diff --git a/services/agents-api/migrations/000025_session_creators.sql b/services/agents-api/migrations/000025_session_creators.sql new file mode 100644 index 000000000..f1b7b60ec --- /dev/null +++ b/services/agents-api/migrations/000025_session_creators.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE sessions + ADD COLUMN creator_kind text, + ADD COLUMN creator_id text, + ADD CONSTRAINT sessions_creator_complete CHECK ( + (creator_kind IS NULL AND creator_id IS NULL) + OR (creator_kind IS NOT NULL AND creator_id IS NOT NULL + AND creator_kind IN ('user', 'service_account') AND creator_id <> '') + ); + +-- +goose Down +ALTER TABLE sessions + DROP CONSTRAINT sessions_creator_complete, + DROP COLUMN creator_kind, + DROP COLUMN creator_id; diff --git a/services/agents-api/migrations/000026_executor_principals.sql b/services/agents-api/migrations/000026_executor_principals.sql new file mode 100644 index 000000000..57699d6db --- /dev/null +++ b/services/agents-api/migrations/000026_executor_principals.sql @@ -0,0 +1,43 @@ +-- +goose Up +ALTER TABLE environment_executor_credentials + ADD COLUMN key_id uuid, + ADD COLUMN tenant_id uuid REFERENCES execution_project_scopes(tenant_id), + ADD COLUMN subject_kind text, + ADD COLUMN subject_id text, + ADD COLUMN created_at timestamptz; + +UPDATE environment_executor_credentials +SET key_id = environment_id, revoked_at = COALESCE(revoked_at, clock_timestamp()); + +ALTER TABLE environment_executor_credentials + DROP CONSTRAINT environment_executor_credentials_pkey, + ALTER COLUMN environment_id DROP NOT NULL, + ALTER COLUMN key_id SET NOT NULL, + ADD PRIMARY KEY (key_id), + ADD CONSTRAINT executor_principal_complete CHECK ( + (tenant_id IS NULL AND subject_kind IS NULL AND subject_id IS NULL + AND environment_id IS NOT NULL AND revoked_at IS NOT NULL AND created_at IS NULL) + OR (tenant_id IS NOT NULL AND subject_kind IS NOT NULL AND subject_id IS NOT NULL + AND subject_kind IN ('user', 'service_account') AND subject_id <> '' AND created_at IS NOT NULL) + ); + +-- +goose Down +LOCK TABLE environment_executor_credentials IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environment_executor_credentials WHERE tenant_id IS NOT NULL) THEN + RAISE EXCEPTION 'Cannot remove durable executor principal identities while principal keys exist'; + END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE environment_executor_credentials + DROP CONSTRAINT executor_principal_complete, + DROP CONSTRAINT environment_executor_credentials_pkey, + ALTER COLUMN environment_id SET NOT NULL, + ADD PRIMARY KEY (environment_id), + DROP COLUMN key_id, + DROP COLUMN tenant_id, + DROP COLUMN subject_kind, + DROP COLUMN subject_id, + DROP COLUMN created_at; diff --git a/services/agents-api/migrations/000027_environment_connections.sql b/services/agents-api/migrations/000027_environment_connections.sql new file mode 100644 index 000000000..80633e958 --- /dev/null +++ b/services/agents-api/migrations/000027_environment_connections.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE environment_connections ( + environment_id uuid PRIMARY KEY REFERENCES environments(id), + generation uuid NOT NULL, + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0) +); + +-- +goose Down +LOCK TABLE environment_connections IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environment_connections) THEN + RAISE EXCEPTION 'Cannot remove active Environment observation fencing'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE environment_connections; diff --git a/services/agents-api/migrations/000028_environment_input_activity.sql b/services/agents-api/migrations/000028_environment_input_activity.sql new file mode 100644 index 000000000..9de50b52a --- /dev/null +++ b/services/agents-api/migrations/000028_environment_input_activity.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE INDEX environment_input_reservations_latest_idx +ON environment_input_reservations(session_id, created_at DESC, id DESC); + +-- +goose Down +DROP INDEX environment_input_reservations_latest_idx; diff --git a/services/agents-api/migrations/000029_environment_initial_input.sql b/services/agents-api/migrations/000029_environment_initial_input.sql new file mode 100644 index 000000000..d846250ba --- /dev/null +++ b/services/agents-api/migrations/000029_environment_initial_input.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE environment_input_reservations +ADD COLUMN is_initial boolean NOT NULL DEFAULT false; + +-- +goose Down +LOCK TABLE environment_input_reservations IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environment_input_reservations WHERE is_initial) THEN + RAISE EXCEPTION 'Cannot remove initial Environment input origin while initial reservations exist'; + END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE environment_input_reservations DROP COLUMN is_initial; diff --git a/services/agents-api/migrations/000030_vaults.sql b/services/agents-api/migrations/000030_vaults.sql new file mode 100644 index 000000000..5f3f7c439 --- /dev/null +++ b/services/agents-api/migrations/000030_vaults.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE vaults ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + name text CHECK (name IS NULL OR octet_length(name) BETWEEN 1 AND 256), + metadata jsonb NOT NULL CHECK (jsonb_typeof(metadata) = 'object'), + created_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +-- +goose Down +DROP TABLE vaults; diff --git a/services/agents-api/migrations/000031_vault_credentials.sql b/services/agents-api/migrations/000031_vault_credentials.sql new file mode 100644 index 000000000..65e87e3bb --- /dev/null +++ b/services/agents-api/migrations/000031_vault_credentials.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE vault_credentials ( + id uuid PRIMARY KEY, + vault_id uuid NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, + name text NOT NULL CHECK (octet_length(name) BETWEEN 1 AND 256), + auth_type text NOT NULL CHECK (auth_type = 'static_bearer'), + mcp_server_url text NOT NULL, + token_ciphertext bytea NOT NULL CHECK (octet_length(token_ciphertext) > 0), + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + updated_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +-- +goose Down +DROP TABLE vault_credentials; diff --git a/services/agents-api/migrations/000032_vault_status.sql b/services/agents-api/migrations/000032_vault_status.sql new file mode 100644 index 000000000..30a2de62b --- /dev/null +++ b/services/agents-api/migrations/000032_vault_status.sql @@ -0,0 +1,16 @@ +-- +goose Up +ALTER TABLE vaults ADD COLUMN status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'archived')); +CREATE INDEX vaults_tenant_created_id_idx ON vaults (tenant_id, created_at, id); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM vaults WHERE status = 'archived') THEN + RAISE EXCEPTION 'Cannot remove archived Vault classification'; + END IF; +END $$; +-- +goose StatementEnd +DROP INDEX vaults_tenant_created_id_idx; +ALTER TABLE vaults DROP COLUMN status; diff --git a/services/agents-api/migrations/000033_credential_status.sql b/services/agents-api/migrations/000033_credential_status.sql new file mode 100644 index 000000000..d3a315c29 --- /dev/null +++ b/services/agents-api/migrations/000033_credential_status.sql @@ -0,0 +1,17 @@ +-- +goose Up +ALTER TABLE vault_credentials ADD COLUMN status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'archived')); +CREATE INDEX vault_credentials_vault_created_id_idx ON vault_credentials (vault_id, created_at, id); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + LOCK TABLE vault_credentials IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM vault_credentials WHERE status = 'archived') THEN + RAISE EXCEPTION 'Cannot remove archived Credential classification'; + END IF; +END $$; +-- +goose StatementEnd +DROP INDEX vault_credentials_vault_created_id_idx; +ALTER TABLE vault_credentials DROP COLUMN status; diff --git a/services/agents-api/migrations/000034_subagent_identities.sql b/services/agents-api/migrations/000034_subagent_identities.sql new file mode 100644 index 000000000..300ca88d9 --- /dev/null +++ b/services/agents-api/migrations/000034_subagent_identities.sql @@ -0,0 +1,19 @@ +-- +goose Up +CREATE TABLE subagent_identities ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES sessions(id), + device_id uuid NOT NULL REFERENCES devices(id), + engine text NOT NULL, + native_id text NOT NULL CHECK (native_id <> ''), + parent_native_id text NOT NULL CHECK (parent_native_id <> '' AND parent_native_id <> native_id), + native_created_at bigint NOT NULL CHECK (native_created_at > 0), + first_turn_id uuid NOT NULL, + first_event_ordinal integer NOT NULL, + UNIQUE (device_id, engine, native_id), + UNIQUE (session_id, native_id), + FOREIGN KEY (session_id, first_turn_id) REFERENCES turns(session_id, id), + FOREIGN KEY (first_turn_id, first_event_ordinal) REFERENCES turn_events(turn_id, ordinal) +); + +-- +goose Down +DROP TABLE subagent_identities; diff --git a/services/agents-api/migrations/000035_local_environment_devices.sql b/services/agents-api/migrations/000035_local_environment_devices.sql new file mode 100644 index 000000000..a473681bf --- /dev/null +++ b/services/agents-api/migrations/000035_local_environment_devices.sql @@ -0,0 +1,14 @@ +-- +goose Up +ALTER TABLE devices ADD COLUMN environment_id uuid UNIQUE REFERENCES environments(id); + +-- +goose Down +LOCK TABLE devices IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM devices WHERE environment_id IS NOT NULL) THEN + RAISE EXCEPTION 'Cannot discard dedicated Environment device authority'; + END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE devices DROP COLUMN environment_id; diff --git a/services/agents-api/migrations/000036_environment_file_writes.sql b/services/agents-api/migrations/000036_environment_file_writes.sql new file mode 100644 index 000000000..4c8a04f19 --- /dev/null +++ b/services/agents-api/migrations/000036_environment_file_writes.sql @@ -0,0 +1,25 @@ +-- +goose Up +CREATE TABLE environment_file_writes ( + id uuid PRIMARY KEY, + environment_id uuid NOT NULL REFERENCES environments(id), + device_id uuid NOT NULL REFERENCES devices(id), + request_sha256 text NOT NULL CHECK (request_sha256 ~ '^[0-9a-f]{64}$'), + state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'committed', 'rejected')), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + settled_at timestamptz, + CHECK ((state = 'pending') = (settled_at IS NULL)) +); +CREATE UNIQUE INDEX environment_file_writes_pending ON environment_file_writes(environment_id) + WHERE state = 'pending'; + +-- +goose Down +LOCK TABLE environment_file_writes IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM environment_file_writes) THEN + RAISE EXCEPTION 'Cannot remove durable Environment file write identities while writes exist'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE environment_file_writes; diff --git a/services/agents-api/migrations/000037_source_files.sql b/services/agents-api/migrations/000037_source_files.sql new file mode 100644 index 000000000..68d1f6220 --- /dev/null +++ b/services/agents-api/migrations/000037_source_files.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE source_files ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + filename text NOT NULL CHECK (octet_length(filename) BETWEEN 1 AND 1024), + purpose text NOT NULL CHECK (purpose = 'user_data'), + body_oid oid NOT NULL UNIQUE, + size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 0 AND 536870912), + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + created_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM source_files) THEN + RAISE EXCEPTION 'delete source files through the service before downgrade'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE source_files; diff --git a/services/agents-api/migrations/000038_runtime_allocations.sql b/services/agents-api/migrations/000038_runtime_allocations.sql new file mode 100644 index 000000000..3921fefc5 --- /dev/null +++ b/services/agents-api/migrations/000038_runtime_allocations.sql @@ -0,0 +1,27 @@ +-- +goose Up +CREATE TABLE runtime_allocations ( + id uuid PRIMARY KEY, + environment_id uuid NOT NULL UNIQUE REFERENCES environments(id), + device_id uuid NOT NULL UNIQUE REFERENCES devices(id), + provider_key uuid NOT NULL, + state text NOT NULL DEFAULT 'creating' + CHECK (state IN ('creating', 'running', 'cleanup_pending', 'released')), + create_settled boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + kept_at timestamptz NOT NULL DEFAULT clock_timestamp(), + released_at timestamptz, + CHECK ((state = 'released') = (released_at IS NOT NULL)), + CHECK (state <> 'released' OR create_settled) +); + +-- +goose Down +LOCK TABLE runtime_allocations IN ACCESS EXCLUSIVE MODE; +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM runtime_allocations) THEN + RAISE EXCEPTION 'Cannot discard managed Runtime allocation and cleanup identities'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE runtime_allocations; diff --git a/services/agents-api/migrations/000039_environment_input_failure.sql b/services/agents-api/migrations/000039_environment_input_failure.sql new file mode 100644 index 000000000..10b6b24a8 --- /dev/null +++ b/services/agents-api/migrations/000039_environment_input_failure.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE environment_input_reservations DROP CONSTRAINT environment_input_reservations_state_check; +ALTER TABLE environment_input_reservations ADD CONSTRAINT environment_input_reservations_state_check + CHECK (state IN ('pending', 'admitted', 'expired', 'cancelled', 'failed')); + +-- +goose Down +-- The constraint refuses rollback while failed outcomes still need to be retained. +ALTER TABLE environment_input_reservations DROP CONSTRAINT environment_input_reservations_state_check; +ALTER TABLE environment_input_reservations ADD CONSTRAINT environment_input_reservations_state_check + CHECK (state IN ('pending', 'admitted', 'expired', 'cancelled')); diff --git a/services/agents-api/migrations/000040_session_artifacts.sql b/services/agents-api/migrations/000040_session_artifacts.sql new file mode 100644 index 000000000..ac739682c --- /dev/null +++ b/services/agents-api/migrations/000040_session_artifacts.sql @@ -0,0 +1,28 @@ +-- +goose Up +ALTER TABLE turns ADD COLUMN artifact_capture_started boolean NOT NULL DEFAULT false; +CREATE TABLE session_artifacts ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES sessions(id), + turn_id uuid NOT NULL, + environment_id uuid NOT NULL REFERENCES environments(id), + path text NOT NULL CHECK (octet_length(path) BETWEEN 20 AND 4110), + size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 0 AND 209715200), + body_oid oid NOT NULL UNIQUE, + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + created_at timestamptz, + UNIQUE (turn_id, path), + FOREIGN KEY (session_id, turn_id) REFERENCES turns(session_id, id) +); +CREATE INDEX session_artifacts_page_idx ON session_artifacts (session_id, created_at DESC, id DESC) +WHERE created_at IS NOT NULL; + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM session_artifacts) THEN + RAISE EXCEPTION 'delete session artifacts through the service before downgrade'; + END IF; +END $$; +-- +goose StatementEnd +DROP TABLE session_artifacts; +ALTER TABLE turns DROP COLUMN artifact_capture_started; diff --git a/services/agents-api/migrations/000041_source_files_list.sql b/services/agents-api/migrations/000041_source_files_list.sql new file mode 100644 index 000000000..2a645f287 --- /dev/null +++ b/services/agents-api/migrations/000041_source_files_list.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX source_files_page_idx ON source_files (tenant_id, created_at DESC, id DESC); + +-- +goose Down +DROP INDEX source_files_page_idx; diff --git a/services/agents-api/migrations/000042_environment_templates.sql b/services/agents-api/migrations/000042_environment_templates.sql new file mode 100644 index 000000000..fb772148d --- /dev/null +++ b/services/agents-api/migrations/000042_environment_templates.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE environment_templates ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + name text CHECK (name IS NULL OR char_length(name) BETWEEN 1 AND 256), + network_access text NOT NULL CHECK (network_access IN ('enabled', 'disabled')), + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + updated_at timestamptz NOT NULL DEFAULT statement_timestamp() +); +CREATE INDEX environment_templates_tenant_order ON environment_templates (tenant_id, created_at, id); + +-- +goose Down +DROP TABLE environment_templates; diff --git a/services/agents-api/migrations/000043_environment_initial_files.sql b/services/agents-api/migrations/000043_environment_initial_files.sql new file mode 100644 index 000000000..66d29483f --- /dev/null +++ b/services/agents-api/migrations/000043_environment_initial_files.sql @@ -0,0 +1,20 @@ +-- +goose Up +ALTER TABLE environment_templates ADD COLUMN files jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(files) = 'array'); +ALTER TABLE environment_templates ADD COLUMN file_contents bytea; +CREATE TABLE initial_environment_files ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + position integer NOT NULL CHECK (position BETWEEN 0 AND 49), + path text NOT NULL, + size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 0 AND 52428800), + contents bytea NOT NULL, + UNIQUE (session_id, position), + UNIQUE (session_id, path) +); +ALTER TABLE runtime_allocations ADD COLUMN initialization text NOT NULL DEFAULT 'complete' + CHECK (initialization IN ('pending', 'running', 'complete')); + +-- +goose Down +ALTER TABLE runtime_allocations DROP COLUMN initialization; +DROP TABLE initial_environment_files; +ALTER TABLE environment_templates DROP COLUMN file_contents, DROP COLUMN files; diff --git a/services/agents-api/migrations/000044_environment_setup.sql b/services/agents-api/migrations/000044_environment_setup.sql new file mode 100644 index 000000000..859a7471d --- /dev/null +++ b/services/agents-api/migrations/000044_environment_setup.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE environment_templates + ADD COLUMN packages jsonb NOT NULL DEFAULT '{"npm":[],"python":[],"system":[]}'::jsonb CHECK (jsonb_typeof(packages) = 'object'), + ADD COLUMN env_contents bytea, + ADD COLUMN setup_contents bytea; +CREATE TABLE environment_setups ( + session_id uuid PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + contents bytea NOT NULL +); + +-- +goose Down +DROP TABLE environment_setups; +ALTER TABLE environment_templates DROP COLUMN packages, DROP COLUMN env_contents, DROP COLUMN setup_contents; diff --git a/services/agents-api/migrations/000045_environment_skills.sql b/services/agents-api/migrations/000045_environment_skills.sql new file mode 100644 index 000000000..dae32c831 --- /dev/null +++ b/services/agents-api/migrations/000045_environment_skills.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE environment_templates + ADD COLUMN skills jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN skill_contents bytea; + +-- +goose Down +ALTER TABLE environment_templates DROP COLUMN skill_contents, DROP COLUMN skills; diff --git a/services/agents-api/migrations/000046_session_model_execution.sql b/services/agents-api/migrations/000046_session_model_execution.sql new file mode 100644 index 000000000..d43707456 --- /dev/null +++ b/services/agents-api/migrations/000046_session_model_execution.sql @@ -0,0 +1,8 @@ +-- +goose Up +CREATE TABLE session_model_execution ( + session_id uuid PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + encrypted_config bytea NOT NULL +); + +-- +goose Down +DROP TABLE session_model_execution; diff --git a/services/agents-api/migrations/migrations.go b/services/agents-api/migrations/migrations.go new file mode 100644 index 000000000..0cb542014 --- /dev/null +++ b/services/agents-api/migrations/migrations.go @@ -0,0 +1,29 @@ +// Package migrations owns only the Agents API database schema. +package migrations + +import ( + "context" + "database/sql" + "embed" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +//go:embed *.sql +var files embed.FS + +// Apply upgrades the dedicated execution database without consulting product data. +func Apply(ctx context.Context, databaseURL string) error { + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return err + } + defer db.Close() + provider, err := goose.NewProvider(goose.DialectPostgres, db, files, goose.WithTableName("agents_api_schema_version")) + if err != nil { + return err + } + _, err = provider.Up(ctx) + return err +} diff --git a/services/agents-api/sqlc.yaml b/services/agents-api/sqlc.yaml new file mode 100644 index 000000000..3d8628ea6 --- /dev/null +++ b/services/agents-api/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "migrations" + queries: "internal/db/queries" + gen: + go: + package: "sqlc" + out: "internal/db/sqlc" + sql_package: "pgx/v5" + emit_json_tags: true + emit_empty_slices: true diff --git a/services/agents-api/tests/container_server.py b/services/agents-api/tests/container_server.py new file mode 100755 index 000000000..240900917 --- /dev/null +++ b/services/agents-api/tests/container_server.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Run the existing official-client suite against a read-only Linux container.""" + +import os + +# Match ownership of the suite's private key file without granting root access. +# The image's default UID is separately exercised by deployment acceptance. +assert os.getuid() != 0, "Run container acceptance as an unprivileged host user" +keys = os.environ["AGENTS_API_KEYS_FILE"] +args = [ + "docker", "run", "--rm", "--read-only", "--network=host", + "--cap-drop=ALL", "--security-opt=no-new-privileges", + "--user", f"{os.getuid()}:{os.getgid()}", + "--mount", f"type=bind,source={keys},target=/run/keys.json,readonly", + "--env", "AGENTS_API_KEYS_FILE=/run/keys.json", +] +credential_key = os.environ.get("AGENTS_API_CREDENTIAL_KEY_FILE") +if credential_key: + args.extend([ + "--mount", f"type=bind,source={credential_key},target=/run/credential.key,readonly", + "--env", "AGENTS_API_CREDENTIAL_KEY_FILE=/run/credential.key", + ]) +for name in ("AGENTS_API_DATABASE_URL", "AGENTS_API_ADDR", "AGENTS_API_ENGINE"): + args.extend(["--env", name]) +args.append(os.environ["AGENTS_API_IMAGE"]) +# Docker forwards termination to the API and --rm removes the stopped container. +os.execvp(args[0], args) diff --git a/services/agents-api/tests/e2b_native_isolation.py b/services/agents-api/tests/e2b_native_isolation.py new file mode 100644 index 000000000..7a2d7a9ed --- /dev/null +++ b/services/agents-api/tests/e2b_native_isolation.py @@ -0,0 +1,65 @@ +"""Run through a real native tool, with non-secret canaries supplied by acceptance.""" +import glob +import hashlib +import json +import os +import subprocess +import urllib.error +import urllib.request +from pathlib import Path + +fixture = json.loads(Path('/workspace/isolation-fixture.json').read_text()) +paths = ['/environment/staging/canary', + '/home/runtime/.parsar/parsar-daemon/default/auth.json', + fixture['history_path'], + '/proc/1/root/home/runtime/.parsar/parsar-daemon/default/auth.json', + '/root/.parsar/e2b/ready.json', '/var/run/docker.sock'] + + +def read(path): + try: + with open(path, 'rb') as stream: + return stream.read(65536) + except OSError: + return None + + +result = {'private_readable': {path: read(path) is not None for path in paths}} +assert not any(result['private_readable'].values()), result +result['pid_namespace'] = os.readlink('/proc/self/ns/pid') +result['pid_namespace_isolated'] = result['pid_namespace'] != fixture['outer_pid_namespace'] +assert result['pid_namespace_isolated'], 'native tools share outer daemon PID namespace' +result['pid1_comm'] = (read('/proc/1/comm') or b'').decode(errors='replace').strip() +result['pid1_env_readable'] = read('/proc/1/environ') is not None +leaks = [] +for path in glob.glob('/proc/[0-9]*/environ') + glob.glob('/proc/[0-9]*/cmdline'): + data = read(path) + if data is None: + continue + for field in data.split(b'\x00'): + value = field.split(b'=', 1)[-1] + if hashlib.sha256(value).hexdigest() in fixture['secret_hashes']: + leaks.append(path) +result['sensitive_process_leaks'] = leaks +assert not leaks, 'protected outer process credential accessible' +for name, command in [('sudo', ['sudo', '-n', 'id', '-u']), + ('privileged_account', ['su', 'user', '-c', 'id -u'])]: + try: + process = subprocess.run(command, input='', capture_output=True, text=True, timeout=8) + result[name + '_denied'] = process.returncode != 0 + except FileNotFoundError: + result[name + '_unavailable'] = True + result[name + '_denied'] = True + assert result[name + '_denied'], 'native shell gained privileged account' +try: + urllib.request.urlopen('http://127.0.0.1:49983/envs', timeout=5) +except urllib.error.HTTPError as error: + assert error.code in [401, 403], error.code + result['envd_denied'] = True +except (urllib.error.URLError, PermissionError, TimeoutError): + result['envd_denied'] = True +else: + raise AssertionError('unauthenticated envd authority accessible') +result['passed'] = True +Path('/workspace/isolation-result.json').write_text(json.dumps(result)) +print('ISOLATION-PASSED') diff --git a/services/agents-api/tests/fixtures/credentials.go b/services/agents-api/tests/fixtures/credentials.go new file mode 100644 index 000000000..3a6dd2ae9 --- /dev/null +++ b/services/agents-api/tests/fixtures/credentials.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" +) + +// Synthetic archived rows prove filtering; no public archive writer exists. +func seedCredentialList(path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f struct { + Tenant string `json:"tenant"` + Vault string `json:"vault"` + Credentials []string `json:"credentials"` + } + if err := json.Unmarshal(raw, &f); err != nil { + return err + } + if len(f.Credentials) == 0 { + return errors.New("Credential fixture IDs required") + } + ctx := context.Background() + pool, err := fixturePool(ctx) + if err != nil { + return err + } + defer pool.Close() + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // Committed transactions cannot roll back. + result, err := tx.Exec(ctx, `UPDATE vault_credentials c SET status='archived' +FROM vaults v WHERE v.id=c.vault_id AND v.tenant_id=$1 AND v.id=$2 +AND c.id=ANY($3::uuid[])`, f.Tenant, f.Vault, f.Credentials) + if err != nil { + return err + } + if result.RowsAffected() != int64(len(f.Credentials)) { + return errors.New("Credential fixture IDs must be unique and owned by the supplied project and Vault") + } + return tx.Commit(ctx) +} diff --git a/services/agents-api/tests/fixtures/items.go b/services/agents-api/tests/fixtures/items.go new file mode 100644 index 000000000..849b545fd --- /dev/null +++ b/services/agents-api/tests/fixtures/items.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func observeItems(ctx context.Context, s *store.Store, tenant, session, turn, status string) error { + events := []store.ExecutionEvent{ + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"partial answer"}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"command","stage":"after","observation":{"status":"failed","kind":"command","command":"exit 7","cwd":"/workspace","output":"command failed","exit_code":7,"duration_ms":8}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mcp","stage":"after","observation":{"status":"completed","kind":"mcp","server":"reference","name":"lookup","arguments":{"n":9007199254740993},"output":{"structuredContent":{"n":9007199254740993}},"error":null}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"dynamic","stage":"after","observation":{"status":"completed","kind":"function","name":"reference::lookup","arguments":{},"content":[{"type":"input_text","text":""}]}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"patch","stage":"after","observation":{"status":"completed","kind":"function","name":"apply_patch","arguments":{"changes":[{"path":"/workspace/sample","diff":"+example"}]}}}`)}, + {Kind: "tool_call", Payload: json.RawMessage(`{"id":"search","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"search","query":"reference"}}}`)}, + } + if status == store.TurnCompleted || status == store.TurnFailed { + events = append(events, store.ExecutionEvent{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"final answer","phase":"final_answer"}`)}) + } + return s.AppendTurnEvents(ctx, tenant, session, turn, 1, events) +} diff --git a/services/agents-api/tests/fixtures/main.go b/services/agents-api/tests/fixtures/main.go new file mode 100644 index 000000000..89586e441 --- /dev/null +++ b/services/agents-api/tests/fixtures/main.go @@ -0,0 +1,87 @@ +// Command fixtures seeds internal records for official-client recovery tests. +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type fixture struct { + Tenant string `json:"tenant"` + Session string `json:"session"` + Turns []string `json:"turns"` +} + +func main() { + if err := seed(); err != nil { + os.Stderr.WriteString("Internal fixture setup failed.\n") + os.Exit(1) + } +} + +func seed() error { + if path := os.Getenv("AGENTS_API_CREDENTIAL_LIST_FIXTURE"); path != "" { + return seedCredentialList(path) + } + if path := os.Getenv("AGENTS_API_VAULT_LIST_FIXTURE"); path != "" { + return seedVaultList(path) + } + path := os.Getenv("AGENTS_API_TURN_FIXTURE") + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f fixture + if err = json.Unmarshal(raw, &f); err != nil { + return err + } + ctx := context.Background() + pool, err := fixturePool(ctx) + if err != nil { + return err + } + defer pool.Close() + s := store.New(pool) + for _, status := range []string{store.TurnCompleted, store.TurnFailed, store.TurnCancelled, store.TurnInProgress} { + receipt, err := s.SubmitMessage(ctx, f.Tenant, f.Session, uuid.NewString(), json.RawMessage(`{"text":"recovery fixture"}`)) + if err != nil { + return err + } + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + return err + } + if err = observeItems(ctx, s, f.Tenant, f.Session, receipt.TurnID, status); err != nil { + return err + } + if status != store.TurnInProgress { + outcome := json.RawMessage(`{"error":"SECRET engine log","done":{"metadata":{"agent_session_id":"PRIVATE"}}}`) + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: status, Outcome: outcome}); err != nil { + return err + } + } + f.Turns = append(f.Turns, receipt.TurnID) + } + raw, err = json.Marshal(f) + if err != nil { + return err + } + return os.WriteFile(path, raw, 0600) +} + +func fixturePool(ctx context.Context) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(os.Getenv("PARSAR_AGENTS_API_TEST_DATABASE_URL")) + if err != nil { + return nil, err + } + if !strings.HasPrefix(cfg.ConnConfig.Database, "parsar_agents_api_") || !strings.HasSuffix(cfg.ConnConfig.Database, "_tests") { + return nil, errors.New("dedicated test database required") + } + return pgxpool.NewWithConfig(ctx, cfg) +} diff --git a/services/agents-api/tests/fixtures/vaults.go b/services/agents-api/tests/fixtures/vaults.go new file mode 100644 index 000000000..2136892ac --- /dev/null +++ b/services/agents-api/tests/fixtures/vaults.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" +) + +// Synthetic archived rows prove filtering; no public archive writer exists. +func seedVaultList(path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f struct { + Tenant string `json:"tenant"` + Vaults []string `json:"vaults"` + } + if err := json.Unmarshal(raw, &f); err != nil { + return err + } + if len(f.Vaults) == 0 { + return errors.New("Vault fixture IDs required") + } + ctx := context.Background() + pool, err := fixturePool(ctx) + if err != nil { + return err + } + defer pool.Close() + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // Committed transactions cannot roll back. + result, err := tx.Exec(ctx, "UPDATE vaults SET status='archived' WHERE tenant_id=$1 AND id=ANY($2::uuid[])", f.Tenant, f.Vaults) + if err != nil { + return err + } + if result.RowsAffected() != int64(len(f.Vaults)) { + return errors.New("Vault fixture IDs must be unique and owned by the supplied project") + } + return tx.Commit(ctx) +} diff --git a/services/agents-api/tests/native/README.md b/services/agents-api/tests/native/README.md new file mode 100644 index 000000000..2e63bf238 --- /dev/null +++ b/services/agents-api/tests/native/README.md @@ -0,0 +1,309 @@ +# Native Environment probes + +These fixtures exercise pinned native execution against the actual Agents API +registry/relay and a dedicated execution PostgreSQL database. They are opt-in; +ordinary CI skips native binaries and paid model calls when prerequisites are absent. +The standalone public fixture below exercises the initial `self_hosted` text +profile. The other probes exercise private adapter boundaries. None establishes +complete Agents API compatibility; use the protocol coverage ledger for those gaps. + +## Public standalone self-hosted execution + +Build the standalone service with `make build-agents-api`. Supply the pinned SDK, +native daemon, Codex 0.153.4, executor launcher, private proof directory, dedicated +execution test database, local Docker image and real MiniMax credential described +below, then run: + +```sh +export PARSAR_AGENTS_API_SERVER_BIN="$HOME/.parsar/build/agents-api/agents-api" +export PARSAR_OFFICIAL_SDK_PYTHON='' +go test ./services/agents-api/internal/store \ + -run '^TestNativePublicSelfHostedStandalone$' -count=1 -v -timeout=12m +``` + +`official_self_hosted.py` creates empty Sessions through ordinary and streamed +official-client requests, then submits both text inputs through the built server. +The first request must remain open beyond the ordinary HTTP write timeout while +there is no executor or daemon. No Turn or Item may exist during that wait. The +caller starts the executor using the returned Environment ID and `remote_url` +unchanged; the registered daemon and Worker perform preparation and admission. +The second input starts with a connected executor and retained native history. +Both real model Turns must execute the exact remote command with observed cwd, +stdout/stderr, exit 7, retained files and remembered first-Turn context. SDK and +independent raw SSE observers verify activity ordering, responses, query recovery, +tenant isolation and retries without additional Turns or commands. +SDK/raw Environment reads verify pending and connected observations before daemon +startup and safe metadata around both Turns. Actual workspace files must not appear +as API-installed resources. The connect-only executor key must fail public retrieval. +Post-Turn reads may observe reconnection; they do not assert immediate quiescence or +a fixed disconnection deadline. + +Private fixture writes provision only operator identity/device credentials, never +Sessions or input reservations. Store reads independently check execution identity +and native command evidence. Unsupported initial input, functions and mixed input +must fail before persistence. This fixture does not cover all Environment resources, +hosted providers, public cancellation, process isolation or unknown-effect recovery. +It incurs real provider usage and must run serially with other execution-lease +tests on Linux. Passing evidence is produced only by an actual successful run, +under `PARSAR_NATIVE_PROOF_DIR/public-self-hosted-*`. + +`relay_probe.rs` is compiled as an example of the pinned `codex-exec-server` crate. +`TestNativeHarnessRelayPostgreSQLAndProcessRecovery` runs it against the Go registry. +It uses synthetic scoped credentials and zero model calls. + +`environment_model_probe.py` drives the unmodified Codex 0.153.4 **app-server** over +stdio. The harness owns the model/tool loop; this script only sends user Turns and +observes native events, real files and processes. It calls the real MiniMax-M3 +Responses API, never a mock endpoint. Run it through the Go test, which owns the +tenant, Environment, scoped synthetic credentials and execution lease: + +```sh +export PARSAR_AGENTS_API_TEST_DATABASE_URL='' +export PARSAR_CODEX_BINARY='' +export PARSAR_EXECUTOR_PROOF_DIR="$HOME/.parsar/placement-proof" +export PARSAR_PLACEMENT_EXECUTOR_IMAGE='sha256:' +export PARSAR_PLACEMENT_MODEL_KEY_FILE="$HOME/.parsar/secrets/minimax.key" +mkdir -p "$PARSAR_EXECUTOR_PROOF_DIR" +chmod 700 "$PARSAR_EXECUTOR_PROOF_DIR" +go test ./services/agents-api/internal/store \ + -run '^TestNativeAppServerRemoteModelPlacement$' -count=1 -v -timeout=12m +``` + +Run on Linux with Python 3.10+, Docker, a loaded Debian image containing Bash and +coreutils, and the native executable compatible with that image. The fixture does +not pull images or install packages. Optional HTTP(S) proxy variables are forwarded +to the harness; loopback registry traffic bypasses them. Serialize this test with +other execution-lease/database tests. Model calls incur provider usage. + +The executor has its own container PID/filesystem view and mounts only its private +state, test workspace and read-only native executable. Provider credentials and +harness history stay outside it. Host networking is used for the loopback registry; +this is neither network isolation nor a production deployment recipe. Generated +code still shares the executor's user/process visibility. A scoped executor token +must not be confused with a caller, device or provider credential. + +The probe requires explicit remote selections, verifies a workspace absent on the +harness host, checks remote instructions and actual command output/exit/files, and +cold-resumes the same native thread from retained harness history. It records that native interruption preserves the demonstrated background command, +then targets that Turn's process through native list/terminate RPCs and independently +observes PID exit and stopped heartbeats. A second configuration records only the +names of exposed credential variables under the native default policy. Secrets are +checked before evidence is saved. The test removes only its owned container and +processes; private evidence/history remain under the configured evidence directory. +Other binary versions, native credential lifetime, unknown-effect recovery, production +TLS/domain authentication and full API/daemon lifecycle need separate acceptance. + +A successful probe reports `characterized_with_blockers`: its native behavior +assertions passed, while public dispatch/cancellation integration remains unimplemented. +It is not a passing claim for the complete Environment feature. + +## Registered daemon adapter + +`TestNativeDaemonRemoteEnvironment` sends the typed remote descriptor through a +real authenticated daemon/gateway, using the same registry and executor. It creates +harness credentials after daemon startup, so preloaded transport environment cannot +satisfy the test. The fixed native version must advertise the capability through +its actual heartbeat. Supply the placement prerequisites above plus: + +```sh +export PARSAR_NATIVE_DAEMON_BIN='' +export PARSAR_NATIVE_PROOF_DIR="$HOME/.parsar/daemon-environment-proof" +mkdir -p "$PARSAR_NATIVE_PROOF_DIR" +chmod 700 "$PARSAR_NATIVE_PROOF_DIR" +go test ./services/agents-api/internal/store \ + -run '^TestNativeDaemonRemoteEnvironment$' -count=1 -v -timeout=12m +``` + +With the same prerequisites, run `TestNativeDaemonPreparedRemoteEnvironment` to +exercise private prepare/ready/start through the registered daemon. Its separate +preparation subscription creates no Run, then the actual Run starts using the +returned handle. This repeats real remote command/file, cold-history and +cancellation acceptance; it does not enable public Environment admission. Controlled +subprocess/router tests establish deferred-start ownership independently. Both +variants obtain their harness credential from the current registry under the +fixture's execution owner; after real execution, they release it and verify that +native preparation rejects the former credential. No static harness-key file is +used. Public caller principal identity and Worker admission remain separate work. + +Run `TestNativePreparedWorkerRemoteEnvironment` with the same prerequisites to +exercise the real Worker above these controls. It reserves input without a +Turn, selects and binds a capable daemon, resolves a live harness credential, +prepares that daemon, atomically claims the original batch and persists ordinary +events/completion. Two real model +Turns verify remote instructions, exact command cwd/output/exit, retained files and +cold native history. Repeated reservation submission must return replay receipts +without allocating credentials or executing another command. Controlled Store and +gateway tests separately cover preparation failure, expiry/deletion/cancellation, +control-only Start rejection and cancellation while Start is pending. This fixture +configures the Worker resolver before startup. It additionally requires +`PARSAR_OFFICIAL_SDK_PYTHON` pointing to the fixed SDK in `upstream.json`. +Strict SDK and independent raw HTTP/SSE observers subscribe before reservation, +verify the connection action without Turns/Items, and supply the returned URL and +Environment ID unchanged to the caller-started executor. Scheduling begins after +that offline snapshot and initial connection; native readiness, promotion and both +Runs remain Worker-owned. The observers verify action clearing before the first +Turn, both completed Turns/Items, tenant isolation and recovery through a new +client. Private evidence includes `public-environment/public-environment-proof.json`. +Session provisioning and input reservation stay private in this fixture; the +standalone fixture above owns public creation/input acceptance. Complete +Environment lifecycle remains separate work. +The current daemon's pending-start cancellation acknowledgment may omit Outcome; +controlled coverage verifies conservative failure without final Done as well +as cancellation with a supplied outcome. It does not claim complete native +pending-start cancellation results or immediate process quiescence. + +The fixture explicitly sets daemon `PARSAR_CODEX_BIN` to `PARSAR_CODEX_BINARY`. +It checks invalid transient authorization, remote instructions/cwd/output/exit/files, +release and same-thread cold continuation, then sends `prompt_cancel` during an +actual remote command. PID exit and stopped heartbeats are observed independently +while the daemon, registry and executor remain running; the measured cleanup delay +is recorded, with no immediate-quiescence claim. Provider usage is real MiniMax-M3. + +The Environment token must be absent from persisted files and responses. The device +credential remains in its profile; the existing provider adapter may store its key +in private harness `config.toml`. Neither is mounted into the executor. The explicit +shell policy checks inheritance, not arbitrary same-user process visibility. +`remote-adapter-proof.json` describes this bounded daemon acceptance; public input +admission, complete lifecycle, files/templates and broader resource projection still +need their own implementation and real acceptance. + +## Separate executor launcher + +Build `agents-api-codex-executor` with `make build-agents-executor`, and compile +the current `relay_probe.rs` against the pinned upstream libraries as described +in the [service guide](../../README.md#native-executor-transport-prerequisite). +Then supply the ordinary PostgreSQL/native/image prerequisites above plus: + +```sh +export PARSAR_EXECUTOR_LAUNCHER="$HOME/.parsar/build/agents-executor/agents-api-codex-executor" +export PARSAR_NATIVE_RELAY_PROBE='' +go test ./services/agents-api/internal/store -run '^TestNativeExecutorLauncherTLSAndHelpers$' -count=1 -v -timeout=6m +``` + +This fixture uses controlled Docker DNS and a test CA with actual HTTPS/WSS. +It rejects an untrusted CA and wrong hostname before registry HTTP handling, +then verifies native commands, a 128 KiB file, connection recovery, fresh reads, +native filesystem/argv0 helper modes, read-only enforcement and graceful launcher +exit. It mounts the full native installation with its resources. Docker's outer +seccomp/AppArmor restrictions are relaxed solely so the native sandbox can run +inside the test container; this is not a production isolation recipe or public +DNS/certificate deployment. Model calls are zero. + +With `PARSAR_EXECUTOR_LAUNCHER` set, `TestNativeDaemonRemoteEnvironment` uses the +same new launcher and full native installation instead of stock CLI registration. +Its provider calls remain actual MiniMax. The provisioned executor JSON is the +only permitted persistence of that executor credential; harness credentials stay +transient. This second fixture uses loopback HTTP and separately verifies the +authenticated daemon, cold history/files and cancellation. Neither fixture enables +public `self_hosted` admission or proves complete Environment compatibility. + +The prepared Worker fixture (`TestNativePreparedWorkerRemoteEnvironment`) requires +the built launcher and issues a principal key before creating its Environment +Session. It verifies the serialized key ID and absent exact restriction before +real-provider commands/files and cold continuation. PostgreSQL/HTTP fixtures cover +same-principal multiple Sessions, cross-principal rejection, rotation/revocation, +restart and deletion. These checks do not enable public Environment admission. + +## Shared native filesystem owner + +`TestNativeSharedEnvironmentFiles` is an opt-in ownership experiment. It embeds +the unchanged upstream app-server through its public in-process interface and +injects the same `EnvironmentManager` used for direct native filesystem calls. +The Go fixture owns the actual registry, dedicated PostgreSQL lease and credentials, +caller executor container and remote-only workspace. This does not change the +production daemon or expose public file endpoints. + +Compile `shared_files_probe.rs` as `app-server/examples/parsar_shared_files_probe.rs` +in a task-owned copy of native commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`; copy its `shared_files/` modules beside +that example. Keep build source, cache and output under `~/.parsar/`. Use Rust +1.95.0 and the matching native installation/resources, with the existing OpenSSL +and platform build prerequisites. The probe follows native startup with 16 MiB +main-runtime and worker stacks. The upstream release manifest says `0.153.4` +while its tracked lock retains `0.0.0` for 149 workspace packages. Normalize only +those workspace versions and verify that all third-party packages, checksums and +dependency edges remain identical, then retain that build lock and use `--locked`. + +From the copied upstream `codex-rs` directory: + +```sh +cargo build --locked -p codex-app-server --example parsar_shared_files_probe +cargo clippy --locked -p codex-app-server --example parsar_shared_files_probe -- -D warnings +rustfmt --check --edition 2024 app-server/examples/parsar_shared_files_probe.rs +``` + +Supply the PostgreSQL, native binary, launcher, image, private proof and real model +key prerequisites above, plus `PARSAR_SHARED_FILES_PROBE` pointing to this example. +From the Parsar worktree, run: + +```sh +go test ./services/agents-api/internal/store \ + -run '^TestNativeSharedEnvironmentFiles$' -count=1 -v -timeout=12m +``` + +The fixture requires a 128 KiB binary round trip, actual metadata/directory reads, +and direct filesystem writes during an independently observed blocked native model +command. The same pair remains connected through both execution and file access. +A fresh harness process must resume native history and observe retained files. +Expected command lifecycle, cwd/stdout/stderr/exit, answers and side effects are +checked separately. All model calls use the real provider; synthetic credentials +and test bytes do not replace that acceptance. + +Results characterize shared access with production composition blockers. The +pinned in-process router can silently drop non-required notifications when full +without emitting `Lagged`; the dedicated drain and bounded fixture establish only +the expected observations of this run. Raw stdio initialization, lossless event +handling, process/credential lifetime and public path/reference/pagination semantics +need separate acceptance before adopting this runtime or exposing Environment files. + +The separate [raw manager qualification](raw_manager/README.md) investigates an +explicit, pinned native patch that shares the stock raw runner's manager while +retaining its transport assembly. Its deterministic no-model checks qualify only +that seam. They neither replace this real-provider proof nor establish production +Files ownership or public protocol acceptance. + +The [raw remote Files fixture](raw_files/README.md) combines that hook with the +pinned native Unix-socket client, reusing the same registry/container and typed +Files acceptance. Its real first/fresh phases cover file access during execution +and cold history. The native client's internal unbounded event queue remains an +explicit production-adoption limit. Its dedicated first/cancel/fresh scenario +checks observed interruption, exact native termination ownership, independent +command exit, post-cancel Files and retained interrupted history; ordinary +first/fresh acceptance does not cover cancellation. + + +## Public cancellation with the optional harness + +`TestNativePublicSelfHostedCancellationStandalone` can use the separately built +private harness through the actual daemon adapter. Set +`PARSAR_PUBLIC_HARNESS_ARTIFACT` to its absolute path, alongside the existing +built server/daemon, pinned native helper/executor, SDK, image and private key-file +inputs. `PARSAR_PLACEMENT_MODEL` selects the real model for this fixture (default +`MiniMax-M3`); `PARSAR_PLACEMENT_MODEL_BASE_URL` selects its native Responses +provider endpoint (default `https://api.minimax.cn/v1`). These are test inputs, +not public API fields or production configuration options. + +This mode runs the daemon in a task-owned Linux amd64 container using the existing +exact-digest executor image, which must include `strace` for this opt-in mode. It mounts the three binaries and host CA bundle read-only, uses native +`/etc/codex/config.toml`, and shares only required task daemon state and a short, +private HOME. It does not mount the shared operator home, Docker socket, API key +file or observer directory. Host networking connects to the existing local test +API/proxy. Explicit process proxy settings override Docker client defaults. This +is a qualified test placement, not a production isolation profile. +Provider credentials are passed through a private environment file and removed +on cleanup. No shell wrapper launches the harness. + +`strace` follows the daemon from startup through final retries and records every +successful harness exec, including short-lived launches, in separate per-process +files. Owner snapshots do not supply launch counts. The tracer runs with the same +non-root user, dropped capabilities and default seccomp policy; no ptrace privilege +or native sandbox relaxation is added. Only exec syscalls are recorded, without +expanding environment values. + +The fixture observes actual executable identity and Environment/workspace binding +before cancellation and during cold continuation. Existing command, file, history, +retry, receipt and SDK/raw-event assertions remain in force; native process and +private IPC release are checked separately. The default stock-helper fixture is +unchanged. This opt-in currently applies only to the public cancellation fixture. +Its success does not qualify public Files, interrupted-work replay, general remote +mutation retirement, other engines or complete protocol compatibility. diff --git a/services/agents-api/tests/native/directory/README.md b/services/agents-api/tests/native/directory/README.md new file mode 100644 index 000000000..02a24c660 --- /dev/null +++ b/services/agents-api/tests/native/directory/README.md @@ -0,0 +1,35 @@ +# Native directory helper acceptance + +`TestNativeExecutorDirectoryHelper` uses a real PostgreSQL registry, its issued +executor/harness credentials, the pinned native client and an actual Docker +executor. It directly invokes the installed helper through native process RPC, +requires the reported Linux sandbox, and waits for exit and output closure. +It checks root metadata, a 5,000-entry directory with bounded output, an empty +directory, symlink ancestry and invalid path/limit rejection. These are mechanism +tests with synthetic files and zero model calls. They do not establish public +Files compatibility, adapter lifecycle integration or real model execution. + +Build `probe.rs` as an example of `codex-exec-server` in an isolated export of +commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, using the repository's pinned +native toolchain and the existing workspace-lock normalization. No native source +or protocol patch is required. Keep sources, build state and evidence below +`~/.parsar/`. Build the production helper with `make build-agents-executor`. + +Set the existing private integration-test database URL, `PARSAR_DIRECTORY_PROBE`, +`PARSAR_DIRECTORY_HELPER`, `PARSAR_EXECUTOR_PROOF_DIR`, `PARSAR_EXECUTOR_LAUNCHER`, +`PARSAR_CODEX_BINARY`, and the digest-pinned `PARSAR_PLACEMENT_EXECUTOR_IMAGE`. +The latter prerequisites follow the [native fixtures](../README.md). +Then run: + +```sh +go test ./services/agents-api/internal/store -run '^TestNativeExecutorDirectoryHelper$' -count=1 -v +``` + +The fixture removes its container and temporary credential. Evidence retains no +provider credentials. Directory-descriptor unit tests separately control ancestor +replacement between opens and before enumeration, verify the scan bound, and +check descriptor release. Production consumers must also qualify cancellation, +transport uncertainty, installation trust and their exact owner/authorization +binding before admission. Adapter changes require real model calls before/after +observations through the retained harness; this helper-only fixture is not a +replacement for that acceptance. diff --git a/services/agents-api/tests/native/directory/probe.rs b/services/agents-api/tests/native/directory/probe.rs new file mode 100644 index 000000000..7eb187943 --- /dev/null +++ b/services/agents-api/tests/native/directory/probe.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use codex_exec_server::{ + EnvironmentManager, ExecOutputStream, ExecParams, ExecProcess, FileSystemSandboxContext, + ProcessId, +}; +use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::{ + FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, + FileSystemSpecialPath, NetworkSandboxPolicy, +}; +use codex_utils_path_uri::PathUri; +use tokio::time::timeout; + +const TIMEOUT: Duration = Duration::from_secs(15); + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() -> Result<()> { + timeout(Duration::from_secs(90), run()).await??; + Ok(()) +} + +async fn run() -> Result<()> { + let proof = PathBuf::from(std::env::var("PARSAR_NATIVE_ENV_PROOF")?); + let workspace = PathBuf::from(std::env::var("PARSAR_DIRECTORY_WORKSPACE")?); + let helper = PathBuf::from("/usr/local/bin/scoped-directory"); + let manager = EnvironmentManager::from_env( + None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .await?; + ensure!(manager.try_local_environment().is_none(), "local fallback"); + let environment = manager + .default_environment() + .context("remote environment")?; + ensure!(environment.is_remote(), "remote backend required"); + timeout(TIMEOUT, environment.wait_until_ready()).await??; + let cwd = PathUri::from_host_native_path(&workspace)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&workspace)?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&helper)?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + FileSystemAccessMode::Read, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let mut observed = Vec::new(); + for (index, (relative, limit)) in [ + ("", 64), + ("sub", 1), + ("empty", 1), + ("a/sub", 16), + ("../outside", 2), + ("sub", 0), + ] + .into_iter() + .enumerate() + { + let mut request = params( + &format!("directory-{index}"), + &cwd, + vec![ + helper.to_string_lossy().into_owned(), + workspace.to_string_lossy().into_owned(), + relative.into(), + limit.to_string(), + ], + ); + request.sandbox = Some(sandbox.clone()); + let started = environment.get_exec_backend().start(request).await?; + let sandbox_type = format!("{:?}", started.sandbox_type); + ensure!( + sandbox_type == "Some(LinuxSeccomp)", + "required Linux sandbox missing: {sandbox_type}" + ); + let (stdout, stderr, exit) = read_closed(started.process.as_ref()).await?; + ensure!( + exit == Some(0) && stderr.is_empty(), + "directory helper execution failed: {:?} {}", + exit, + String::from_utf8_lossy(&stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&stdout)?; + ensure!(value["version"] == 1, "helper version mismatch"); + if relative == "" { + let entries = value["directory"]["entries"] + .as_array() + .context("root entries")?; + ensure!(entries.len() == 4, "root count mismatch: {value}"); + ensure!( + entries + .iter() + .any(|e| e["name"] == "retained.txt" && e["size_bytes"] == 8), + "retained metadata mismatch" + ); + } else if relative == "sub" && limit == 1 { + ensure!( + value["directory"]["entries"] + .as_array() + .context("limited entries")? + .len() + == 1 + && value["directory"]["truncated"] == true, + "bounded scan mismatch" + ); + } else if relative == "empty" { + ensure!( + value["directory"]["entries"] == serde_json::json!([]) + && value["directory"]["truncated"] == false, + "empty mismatch" + ); + } else { + ensure!(value["error"].is_string(), "expected rejection: {value}"); + } + observed.push(serde_json::json!({"path":relative,"limit":limit,"response":value,"sandbox":sandbox_type,"exit":exit,"closed":true})); + } + std::fs::write( + proof.join("directory-native.json"), + serde_json::to_vec_pretty( + &serde_json::json!({"native_source":"3d2ee51ca2d5db578f328aa75e20aa22c0197c9a","observations":observed,"model_calls":0,"scope":"authenticated native process RPC, actual Docker executor, explicit argv, read-only sandbox, helper output/exit/close; not public API acceptance"}), + )?, + )?; + Ok(()) +} + +fn params(id: &str, cwd: &PathUri, argv: Vec) -> ExecParams { + ExecParams { + process_id: ProcessId::from(id), + argv, + cwd: cwd.clone(), + shell_snapshot: None, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + } +} + +async fn read_closed(process: &dyn ExecProcess) -> Result<(Vec, Vec, Option)> { + timeout(TIMEOUT, async { + let mut after = None; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + let result = process.read(after, Some(65536), Some(1000)).await?; + ensure!(result.failure.is_none(), "process failed"); + for chunk in result.chunks { + match chunk.stream { + ExecOutputStream::Stdout => stdout.extend(chunk.chunk.0), + ExecOutputStream::Stderr => stderr.extend(chunk.chunk.0), + ExecOutputStream::Pty => anyhow::bail!("unexpected PTY"), + } + } + ensure!( + stdout.len() + stderr.len() <= 2 * 1024 * 1024, + "output cap exceeded" + ); + if result.closed { + return Ok((stdout, stderr, result.exit_code)); + } + after = result.next_seq.checked_sub(1); + } + }) + .await? +} diff --git a/services/agents-api/tests/native/environment_model_probe.py b/services/agents-api/tests/native/environment_model_probe.py new file mode 100644 index 000000000..543c8d2c8 --- /dev/null +++ b/services/agents-api/tests/native/environment_model_probe.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Opt-in stock app-server placement evidence; never a substitute model/tool loop.""" +import hashlib +import json +import os +from pathlib import Path +import queue +import subprocess +import threading +import time +import uuid + + +class NativeRPCError(RuntimeError): + def __init__(self, method, error): + super().__init__('native request failed: ' + method) + self.error = error + + +class AppServer: + def __init__(self, binary, root, env, label): + self.messages, self.events, self.sequence = queue.Queue(), [], 0 + self.log = (root / (label + '.stderr')).open('wb') + self.process = subprocess.Popen( + [binary, 'app-server'], cwd=root / 'harness', env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.log, + text=True, + ) + threading.Thread(target=self.read, daemon=True).start() + self.request('initialize', {'clientInfo': {'name': 'parsar_environment_probe', 'version': '1'}, + 'capabilities': {'experimentalApi': True}}) + self.send({'method': 'initialized'}) + + def read(self): + for line in self.process.stdout: + self.messages.put(json.loads(line)) + self.messages.put(None) + + def send(self, message): + self.process.stdin.write(json.dumps(message) + '\n') + self.process.stdin.flush() + + def receive(self, timeout=120): + message = self.messages.get(timeout=timeout) + if message is None: + raise RuntimeError('app-server exited') + self.events.append(message) + if 'method' in message and 'id' in message: + self.send({'id': message['id'], 'error': {'code': -32601, 'message': 'Unexpected request in placement probe'}}) + raise RuntimeError('unexpected approval or client tool request') + return message + + def request(self, method, params, timeout=120): + self.sequence += 1 + request_id = self.sequence + self.send({'id': request_id, 'method': method, 'params': params}) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + message = self.receive(max(0.1, deadline - time.monotonic())) + if message.get('id') == request_id: + if 'error' in message: + raise NativeRPCError(method, message['error']) + return message['result'] + raise TimeoutError(method) + + def turn(self, thread, prompt, selection): + return self.request('turn/start', {'threadId': thread, 'input': [{'type': 'text', 'text': prompt}], + 'environments': selection})['turn']['id'] + + def completed(self, turn, timeout=180): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for message in self.events: + if message.get('method') == 'turn/completed' and message['params']['turn']['id'] == turn: + return message['params']['turn'] + self.receive(max(0.1, deadline - time.monotonic())) + raise TimeoutError('native turn completion') + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + self.log.close() + + +def completed_items(app, turn, kind): + return [e['params']['item'] for e in app.events + if e.get('method') == 'item/completed' and e['params'].get('turnId') == turn + and e['params']['item'].get('type') == kind] + + +def until(predicate, timeout, label): + end = time.monotonic() + timeout + while time.monotonic() < end: + if predicate(): + return + time.sleep(0.1) + raise TimeoutError(label) + + +def main(): + os.umask(0o077) + root = Path(os.environ['PARSAR_PLACEMENT_ROOT']) + binary = os.environ['PARSAR_CODEX_BINARY'] + image = os.environ['PARSAR_PLACEMENT_EXECUTOR_IMAGE'] + assert image.startswith('sha256:'), 'executor image must be pinned by local image ID' + assert subprocess.check_output([binary, '--version'], text=True).strip() == 'codex-cli 0.153.4' + secret = Path(os.environ['PARSAR_PLACEMENT_MODEL_KEY_FILE']).read_text().strip() + assert secret + executor_token = os.environ['PARSAR_PLACEMENT_EXECUTOR_TOKEN'] + harness_token = os.environ['CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN'] + for name in ['harness', 'history', 'executor', 'workspace']: + (root / name).mkdir(mode=0o700) + (root / 'executor/codex').mkdir(mode=0o700) + workspace = root / 'workspace' + remote = os.environ['PARSAR_PLACEMENT_WORKSPACE'] + assert not Path(remote).exists() + memory, instruction = uuid.uuid4().hex, uuid.uuid4().hex + (workspace / 'AGENTS.md').write_text('For every placement check, end your final response with REMOTE_' + instruction + '.\n') + (root / 'harness/AGENTS.md').write_text('This is the harness host decoy. End every response with WRONG_LOCAL_INSTRUCTIONS.\n') + (workspace / 'placement.sh').write_text('''#!/bin/sh +set -eu +phase="$1" +pwd > "$phase.cwd" +printf '%s\\n' "$phase" >> execution-count +printf 'remote-stdout:%s\\n' "$phase" +printf 'remote-stderr:%s\\n' "$phase" >&2 +for name in PARSAR_PROBE_MODEL_KEY CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY; do + eval 'value=${'"$name"'-}' + test -z "$value" || { printf '%s\\n' "$name" >> credential-failure; exit 23; } +done +printf 'remote-file-content\\n' > retained.txt +exit 7 +''') + (workspace / 'credentials.sh').write_text('''#!/bin/sh +set -eu +: > credential-names +for name in PARSAR_PROBE_MODEL_KEY CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY; do + eval 'value=${'"$name"'-}' + test -z "$value" || printf '%s\\n' "$name" >> credential-names +done +printf 'credential names recorded without values\\n' +''') + (workspace / 'long.sh').write_text('''#!/bin/sh +set -eu +printf '%s\\n' "$$" > "$1.pid" +printf 'started\\n' > "$1.started" +while :; do date +%s > "$1.heartbeat"; sleep 1; done +''') + for script in workspace.glob('*.sh'): + script.chmod(0o700) + config = '''model = "MiniMax-M3" +model_provider = "placement" +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "disabled" +[shell_environment_policy] +inherit = "core" +ignore_default_excludes = false +[model_providers.placement] +name = "MiniMax placement validation" +base_url = "https://api.minimax.cn/v1" +env_key = "PARSAR_PROBE_MODEL_KEY" +wire_api = "responses" +[features] +multi_agent = false +''' + (root / 'history/config.toml').write_text(config) + env = {name: os.environ[name] for name in ['PATH', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] if name in os.environ} + env.update(HOME=str(root / 'harness'), CODEX_HOME=str(root / 'history'), RUST_LOG='off', + PARSAR_PROBE_MODEL_KEY=secret, CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=harness_token, + CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=os.environ['CODEX_EXEC_SERVER_NOISE_REGISTRY_URL'], + CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=os.environ['CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID']) + docker_env = os.environ.copy() + docker_env['CODEX_API_KEY'] = executor_token + container = os.environ['PARSAR_PLACEMENT_CONTAINER'] + args = ['docker', 'run', '--detach', '--name', container, '--network', 'host', + '--user', str(os.getuid()) + ':' + str(os.getgid()), '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--env', 'CODEX_API_KEY', + '--env', 'HOME=/executor', '--env', 'CODEX_HOME=/executor/codex', '--env', 'RUST_LOG=off', + '--env', 'NO_PROXY=127.0.0.1,localhost', '--workdir', remote, + '--mount', f'type=bind,src={binary},dst=/usr/local/bin/codex,readonly', + '--mount', f'type=bind,src={root / "executor"},dst=/executor', + '--mount', f'type=bind,src={workspace},dst={remote}', + '--entrypoint', '/usr/local/bin/codex', image, 'exec-server', '--remote', + env['CODEX_EXEC_SERVER_NOISE_REGISTRY_URL'], '--environment-id', env['CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID']] + apps, report = [], {'native_version': '0.153.4', 'executor_image': image, 'remote_cwd': remote, + 'native_source': '3d2ee51ca2d5db578f328aa75e20aa22c0197c9a', + 'native_binary_sha256': hashlib.sha256(Path(binary).read_bytes()).hexdigest(), + 'probe_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + selection = [{'environmentId': 'remote', 'cwd': remote, 'runtimeWorkspaceRoots': [remote]}] + def start(label, override=None): + app = AppServer(binary, root, env if override is None else override, label) + apps.append(app) + return app + def persist(): + payload = json.dumps({'report': report, 'events': [a.events for a in apps]}, indent=2) + files = [p for folder in ['history', 'executor'] for p in (root / folder).rglob('*') if p.is_file()] + files += list(root.glob('*.stderr')) + for value in [secret, executor_token, harness_token]: + assert value not in payload, 'credential in native payload' + assert all(value.encode() not in p.read_bytes() for p in files), 'credential in native history/logs' + (root / 'proof.json').write_text(payload) + try: + subprocess.run(args, env=docker_env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=30) + time.sleep(1) + running = subprocess.check_output(['docker', 'inspect', '--format', '{{.State.Running}}', container], text=True).strip() + assert running == 'true', 'native executor exited during startup' + app = start('first') + assert app.request('environment/status', {'environmentId': 'local'})['status'] == 'unknown' + report['remote_info'] = app.request('environment/info', {'environmentId': 'remote'}, timeout=40) + params = {'cwd': str(root / 'harness'), 'model': 'MiniMax-M3', 'modelProvider': 'placement', + 'approvalPolicy': 'never', 'sandbox': 'danger-full-access', 'environments': selection} + started = app.request('thread/start', params) + thread = started['thread']['id'] + report['thread_start'] = started + turn = app.turn(thread, 'Placement check. Remember the memory word ' + memory + '. Run the exact command `./placement.sh first` once using the native shell tool. Exit 7 is intentional; do not retry or change the script. Report its stdout/stderr and memory word briefly.', selection) + assert app.completed(turn)['status'] == 'completed' + commands = completed_items(app, turn, 'commandExecution') + assert any(c.get('exitCode') == 7 and 'remote-stdout:first' in c.get('aggregatedOutput', '') and 'remote-stderr:first' in c.get('aggregatedOutput', '') for c in commands), 'native output/exit evidence missing' + assert (workspace / 'first.cwd').read_text().strip() == remote + assert not (workspace / 'credential-failure').exists() + assert instruction in json.dumps(completed_items(app, turn, 'agentMessage')), 'remote AGENTS instructions absent' + assert 'WRONG_LOCAL_INSTRUCTIONS' not in json.dumps(app.events) + report['first_turn'] = turn + app.close() + time.sleep(2) + app = start('resume') + resumed = app.request('thread/resume', {'threadId': thread, 'approvalPolicy': 'never', 'sandbox': 'danger-full-access'}) + assert resumed['thread']['id'] == thread + report['thread_resume'] = resumed + turn = app.turn(thread, 'Placement check. State the memory word from our earlier conversation. Run the exact command `./placement.sh resumed` once and read retained.txt using the native shell. Exit 7 is intentional; do not retry or change the script.', selection) + assert app.completed(turn)['status'] == 'completed' + fresh_events = completed_items(app, turn, 'agentMessage') + assert memory in json.dumps(fresh_events), 'native history was not recalled' + assert instruction in json.dumps(fresh_events), 'remote instruction not retained/applied' + assert 'WRONG_LOCAL_INSTRUCTIONS' not in json.dumps(fresh_events) + assert (workspace / 'resumed.cwd').read_text().strip() == remote + assert (workspace / 'execution-count').read_text().splitlines() == ['first', 'resumed'] + report['cold_resume_turn'] = turn + def alive(label): + pid = (workspace / (label + '.pid')).read_text().strip() + result = subprocess.run(['docker', 'exec', container, 'sh', '-c', 'test -r /proc/"$1"/stat && test "$(cut -d " " -f 3 /proc/"$1"/stat)" != Z', 'probe', pid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + assert result.returncode in (0, 1), 'remote process observation failed' + assert subprocess.check_output(['docker', 'inspect', '--format', '{{.State.Running}}', container], text=True).strip() == 'true' + return result.returncode == 0 + turn = app.turn(thread, 'Run the exact command `./long.sh cancelled` with the native shell tool and keep waiting for it. It will be interrupted externally. Do not start any other command.', selection) + until(lambda: (workspace / 'cancelled.started').exists(), 120, 'remote long command start') + assert alive('cancelled') + app.request('turn/interrupt', {'threadId': thread, 'turnId': turn}) + ended = app.completed(turn) + assert ended['status'] == 'interrupted' + time.sleep(2) + active_after_interrupt = alive('cancelled') + heartbeat = (workspace / 'cancelled.heartbeat').read_text() + time.sleep(2) + report['cancel'] = {'turn': turn, 'native_status': ended['status'], + 'remote_alive_after_interrupt': active_after_interrupt and alive('cancelled'), + 'heartbeat_advanced_after_interrupt': heartbeat != (workspace / 'cancelled.heartbeat').read_text()} + assert report['cancel']['remote_alive_after_interrupt'] and report['cancel']['heartbeat_advanced_after_interrupt'], 'pinned native cancellation behavior changed; reassess the integration gap' + owned = {(e['params']['item']['id'], e['params']['item'].get('processId')) for e in app.events + if e.get('method') in ('item/started', 'item/completed') and e['params'].get('turnId') == turn + and e['params']['item'].get('type') == 'commandExecution'} + listed = app.request('thread/backgroundTerminals/list', {'threadId': thread, 'limit': 100}) + assert listed['nextCursor'] is None, 'unexpected background-terminal pagination in this bounded fixture' + targets = [p for p in listed['data'] if (p['itemId'], p['processId']) in owned] + assert len(targets) == 1 and targets[0]['cwd'] == remote, 'cannot identify the current Turn process' + stopped_at = time.monotonic() + receipt = app.request('thread/backgroundTerminals/terminate', {'threadId': thread, 'processId': targets[0]['processId']}) + assert receipt['terminated'] is True + until(lambda: not alive('cancelled'), 10, 'remote exit after native targeted termination') + heartbeat = (workspace / 'cancelled.heartbeat').read_text() + time.sleep(2) + assert (workspace / 'cancelled.heartbeat').read_text() == heartbeat + report['cancel']['native_targeted_termination'] = {'target': targets[0], 'receipt': receipt, + 'remote_exit_observed': True, 'heartbeat_stopped': True, 'seconds': time.monotonic() - stopped_at} + app.close() + time.sleep(2) + app = start('after-cancel') + assert app.request('thread/resume', {'threadId': thread, 'approvalPolicy': 'never', 'sandbox': 'danger-full-access'})['thread']['id'] == thread + turn = app.turn(thread, 'Placement check. Use the native shell to read retained.txt, then state the original memory word briefly.', selection) + assert app.completed(turn)['status'] == 'completed' + assert memory in json.dumps(completed_items(app, turn, 'agentMessage')), 'history missing after targeted cancellation' + assert any('remote-file-content' in c.get('aggregatedOutput', '') for c in completed_items(app, turn, 'commandExecution')), 'retained file was not read after cancellation' + report['after_cancel_turn'] = turn + app.close() + time.sleep(2) + default_policy = config.replace('inherit = "core"\nignore_default_excludes = false', 'inherit = "all"\nignore_default_excludes = true') + (root / 'history/config.toml').write_text(default_policy) + app = start('default-policy') + baseline = app.request('thread/start', params)['thread']['id'] + turn = app.turn(baseline, 'Run the exact command `./credentials.sh` once with the native shell. It records variable names only. Do not print any environment values. Reply done.', selection) + assert app.completed(turn)['status'] == 'completed' + names = (workspace / 'credential-names').read_text().splitlines() + assert names == ['CODEX_API_KEY'], 'default-policy exposure differs; inspect recorded names without values' + report['default_policy_visible_credential_names'] = names + app.close() + time.sleep(2) + (root / 'history/config.toml').write_text(config) + invalid_env = dict(env, CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=uuid.uuid4().hex) + app = start('invalid-auth', invalid_env) + assert app.request('environment/status', {'environmentId': 'local'})['status'] == 'unknown' + try: + app.request('environment/info', {'environmentId': 'remote'}, timeout=30) + except NativeRPCError as error: + assert error.error['code'] == -32603 and '401 Unauthorized' in error.error['message'] + report['invalid_authorization_rejected'] = True + else: + raise AssertionError('invalid registry authorization accepted') + assert not Path(remote).exists() + assert not list((root / 'harness').glob('*.cwd')) + assert not (workspace / 'credential-failure').exists() + report['harness_local_path_not_created'] = True + report['status'] = 'characterized_with_blockers' + report['integration_blockers'] = ['turn/interrupt preserves background execution; typed dispatch must retain Turn ownership and apply native targeted termination where required', 'typed dispatch credential lifetime/readiness/public lifecycle are not implemented'] + finally: + for app in apps: + app.close() + with (root / 'executor.stderr').open('wb') as log: + subprocess.run(['docker', 'logs', container], stdout=log, stderr=log, timeout=10) + subprocess.run(['docker', 'rm', '-f', container], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) + if report.get('status') != 'characterized_with_blockers': + report['status'] = 'failed' + persist() + + +if __name__ == '__main__': + try: + main() + except BaseException: + import traceback + (Path(os.environ['PARSAR_PLACEMENT_ROOT']) / 'failure.txt').write_text(traceback.format_exc()) + raise diff --git a/services/agents-api/tests/native/raw_files/.gitattributes b/services/agents-api/tests/native/raw_files/.gitattributes new file mode 100644 index 000000000..48fe5701e --- /dev/null +++ b/services/agents-api/tests/native/raw_files/.gitattributes @@ -0,0 +1 @@ +client-dependency.patch whitespace=-blank-at-eol diff --git a/services/agents-api/tests/native/raw_files/README.md b/services/agents-api/tests/native/raw_files/README.md new file mode 100644 index 000000000..87cab141f --- /dev/null +++ b/services/agents-api/tests/native/raw_files/README.md @@ -0,0 +1,116 @@ +# Raw remote Files and history qualification + +This opt-in fixture uses the pinned upstream `RemoteAppServerClient` over a private +Unix socket and the [qualified manager hook](../raw_manager/README.md). Typed Files +and native execution share the stock-built EnvironmentManager and one authorized +registry/Noise pair. The socket is a local control connection, separate from that +executor pair. No production daemon path or public Files API is enabled. + +## Prepare and build + +Use the Linux/toolchain/native-resource prerequisites in the manager qualification. +The shared preparation command exports the exact native commit and verifies both +the manager patch and the fixture-only client dependency patch. The latter adds +one dev-dependency/lock edge to an existing exact-pin workspace crate; third-party +packages and versions remain unchanged. The original workspace-version-only lock +normalization is recorded separately. All hashes are in `source.json`. + +From the Parsar worktree, with an absolute `NATIVE_SOURCE` Git checkout path: + +```sh +export RAW_FILES_ROOT="$HOME/.parsar/raw-files-qualification" +python3 services/agents-api/tests/native/raw_manager/prepare.py \ + --manifest services/agents-api/tests/native/raw_files/source.json \ + --source "$NATIVE_SOURCE" --output "$RAW_FILES_ROOT/source" +mkdir -p "$RAW_FILES_ROOT/state" +export TMPDIR="$RAW_FILES_ROOT/state" +export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" +export CARGO_TARGET_DIR="$HOME/.parsar/cache/raw-files-target" +export RUSTUP_TOOLCHAIN=1.95 +export CARGO_PROFILE_DEV_DEBUG=0 +cd "$RAW_FILES_ROOT/source/codex-rs" +cargo build --locked -p codex-app-server \ + --example parsar_raw_files_probe --example parsar_shared_files_probe +cargo clippy --locked -p codex-app-server \ + --example parsar_raw_files_probe --example parsar_shared_files_probe -- -D warnings +rustfmt --check --edition 2024 app-server/examples/parsar_raw_files_probe.rs \ + app-server/examples/parsar_shared_files_probe.rs +``` + +Use a new output directory. Keep `preparation.json`, the compiled probe and matching +native helper/launcher hashes, toolchain, command outcomes and failed evidence. +Run the required repository `make check` separately; native builds and model calls +are explicit opt-in acceptance checks. + +## Real-provider acceptance + +Supply the database, native helper/launcher, pinned executor image, private proof +root and real model key prerequisites in the [native guide](../README.md#shared-native-filesystem-owner). +Point `PARSAR_RAW_FILES_PROBE` at the raw example. From the Parsar worktree: + +```sh +go test ./services/agents-api/internal/store \ + -run '^TestNativeRawEnvironmentFiles$' -count=1 -v -timeout=12m +``` + +The outer Go fixture owns the actual leased database, registry, separate executor +and harness credentials, executor container and independently observed workspace. +It rejects proof directories outside the caller's canonical `~/.parsar` before +creating state. It retains the original caller HOME while giving the native probe +an isolated HOME. Use a short private state/socket path; Unix socket path limits +still apply. A phase uses one native owner and one authenticated pair. + +Both first/fresh phases check 128 KiB binary bytes/hash and metadata/listing while +idle and during a real native command, exact remote effects and observed command +output/status, then fresh-process native history with retained files and a +prompt-only random value. The legacy in-process fixture remains available through +`TestNativeSharedEnvironmentFiles` and `PARSAR_SHARED_FILES_PROBE`; run it with the +prepared legacy example when shared fixture code changes. + +Both fixtures also read a retained 2 MiB + 37-byte binary through the native +same-manager `read_file_stream`, including a 4096-byte prefix and an empty file. +The checks run while idle, during execution and after cold continuation, preserving +the original whole-file, metadata and execution assertions. The retained result is +bounded, but the native stream may fetch a complete 1 MiB chunk for a short prefix. +Early drop schedules native close; neither it nor EOF proves a close receipt or +settlement of all underlying I/O. These checks do not qualify caller detachment, +path confinement or snapshot consistency for a production Files interface. + +The dedicated cancellation workflow preserves that ordinary regression and adds a +separate `first -> cancel -> fresh` run with the same raw example: + +```sh +go test ./services/agents-api/internal/store \ + -run '^TestNativeRawEnvironmentFilesCancellation$' -count=1 -v -timeout=12m +``` + +Go independently observes the active command before allowing native interruption. +The fixture distinguishes its acknowledgement from the observed interrupted Turn, +nullable command completion, targeted background-terminal termination and command +effects. A termination target must match the current Turn's observed native item +and process identifiers; an OS PID never selects a native target. The owner stays +alive while typed Files and the independently observed command exit are checked. +After a fresh process resumes, native Turn listing must retain the interrupted +Turn, and typed Files must retain the post-cancel marker and binary hash before new +execution. Completed command counts prove old work was not rerun. Keep native +typed bodies and actual unknown fields; do not synthesize an exit code or final +answer for cancellation. + +## Limits + +The maintained remote client has an internal unbounded event channel and uses its +pinned typed notification parser. The fixture's finite event/byte assertions do not +bound that queue or prove preservation of unknown notifications. This workflow +qualifies private Files/execution/history composition, not production backpressure, +complete output, public Files paths/references/pagination, idle ownership or caller +and tenant authorization. Client shutdown alone does not stop the runner; the +fixture must use native runner shutdown and join it before claiming completion. + +Only the dedicated three-phase workflow qualifies this cancellation composition; +the ordinary first/fresh checks do not exercise it. Command PID exit and a stable +heartbeat are bounded observations, not proof that all descendants are gone. +Production adoption also requires exact Environment/device/generation ownership, bounded capacity and +lifetime, history retention and stale-write retirement. Never replay an unknown +mutation. Readiness-gated command output does not fix the recorded native early-output +limitation. Real model calls are necessary for this acceptance; no-model or synthetic +checks cannot substitute for it. diff --git a/services/agents-api/tests/native/raw_files/client-dependency.patch b/services/agents-api/tests/native/raw_files/client-dependency.patch new file mode 100644 index 000000000..fbcc00922 --- /dev/null +++ b/services/agents-api/tests/native/raw_files/client-dependency.patch @@ -0,0 +1,20 @@ +--- a/codex-rs/app-server/Cargo.toml ++++ b/codex-rs/app-server/Cargo.toml +@@ -118,6 +118,7 @@ + codex-windows-sandbox = { workspace = true } + + [dev-dependencies] ++codex-app-server-client = { workspace = true } + app_test_support = { workspace = true } + axum = { workspace = true, default-features = false, features = [ + "http1", +--- a/codex-rs/Cargo.lock ++++ b/codex-rs/Cargo.lock +@@ -2032,6 +2032,7 @@ + "clap", + "codex-agent-extension", + "codex-analytics", ++ "codex-app-server-client", + "codex-app-server-protocol", + "codex-app-server-transport", + "codex-arg0", diff --git a/services/agents-api/tests/native/raw_files/source.json b/services/agents-api/tests/native/raw_files/source.json new file mode 100644 index 000000000..e12968564 --- /dev/null +++ b/services/agents-api/tests/native/raw_files/source.json @@ -0,0 +1,63 @@ +{ + "repository": "https://github.com/openai/codex", + "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", + "native_version": "0.153.4", + "rust_toolchain": "1.95.0", + "scope": "private raw remote Files/execution/cancellation/cold-history qualification; no production selection", + "patch": { + "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", + "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" + }, + "cargo_lock": { + "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", + "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", + "workspace_packages": 149 + }, + "fixtures": [ + { + "source": "../shared_files_probe.rs", + "target": "codex-rs/app-server/examples/parsar_shared_files_probe.rs" + }, + { + "source": "../raw_files_probe.rs", + "target": "codex-rs/app-server/examples/parsar_raw_files_probe.rs" + }, + { + "source": "../shared_files/files.rs", + "target": "codex-rs/app-server/examples/shared_files/files.rs" + }, + { + "source": "../shared_files/runtime.rs", + "target": "codex-rs/app-server/examples/shared_files/runtime.rs" + }, + { + "source": "../shared_files/raw_runtime.rs", + "target": "codex-rs/app-server/examples/shared_files/raw_runtime.rs" + }, + { + "source": "../shared_files/probe.rs", + "target": "codex-rs/app-server/examples/shared_files/probe.rs" + }, + { + "source": "../shared_files/observations.rs", + "target": "codex-rs/app-server/examples/shared_files/observations.rs" + }, + { + "source": "../shared_files/configuration.rs", + "target": "codex-rs/app-server/examples/shared_files/configuration.rs" + }, + { + "source": "../shared_files/cancellation.rs", + "target": "codex-rs/app-server/examples/shared_files/cancellation.rs" + } + ], + "fixture_patch": { + "file": "client-dependency.patch", + "sha256": "76df7fcb970ca67bd1f40b2d98e5a2f8ec24564f2e772a69a7e9e9b7ad7f2cc9", + "scope": "one dev-dependency edge to the existing exact-pin workspace client; no third-party changes", + "prepared_files": { + "codex-rs/app-server/Cargo.toml": "c0d1cbed4ab6256ba28a3bbfaad7b8217ae928dce29b8299bd725027c63e350e", + "codex-rs/Cargo.lock": "25dbeba0fe924e6501168dd8b54670a6ec9d0639cd3ad0ccbb5828f1d4b60fa6" + } + } +} diff --git a/services/agents-api/tests/native/raw_files_probe.rs b/services/agents-api/tests/native/raw_files_probe.rs new file mode 100644 index 000000000..b421db6fc --- /dev/null +++ b/services/agents-api/tests/native/raw_files_probe.rs @@ -0,0 +1,16 @@ +#[path = "shared_files/cancellation.rs"] +mod cancellation; +#[path = "shared_files/configuration.rs"] +mod configuration; +#[path = "shared_files/files.rs"] +mod files; +#[path = "shared_files/observations.rs"] +mod observations; +#[path = "shared_files/probe.rs"] +mod probe; +#[path = "shared_files/raw_runtime.rs"] +mod runtime; + +fn main() -> std::process::ExitCode { + probe::main(true) +} diff --git a/services/agents-api/tests/native/raw_manager/.gitattributes b/services/agents-api/tests/native/raw_manager/.gitattributes new file mode 100644 index 000000000..94f0862b4 --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/.gitattributes @@ -0,0 +1 @@ +manager-exposure.patch whitespace=-blank-at-eol diff --git a/services/agents-api/tests/native/raw_manager/README.md b/services/agents-api/tests/native/raw_manager/README.md new file mode 100644 index 000000000..e557e4a06 --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/README.md @@ -0,0 +1,81 @@ +# Raw app-server manager qualification + +This private experiment exposes the `EnvironmentManager` created by the stock +Codex raw runner. Native requests and typed filesystem operations can therefore +use the same manager without adopting the in-process notification queue. It adds +one explicit embedding entrypoint; the ordinary entrypoint retains its behavior. +The patch is a local dependency experiment, not an available upstream API or a +production runtime selection. + +`source.json` pins Codex 0.153.4, commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, the patch hash, Rust 1.95.0 and +the build lock overlay. The upstream lock records 149 workspace packages as +`0.0.0` while its manifest uses `0.153.4`. Preparation changes only those version +entries and checks the exact original/result hashes; no dependency resolver or +third-party update is involved. Keep this qualification separate from the public +Agents API protocol pin and the existing production native installation. + +## Prepare and run + +Use Linux with Python 3.10+, Git, tar, Rust 1.95.0, rustfmt, Clippy, a C toolchain, +pkg-config and OpenSSL development headers. Supply an existing official Codex Git +checkout containing the exact commit and its matching native executable/resources. +The source export uses the named commit, ignoring any local checkout changes. + +From the Parsar worktree, set `NATIVE_SOURCE` and `CODEX_BINARY` to absolute paths: + +```sh +export RAW_MANAGER_ROOT="$HOME/.parsar/raw-manager-qualification" +python3 services/agents-api/tests/native/raw_manager/prepare.py \ + --source "$NATIVE_SOURCE" --output "$RAW_MANAGER_ROOT/source" +mkdir -p "$RAW_MANAGER_ROOT/state" +export TMPDIR="$RAW_MANAGER_ROOT/state" +export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" +export CARGO_TARGET_DIR="$HOME/.parsar/cache/raw-manager-target" +export RUSTUP_TOOLCHAIN=1.95 +export CARGO_PROFILE_DEV_DEBUG=0 +rustc --version # The installed toolchain must report 1.95.0. +cd "$RAW_MANAGER_ROOT/source/codex-rs" +cargo build --locked -p codex-app-server --example parsar_raw_manager_probe +cargo test --locked -p codex-app-server --example parsar_raw_manager_probe +cargo clippy --locked -p codex-app-server --example parsar_raw_manager_probe -- -D warnings +rustfmt --check --edition 2024 app-server/examples/parsar_raw_manager_probe.rs +cargo test --locked -p codex-app-server --lib transport::tests +for mode in smoke receiver-dropped startup-failure late-startup-failure; do + "$CARGO_TARGET_DIR/debug/examples/parsar_raw_manager_probe" \ + "$mode" "$CODEX_BINARY" "$RAW_MANAGER_ROOT/state" +done +``` + +Use a new output directory for each prepared source. Failed preparation leaves +its partial directory for inspection and never overwrites an existing tree. +Retain `preparation.json`, command outputs/exit codes, toolchain versions and the +compiled probe/helper SHA-256 values with the acceptance evidence. A mismatched +patch or upstream lock fails preparation; there is no fallback to another pin. +Native tests and build are opt-in, separate from the repository's `make check`. + +## What this establishes + +The deterministic fixture starts the actual raw stdio runner, performs its native +handshake and adds a fresh Environment through native RPC. Looking up that new ID +through the published handle and using its typed filesystem checks shared manager +identity; a same-disk read/write alone would not. Failure modes exercise a dropped +manager receiver and startup failure. Existing native transport tests retain the +stock backpressure/disconnect regression coverage. + +The fixture resolves proof paths within the caller's `~/.parsar` before creating +state. Its isolated child HOME retains the original caller context for this check; +the path test rejects misleading components, parent traversal and symlink escapes. + +Publication means that a manager handle exists. It does not establish completed +initialization, remote readiness, caller authorization or revocation. An `Arc` may +outlive the runner, so a future owner must supervise failure and release it. + +There are no model calls in this seam qualification. Real remote Files plus model +execution/cancellation/cold-history composition remain required before adoption. +Idle ownership, tenant/generation checks, workspace confinement, unknown write +outcomes and public Files paths/references/pagination remain separate work. This +does not fix or validate every native event queue, and it does not change the +existing shared-manager probe's recorded limitations. Production adoption must +explicitly own patch maintenance and remove the patch when a suitable maintained +upstream entrypoint is selected and verified. diff --git a/services/agents-api/tests/native/raw_manager/owner.rs b/services/agents-api/tests/native/raw_manager/owner.rs new file mode 100644 index 000000000..6160adc0a --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/owner.rs @@ -0,0 +1,159 @@ +use anyhow::{Context, Result, ensure}; +use codex_app_server::{ + AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, + PluginStartupTasks, RemoteControlStartupMode, + run_main_with_transport_options_and_environment_manager, +}; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_exec_server::EnvironmentManager; +use codex_protocol::protocol::SessionSource; +use codex_utils_cli::CliConfigOverrides; +use codex_utils_path_uri::PathUri; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +pub const ENVIRONMENT_ID: &str = "raw-manager-smoke"; +pub const CONTENT: &[u8] = b"typed Files through the stock raw runner\n"; + +async fn run( + native: &Path, + transport: AppServerTransport, + invalid: bool, + sender: oneshot::Sender>, +) -> std::io::Result<()> { + let mut loader = LoaderOverrides::without_managed_config_for_tests(); + loader.ignore_user_config = true; + loader.ignore_project_config = true; + run_main_with_transport_options_and_environment_manager( + Arg0DispatchPaths { + codex_self_exe: Some(native.to_path_buf()), + ..Default::default() + }, + CliConfigOverrides { + raw_overrides: if invalid { + vec!["missing-equals".into()] + } else { + vec![] + }, + }, + loader, + true, + false, + transport, + SessionSource::Exec, + AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions { + plugin_startup_tasks: PluginStartupTasks::Skip, + remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, + install_shutdown_signal_handler: false, + ..Default::default() + }, + sender, + ) + .await +} + +pub async fn serve(native: &Path, root: &Path) -> Result<()> { + let (sender, receiver) = oneshot::channel::>(); + let files = async { + let manager = receiver.await.context("manager was not published")?; + ensure!( + manager.try_local_environment().is_none(), + "local fallback is configured" + ); + // This ID can appear only after the controller's initialized raw RPC. + let environment = tokio::time::timeout(Duration::from_secs(20), async { + loop { + if let Some(environment) = manager.get_environment(ENVIRONMENT_ID) { + break environment; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .context("raw environment/add did not reach the published manager")?; + ensure!( + environment.is_remote(), + "raw-added environment is not remote" + ); + let filesystem = environment.get_filesystem(); + let path = PathUri::from_host_native_path(root.join("remote-file.txt"))?; + filesystem + .write_file(&path, CONTENT.to_vec(), Default::default(), None) + .await?; + let bytes = filesystem + .read_file(&path, Default::default(), None) + .await?; + ensure!( + bytes == CONTENT, + "typed remote read returned different bytes" + ); + tokio::fs::write( + root.join("typed-files.pending"), + b"{\"shared_manager\":true,\"typed_files\":true}\n", + ) + .await?; + tokio::fs::rename( + root.join("typed-files.pending"), + root.join("typed-files.json"), + ) + .await?; + Ok::<_, anyhow::Error>(()) + }; + tokio::try_join!( + async { + run(native, AppServerTransport::Stdio, false, sender) + .await + .map_err(Into::into) + }, + files + )?; + Ok(()) +} + +pub async fn failure(native: &Path, mode: &str) -> Result<()> { + let (sender, mut receiver) = oneshot::channel::>(); + if mode == "receiver-dropped" { + drop(receiver); + let error = run(native, AppServerTransport::Stdio, false, sender) + .await + .err() + .context("dropped receiver must fail startup")?; + ensure!( + error.kind() == ErrorKind::BrokenPipe, + "wrong dropped-receiver error: {error}" + ); + } else if mode == "startup-failure" { + ensure!( + run(native, AppServerTransport::Stdio, true, sender) + .await + .is_err() + ); + ensure!( + matches!( + receiver.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + ), + "failed configuration published a manager" + ); + } else { + // A bind failure occurs after handle publication, proving it is not readiness. + let occupied = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let transport = format!("ws://{}", occupied.local_addr()?).parse()?; + ensure!(run(native, transport, false, sender).await.is_err()); + let manager = receiver + .try_recv() + .context("expected publication before bind failure")?; + let weak = Arc::downgrade(&manager); + drop(manager); + ensure!( + weak.upgrade().is_none(), + "failed startup retained the manager" + ); + } + Ok(()) +} diff --git a/services/agents-api/tests/native/raw_manager/prepare.py b/services/agents-api/tests/native/raw_manager/prepare.py new file mode 100644 index 000000000..dfce6b48c --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/prepare.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Prepare an exact-pin, explicitly patched native qualification source tree.""" + +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def prepare(source, output, manifest_file=None): + manifest_file = manifest_file or Path(__file__).resolve().with_name("source.json") + here = manifest_file.parent + manifest_bytes = manifest_file.read_bytes() + manifest = json.loads(manifest_bytes) + patch = here / manifest["patch"]["file"] + patch_bytes = patch.read_bytes() + if sha(patch_bytes) != manifest["patch"]["sha256"]: + raise ValueError("native patch does not match source.json") + patches = [patch] + fixture_patch = manifest.get("fixture_patch") + if fixture_patch: + fixture = here / fixture_patch["file"] + if sha(fixture.read_bytes()) != fixture_patch["sha256"]: + raise ValueError("fixture dependency patch does not match source.json") + patches.append(fixture) + revision = manifest["revision"] + resolved = subprocess.check_output( + ["git", "-C", str(source), "rev-parse", revision + "^{commit}"], text=True + ).strip() + if resolved != revision: + raise ValueError("native source revision differs") + runtime = (Path.home() / ".parsar").resolve() + if not output.is_relative_to(runtime) or output == runtime: + raise ValueError("output must be a new directory below ~/.parsar") + output.mkdir(parents=True, exist_ok=False) + # Export the named commit, never the caller's potentially modified checkout. + with subprocess.Popen( + ["git", "-C", str(source), "archive", "--format=tar", revision], + stdout=subprocess.PIPE, + ) as archive: + try: + subprocess.run(["tar", "-xf", "-", "-C", str(output)], stdin=archive.stdout, check=True) + finally: + archive.stdout.close() + if archive.wait() != 0: + raise RuntimeError("native source export failed") + + lock = output / "codex-rs/Cargo.lock" + original = lock.read_bytes() + overlay = manifest["cargo_lock"] + if sha(original) != overlay["original_sha256"]: + raise ValueError("unexpected upstream Cargo.lock") + parts = original.split(b"[[package]]") + changed = 0 + for index, part in enumerate(parts[1:], 1): + if b'\nsource = ' not in part and b'\nversion = "0.0.0"\n' in part: + parts[index] = part.replace(b'\nversion = "0.0.0"\n', b'\nversion = "0.153.4"\n', 1) + changed += 1 + normalized = b"[[package]]".join(parts) + if changed != overlay["workspace_packages"] or sha(normalized) != overlay["normalized_sha256"]: + raise ValueError("workspace-only lock normalization differs") + lock.write_bytes(normalized) + for item in patches: + subprocess.run(["git", "apply", "--check", str(item)], cwd=output, check=True) + subprocess.run(["git", "apply", str(item)], cwd=output, check=True) + if fixture_patch: + for name, expected in fixture_patch["prepared_files"].items(): + if sha((output / name).read_bytes()) != expected: + raise ValueError("fixture dependency source differs: " + name) + + fixtures = {} + for item in manifest["fixtures"]: + data = (here / item["source"]).read_bytes() + target = output / item["target"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + fixtures[item["target"]] = sha(data) + record = { + "revision": revision, + "manifest_sha256": sha(manifest_bytes), + "patch_sha256": sha(patch_bytes), + "cargo_lock": overlay, + "fixtures": fixtures, + } + if fixture_patch: + record["fixture_patch"] = fixture_patch + (output / "preparation.json").write_text(json.dumps(record, indent=2) + "\n") + print(output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, type=Path, help="existing native Git checkout") + parser.add_argument("--output", required=True, type=Path, help="new directory under ~/.parsar") + parser.add_argument("--manifest", type=Path, help="explicit native qualification manifest") + args = parser.parse_args() + source, output = args.source.expanduser(), args.output.expanduser() + if not source.is_absolute() or not output.is_absolute(): + parser.error("source and output must be absolute paths") + manifest_file = args.manifest.expanduser().resolve() if args.manifest else None + prepare(source.resolve(), output.resolve(), manifest_file) diff --git a/services/agents-api/tests/native/raw_manager/probe.rs b/services/agents-api/tests/native/raw_manager/probe.rs new file mode 100644 index 000000000..19dfcbaf9 --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/probe.rs @@ -0,0 +1,246 @@ +//! Opt-in no-model qualification of the additive raw-runner manager hook. + +#[path = "raw_manager/owner.rs"] +mod owner; + +use anyhow::{Context, Result, bail, ensure}; +use codex_exec_server::ExecServerRuntimePaths; +use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdout, Command}; + +const DEADLINE: Duration = Duration::from_secs(30); +// Match pinned arg0/async-utils stack sizing for large native futures. +const NATIVE_STACK_BYTES: usize = 16 * 1024 * 1024; +const CALLER_HOME_ENV: &str = "PARSAR_RAW_MANAGER_CALLER_HOME"; + +fn main() -> Result<()> { + std::thread::Builder::new() + .name("raw-manager-main".into()) + .stack_size(NATIVE_STACK_BYTES) + .spawn(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(4) + .thread_stack_size(NATIVE_STACK_BYTES) + .build()? + .block_on(run()) + })? + .join() + .map_err(|_| anyhow::anyhow!("native main thread panicked"))? +} + +async fn run() -> Result<()> { + let args: Vec = std::env::args().collect(); + ensure!( + args.len() == 4, + "usage: probe MODE NATIVE_BINARY PROOF_DIRECTORY" + ); + let native = Path::new(&args[2]); + let root = Path::new(&args[3]); + ensure!( + native.is_absolute() && root.is_absolute(), + "absolute paths are required" + ); + let home_variable = match args[1].as_str() { + "owner" + | "child-receiver-dropped" + | "child-startup-failure" + | "child-late-startup-failure" => CALLER_HOME_ENV, + _ => "HOME", + }; + let caller_home = + PathBuf::from(std::env::var_os(home_variable).context("caller HOME is missing")?); + let root = proof_root(root, &caller_home)?; + match args[1].as_str() { + "owner" => owner::serve(native, &root).await, + "child-receiver-dropped" | "child-startup-failure" | "child-late-startup-failure" => { + owner::failure(native, args[1].trim_start_matches("child-")).await + } + "smoke" | "receiver-dropped" | "startup-failure" | "late-startup-failure" => { + let root = tempfile::Builder::new() + .prefix("raw-manager-") + .tempdir_in(&root)? + .keep(); + let result = tokio::time::timeout(DEADLINE, qualify(&args[1], native, &root)).await; + println!( + "{}", + json!({"case":args[1],"evidence":root,"passed":matches!(&result, Ok(Ok(())))}) + ); + result.context("qualification timed out")? + } + _ => bail!("unknown qualification mode"), + } +} + +fn proof_root(root: &Path, caller_home: &Path) -> Result { + ensure!(caller_home.is_absolute(), "caller HOME must be absolute"); + let expected = caller_home + .join(".parsar") + .canonicalize() + .context("resolve caller state directory")?; + let actual = root.canonicalize().context("resolve proof directory")?; + ensure!( + actual.starts_with(expected), + "proof directory must resolve under caller ~/.parsar" + ); + Ok(actual) +} + +fn child(mode: &str, native: &Path, root: &Path) -> Result { + let home = root.join("codex"); + std::fs::create_dir_all(&home)?; + let stderr = std::fs::File::create(root.join("owner.stderr"))?; + Command::new(std::env::current_exe()?) + .args([ + mode, + native.to_str().context("native path is not UTF-8")?, + root.to_str().context("proof path is not UTF-8")?, + ]) + .env_clear() + .env("HOME", root) + // Retained fixture context, not authorization; native HOME stays isolated. + .env( + CALLER_HOME_ENV, + std::env::var_os("HOME").context("caller HOME is missing")?, + ) + .env("CODEX_HOME", home) + .env("TMPDIR", root) + .env("PATH", "/usr/bin:/bin") + .env("CODEX_EXEC_SERVER_URL", "none") + .env("RUST_LOG", "warn") + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr) + .kill_on_drop(true) + .spawn() + .context("launch raw-runner owner") +} + +async fn response(lines: &mut Lines>, id: i64) -> Result { + while let Some(line) = lines.next_line().await? { + let value: Value = serde_json::from_str(&line).context("native stdout was not JSON")?; + if value["id"] == id { + ensure!( + value.get("error").is_none(), + "native request failed: {value}" + ); + return Ok(value["result"].clone()); + } + ensure!( + value.get("id").is_none(), + "unexpected native request/response: {value}" + ); + } + bail!("owner exited before response {id}") +} + +async fn qualify(mode: &str, native: &Path, root: &Path) -> Result<()> { + if mode != "smoke" { + let mut child = child(&format!("child-{mode}"), native, root)?; + ensure!( + child.wait().await?.success(), + "failure scenario did not meet its assertions" + ); + return Ok(()); + } + + // Use the native exec-server, with the unchanged pinned executable for helpers. + let reservation = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let endpoint = format!("ws://{}", reservation.local_addr()?); + drop(reservation); + let server_endpoint = endpoint.clone(); + let paths = ExecServerRuntimePaths::new(PathBuf::from(native), None)?; + let executor = tokio::spawn(async move { + codex_exec_server::run_main( + &server_endpoint, + paths, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .await + }); + let executor = tokio_util::task::AbortOnDropHandle::new(executor); + + let mut owner = child("owner", native, root)?; + let mut input = owner.stdin.take().context("missing owner stdin")?; + let mut lines = BufReader::new(owner.stdout.take().context("missing owner stdout")?).lines(); + input.write_all(format!("{}\n", json!({"id":1,"method":"initialize","params":{"clientInfo":{"name":"parsar_raw_manager_probe","version":"1"},"capabilities":{"experimentalApi":true}}})).as_bytes()).await?; + let initialized = response(&mut lines, 1).await?; + ensure!(initialized.is_object(), "missing stock initialize response"); + input.write_all(b"{\"method\":\"initialized\"}\n").await?; + input.write_all(format!("{}\n", json!({"id":2,"method":"environment/add","params":{"environmentId":owner::ENVIRONMENT_ID,"execServerUrl":endpoint}})).as_bytes()).await?; + response(&mut lines, 2).await?; + loop { + if tokio::fs::try_exists(root.join("typed-files.json")).await? { + break; + } + ensure!( + owner.try_wait()?.is_none(), + "owner exited before typed Files completed" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let evidence: Value = + serde_json::from_slice(&tokio::fs::read(root.join("typed-files.json")).await?)?; + ensure!(evidence["shared_manager"] == true && evidence["typed_files"] == true); + ensure!(tokio::fs::read(root.join("remote-file.txt")).await? == owner::CONTENT); + input.write_all(format!("{}\n", json!({"id":3,"method":"environment/status","params":{"environmentId":owner::ENVIRONMENT_ID}})).as_bytes()).await?; + ensure!( + response(&mut lines, 3).await?["status"] == "ready", + "raw manager lost its environment" + ); + drop(input); + while lines.next_line().await?.is_some() {} + ensure!( + owner.wait().await?.success(), + "raw owner failed during EOF shutdown" + ); + drop(executor); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proof_root_rejects_component_traversal_and_symlink_escapes() -> Result<()> { + let caller_home = PathBuf::from(std::env::var_os("HOME").context("HOME is missing")?); + let task_root = + PathBuf::from(std::env::var_os("TMPDIR").context("task TMPDIR is missing")?); + let task_root = proof_root(&task_root, &caller_home)?; + let fixture = tempfile::Builder::new() + .prefix("raw-manager-path-test-") + .tempdir_in(task_root)?; + let home = fixture.path().join("caller"); + let valid = home.join(".parsar/proof"); + let outside = fixture.path().join("outside/.parsar"); + std::fs::create_dir_all(&valid)?; + std::fs::create_dir_all(&outside)?; + ensure!(proof_root(&valid, &home)? == valid.canonicalize()?); + ensure!( + proof_root(&outside, &home).is_err(), + "a .parsar component is insufficient" + ); + let traversal = home.join(".parsar/../../outside/.parsar"); + ensure!( + proof_root(&traversal, &home).is_err(), + "parent traversal escaped the state root" + ); + #[cfg(unix)] + { + let link = home.join(".parsar/link"); + std::os::unix::fs::symlink(&outside, &link)?; + ensure!( + proof_root(&link, &home).is_err(), + "symlink escaped the state root" + ); + } + Ok(()) + } +} diff --git a/services/agents-api/tests/native/raw_manager/source.json b/services/agents-api/tests/native/raw_manager/source.json new file mode 100644 index 000000000..ba22f5935 --- /dev/null +++ b/services/agents-api/tests/native/raw_manager/source.json @@ -0,0 +1,26 @@ +{ + "repository": "https://github.com/openai/codex", + "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", + "native_version": "0.153.4", + "rust_toolchain": "1.95.0", + "scope": "private no-model raw-runner manager qualification; no production selection", + "patch": { + "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", + "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" + }, + "cargo_lock": { + "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", + "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", + "workspace_packages": 149 + }, + "fixtures": [ + { + "source": "probe.rs", + "target": "codex-rs/app-server/examples/parsar_raw_manager_probe.rs" + }, + { + "source": "owner.rs", + "target": "codex-rs/app-server/examples/raw_manager/owner.rs" + } + ] +} diff --git a/services/agents-api/tests/native/relay_probe.rs b/services/agents-api/tests/native/relay_probe.rs new file mode 100644 index 000000000..2823f270b --- /dev/null +++ b/services/agents-api/tests/native/relay_probe.rs @@ -0,0 +1,294 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use codex_exec_server::{ + EnvironmentConnectionState, EnvironmentManager, EnvironmentObservedStatus, ExecOutputStream, + ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, ReadFileOptions, + WriteFileOptions, +}; +use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::{ + FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, + FileSystemSpecialPath, NetworkSandboxPolicy, +}; +use codex_utils_path_uri::PathUri; +use tokio::time::timeout; + +const TIMEOUT: Duration = Duration::from_secs(40); + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() -> Result<()> { + timeout(Duration::from_secs(100), run()).await??; + Ok(()) +} + +async fn run() -> Result<()> { + let root = PathBuf::from(std::env::var("PARSAR_NATIVE_ENV_PROOF")?); + let phase = std::env::args().nth(1).context("phase required")?; + let manager = EnvironmentManager::from_env( + None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .await?; + ensure!( + manager.try_local_environment().is_none(), + "local fallback configured" + ); + let environment = manager + .default_environment() + .context("remote environment")?; + ensure!(environment.is_remote(), "expected remote backend"); + timeout(TIMEOUT, environment.wait_until_ready()).await??; + let cwd = PathUri::from_host_native_path(root.join("workspace"))?; + let file = PathUri::from_host_native_path(root.join("workspace/large.bin"))?; + let marker_path = root.join("workspace/start-marker.txt"); + let marker = PathUri::from_host_native_path(&marker_path)?; + let contents: Vec = (0..128 * 1024).map(|index| (index % 251) as u8).collect(); + let fs = environment.get_filesystem(); + if phase == "helpers" { + let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + )]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions( + &read_only, + NetworkSandboxPolicy::Restricted, + ), + cwd.clone(), + ); + ensure!( + fs.read_file(&file, ReadFileOptions::default(), Some(&sandbox)) + .await? + == contents, + "sandboxed native file read mismatch" + ); + ensure!( + fs.write_file( + &file, + b"must-not-write".to_vec(), + WriteFileOptions::default(), + Some(&sandbox) + ) + .await + .is_err(), + "read-only native helper permitted a write" + ); + let mut command = params("native-helpers", &cwd, vec![ + "/bin/sh".into(), "-c".into(), + "printf '%s' \"$0\"; if printf forbidden > sandbox-denied; then exit 42; fi; exit 7".into(), + ]); + command.arg0 = Some("launcher-argv0".into()); + command.sandbox = Some(sandbox); + let process = environment.get_exec_backend().start(command).await?; + let (stdout, _, exit) = read_closed(process.process.as_ref()).await?; + ensure!( + stdout == b"launcher-argv0" && exit == Some(7), + "sandboxed argv0 helper mismatch" + ); + ensure!( + fs.read_file(&file, ReadFileOptions::default(), None) + .await? + == contents, + "denied write changed file" + ); + std::fs::write( + root.join("helpers.json"), + r#"{"native_fs_helper":true,"native_argv0_helper":true,"read_only_enforced":true}"#, + )?; + return Ok(()); + } + if phase == "fresh" { + ensure!( + fs.read_file(&file, ReadFileOptions::default(), None) + .await? + == contents, + "retained file mismatch" + ); + let start = fs + .read_file(&marker, ReadFileOptions::default(), None) + .await?; + ensure!( + String::from_utf8(start)?.lines().count() == 1, + "command repeated" + ); + std::fs::write( + root.join("fresh.json"), + r#"{"fresh_connection":true,"retained_file":true,"single_start":true}"#, + )?; + return Ok(()); + } + ensure!(phase == "first", "unknown phase"); + let exec = environment.get_exec_backend(); + let mut controlled_params = params("retained", &cwd, vec![ + "/bin/sh".into(), "-c".into(), + "printf '%s %s\\n' \"$1\" \"$$\" >> \"$2\"; printf 'READY\\n'; IFS= read -r first; printf 'FIRST:%s\\n' \"$first\"; IFS= read -r second; printf 'SECOND:%s\\n' \"$second\"; exit 9".into(), + "relay".into(), "single-native-start".into(), marker_path.to_str().context("marker path")?.into(), + ]); + controlled_params.pipe_stdin = true; + let controlled = exec.start(controlled_params).await?; + read_until(controlled.process.as_ref(), b"READY\n").await?; + let command = async { + let started = exec + .start(params( + "concurrent", + &cwd, + vec![ + "/bin/sh".into(), + "-c".into(), + "printf native-stdout; printf native-stderr >&2; exit 7".into(), + ], + )) + .await?; + let (stdout, stderr, exit) = read_closed(started.process.as_ref()).await?; + ensure!( + stdout == b"native-stdout" && stderr == b"native-stderr" && exit == Some(7), + "command output mismatch" + ); + Ok::<_, anyhow::Error>(()) + }; + let files = async { + fs.write_file(&file, contents.clone(), WriteFileOptions::default(), None) + .await?; + ensure!( + fs.read_file(&file, ReadFileOptions::default(), None) + .await? + == contents, + "large file mismatch" + ); + Ok::<_, anyhow::Error>(()) + }; + tokio::try_join!(command, files)?; + let sleeper = exec + .start(params( + "terminate", + &cwd, + vec!["/bin/sleep".into(), "60".into()], + )) + .await?; + sleeper.process.terminate().await?; + let (_, _, terminated_exit) = read_closed(sleeper.process.as_ref()).await?; + ensure!(terminated_exit.is_some(), "termination did not settle"); + environment.refresh_connection().await?; + controlled.process.write(b"before-loss\n".to_vec()).await?; + read_until(controlled.process.as_ref(), b"FIRST:before-loss\n").await?; + let mut states = environment + .subscribe_connection_state() + .context("state receiver")?; + ensure!( + *states.borrow_and_update() == EnvironmentConnectionState::Connected, + "not connected before injection" + ); + std::fs::write(root.join("ready-to-disconnect"), b"ready")?; + timeout(TIMEOUT, async { + let mut disconnected = false; + loop { + states.changed().await?; + match *states.borrow_and_update() { + EnvironmentConnectionState::Disconnected => disconnected = true, + EnvironmentConnectionState::Connected if disconnected => { + return Ok::<_, anyhow::Error>(()); + } + EnvironmentConnectionState::Connected => {} + } + } + }) + .await??; + ensure!( + environment.status().await == EnvironmentObservedStatus::Ready, + "recovered status is not ready" + ); + controlled.process.write(b"after-loss\n".to_vec()).await?; + let (stdout, stderr, exit) = read_closed(controlled.process.as_ref()).await?; + ensure!( + stdout == b"READY\nFIRST:before-loss\nSECOND:after-loss\n" + && stderr.is_empty() + && exit == Some(9), + "retained process outcome mismatch" + ); + let start = String::from_utf8( + fs.read_file(&marker, ReadFileOptions::default(), None) + .await?, + )?; + ensure!( + start.lines().count() == 1 && start.starts_with("single-native-start "), + "command start repeated" + ); + std::fs::write( + root.join("first.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "native_remote":true,"large_file_bytes":contents.len(),"concurrent_command":true, + "stdout_stderr":true,"command_exit":7,"termination_exit":terminated_exit, + "same_key_refresh":true,"disconnected_connected":true,"same_process_recovered":true, + "controlled_exit":9,"single_command_start":true,"model_calls":0 + }))?, + )?; + Ok(()) +} + +fn params(id: &str, cwd: &PathUri, argv: Vec) -> ExecParams { + ExecParams { + process_id: ProcessId::from(id), + argv, + cwd: cwd.clone(), + shell_snapshot: None, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + } +} + +async fn read_until(process: &dyn ExecProcess, needle: &[u8]) -> Result<()> { + timeout(TIMEOUT, async { + let mut after = None; + let mut output = Vec::new(); + loop { + let result = process.read(after, None, Some(1000)).await?; + ensure!(result.failure.is_none(), "process failed"); + for chunk in result.chunks { + output.extend(chunk.chunk.0); + } + if output.windows(needle.len()).any(|part| part == needle) { + return Ok(()); + } + ensure!(!result.closed, "process closed before checkpoint"); + after = result.next_seq.checked_sub(1); + } + }) + .await? +} + +async fn read_closed(process: &dyn ExecProcess) -> Result<(Vec, Vec, Option)> { + timeout(TIMEOUT, async { + let mut after = None; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + let result = process.read(after, None, Some(1000)).await?; + ensure!(result.failure.is_none(), "process failed"); + for chunk in result.chunks { + match chunk.stream { + ExecOutputStream::Stdout => stdout.extend(chunk.chunk.0), + ExecOutputStream::Stderr => stderr.extend(chunk.chunk.0), + ExecOutputStream::Pty => anyhow::bail!("unexpected PTY"), + } + } + if result.closed { + return Ok((stdout, stderr, result.exit_code)); + } + after = result.next_seq.checked_sub(1); + } + }) + .await? +} diff --git a/services/agents-api/tests/native/retirement/README.md b/services/agents-api/tests/native/retirement/README.md new file mode 100644 index 000000000..5badd0281 --- /dev/null +++ b/services/agents-api/tests/native/retirement/README.md @@ -0,0 +1,168 @@ +# Native executor retirement qualification + +This opt-in Linux regression distinguishes connection/task shutdown from retirement +of already admitted filesystem work. It is a negative qualification of the pinned +Codex executor, not a production fix or public Files acceptance. + +## Reproduce + +Use the [raw Files prerequisites](../raw_files/README.md) and the same exact native +source/toolchain. From the Parsar worktree: + +```sh +export RETIREMENT_ROOT="$HOME/.parsar/retirement-qualification" +python3 services/agents-api/tests/native/raw_manager/prepare.py \ + --manifest services/agents-api/tests/native/retirement/source.json \ + --source "$NATIVE_SOURCE" --output "$RETIREMENT_ROOT/source" +mkdir -p "$RETIREMENT_ROOT/state" +export TMPDIR="$RETIREMENT_ROOT/state" +export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" +export CARGO_TARGET_DIR="$HOME/.parsar/cache/retirement-target" +export RUSTUP_TOOLCHAIN=1.95 +export CARGO_PROFILE_DEV_DEBUG=0 +cd "$RETIREMENT_ROOT/source/codex-rs" +cargo test --locked -p codex-exec-server --lib retirement_qualification -- --nocapture +cargo clippy --locked -p codex-exec-server --tests -- -D warnings +rustfmt --check --edition 2024 exec-server/src/retirement_gate.rs \ + exec-server/src/server/retirement_qualification.rs \ + exec-server/src/server/placement_qualification.rs +``` + +The test uses the existing processor's duplex JSON-RPC helper and typed native +`fs/writeFile` parameters with `followSymlinks: false`. A separately hashed, +`cfg(test)`-only gate pauses one existing blocking worker after it opens and +validates a regular file, immediately before the original truncation/write. It +changes scheduling, not the mutation algorithm. The gate matches one exact path, +releases on test unwind and has a 15-second timeout. The owned shell heartbeat is +bounded even if an assertion fails. This test does not exercise sandboxed writes, +all filesystem operations or every platform. + +In both cases the test observes the original worker entering, closes the connection +and joins its handler, verifies the mutation has no response, then admits a new +native owner writing the same regular file. The successor's acknowledged bytes +must be present before releasing the original worker. The original bytes then +replace them. One case additionally awaits the actual `ConnectionProcessor` +shutdown and observes the owned command PID disappear before admitting the +successor. Command retirement and blocking filesystem work are separate facts. +No unknown mutation is replayed. + +Successful test completion means this precise missing barrier was reproduced. +It does **not** mean retirement passed. If upstream behavior changes to settle +the held worker, re-evaluate the assertions and guarantee rather than weakening +the test to keep the negative result. + +## Retirement matrix + +| Boundary | Evidence and limit | +|---|---| +| Connection handler return | Instrumented native regression: the admitted blocking write remains live, and the owned command continues after detach. | +| `ConnectionProcessor::shutdown` return | Instrumented native regression: the owned command exits, while the held write can still overwrite a successor. This is not whole OS-process or Tokio-runtime destruction. | +| Native remote runner | Source: `remote.rs` calls the processor shutdown after the remote transport ends. This test exercises that processor boundary directly, not an end-to-end remote runner shutdown. | +| Registry pair disconnect / harness credential withdrawal | Source: the registry closes authenticated physical peers; native remote reconnect retains its processor. Transport closure is not a filesystem settlement receipt. Credential withdrawal is not directly injected by this test. | +| Core execution lease loss | Source: database ownership controls service admission; it cannot retract work already dispatched to a native executor. No lease-loss injection is claimed. | +| Executor process / isolated placement destruction | Qualified separately by the candidate fixture below for its task-owned local Docker placement. Production admission still needs an authorized supervisor/storage boundary or native mutation-drain receipt. | + +The replacement owner in this regression is admitted directly to native processors; +it is deliberately not evidence that public Core admission allows this race. It +shows why that admission must not infer write retirement from these boundaries. +Retain unresolved ownership and block successor mutation when the actual retirement +barrier is unknown, including recovery after Core restart. Do not equate connection +observation generations with a filesystem fence or change the public protocol. + +## Separate real execution acceptance + +The manifest reuses the manager hook and raw Files fixtures without changing their +native pin or third-party dependencies. The extra gate and test module compile +only in the native library unit-test build. Build the ordinary raw Files example +and run its existing first/fresh and cancellation workflows with a real model API, +the existing registry, pinned launcher and executor image. Record artifact/source +hashes separately from the instrumented library test and retain failures. Those +workflows verify actual Files, native commands and preserved history; they do not +upgrade this mechanism result into production retirement acceptance. + +Run repository `make check` independently. No API or database query is changed. +Public Files, production owner integration, complete descendant retirement and +Claude's independent placement qualification remain separate tasks. + +## Whole-placement candidate qualification + +`placement.py` uses the same exact-pin test build and scheduling gate in a +credential-free, task-owned Linux Docker unit. Build the `codex-exec-server` +library test binary with `cargo test --locked -p codex-exec-server --lib --no-run`, +then pass that binary and the already qualified immutable executor image: + +```sh +python3 services/agents-api/tests/native/retirement/placement.py \ + --binary "$NATIVE_TEST_BINARY" --image "$EXECUTOR_IMAGE_ID" \ + --output "$HOME/.parsar/placement-retirement/attempt-1" +``` + +The host must be the Docker host, expose readable cgroup v2 membership/events, +and use the existing Debian executor image with `setsid`. The test process is +the placement init; the native processor dispatches a held file write and a +command that starts a detached-session descendant. Before stopping, the runner +checks native readiness, actual cgroup members and independent process-session +identity. A still-live placement fails the retirement observation and cannot +start the successor. Docker stop is followed by cgroup and process-identity +observations before the fresh native write; a successful stop request alone is +insufficient. Failed assertions retain evidence and reclaim only exact labeled +test instances. Workspace files survive container removal. + +This qualifies only the tested local filesystem and placement. It does not +implement Core authority, durable unknown-owner reconciliation, remote supervisor +receipts, Claude containment or public Files. Whole-placement retirement does not +undo completed effects; the held old mutation remains unknown and is never +replayed. No model credentials enter the instrumented unit. Run the ordinary +real-model Files/cancellation/history fixture separately, and report its outcome +independently. Production runtime/pins and the earlier negative tests are unchanged. + +## Local Runtime operator consumer + +Build the existing daemon and pass `--controller /absolute/path/to/parsar-daemon` +to `placement.py` to exercise the actual local retirement command. The same held +native write and detached descendant are stopped through that consumer. Separate +CLI processes race on the binding and recover the identical receipt after removal; +the fixture independently checks old processes, retained successor bytes and an +untouched neighboring container. Scoped enrollment includes a generated Environment +UUID; wrong, missing and explicitly empty scopes must fail without stopping the +placement. This is operator-confirmed association, not Core resource validation. +Earlier native negative tests remain unchanged. + +Operators explicitly create an owned container with +`--label parsar.runtime.placement=`, then run: + +```sh +parsar-daemon placement enroll --container "$FULL_CONTAINER_ID" \ + --owner "$PLACEMENT_OWNER" --workspace "$ABSOLUTE_HOST_WORKSPACE" \ + --environment "$ENVIRONMENT_ID" +parsar-daemon placement retire --container "$FULL_CONTAINER_ID" \ + --environment "$ENVIRONMENT_ID" +``` + +This initial profile requires local Linux/cgroup v2 and the fixed socket +`unix:///var/run/docker.sock`; ambient Docker context/host variables do not select +the target. Use a non-root container user, private PID/IPC/cgroup namespaces, +`--network none --cap-drop ALL --security-opt no-new-privileges --restart no`, +no devices, additional capabilities, shared volumes or privileged settings. +Exactly one writable bind retains workspace/history on ext-family, XFS, Btrfs or +tmpfs storage without nested mounts; additional binds may only be read-only regular +files. Every source must be on a whole-filesystem host mount with exactly one mount +for that device in the controller namespace. Host bind aliases, Btrfs subvolume +roots, repeated-device or stacked mounts and missing mount evidence are rejected; +this first profile does not resolve arbitrary backing-path aliases. Controller state and the canonical Docker socket must not be exposed by any mount, +including ancestor directories and filesystem roots. Use canonical absolute +workspace paths and a trusted operator account with Docker access. State ancestors +must be owned by that user or root and not writable by group/others; the placement +state directory and files require modes 0700/0600. Host administrators remain trusted. + +The command persists intent before stop and verifies stopped state, cgroup emptiness +and old process identities before non-forced container removal. It never removes +workspace files or Docker volumes, releases an ordinary Turn, or replays unknown +writes. A saved completed receipt survives controller restart; recovery completes the +directory-sync barrier before returning success. Unavailable evidence, +changed incarnations and removal without a durable receipt remain unknown. An +interrupted stop can reconcile the same still-existing stopped unit. There is no +clear-unknown shortcut. This consumer does not gate Core dispatch or grant public +feature admission; remote authority, Claude placement and broader storage remain +separate work. Run full `make check` and uninstrumented real-provider acceptance +separately from the credential-free mechanism fixture. diff --git a/services/agents-api/tests/native/retirement/gate.rs b/services/agents-api/tests/native/retirement/gate.rs new file mode 100644 index 000000000..1e5772727 --- /dev/null +++ b/services/agents-api/tests/native/retirement/gate.rs @@ -0,0 +1,88 @@ +//! Qualification-only scheduling gate inside an existing native blocking write. +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +static ARMED: Mutex>> = Mutex::new(None); + +pub(crate) struct Gate { + path: PathBuf, + pub(crate) entered: AtomicBool, + pub(crate) finished: AtomicBool, + released: Mutex, + changed: Condvar, +} + +pub(crate) struct ReleaseOnDrop(pub(crate) Arc); + +impl Gate { + pub(crate) fn arm(path: PathBuf) -> ReleaseOnDrop { + let gate = Arc::new(Self { + path, + entered: AtomicBool::new(false), + finished: AtomicBool::new(false), + released: Mutex::new(false), + changed: Condvar::new(), + }); + let mut armed = ARMED.lock().expect("gate registry"); + assert!(armed.is_none(), "only one qualification write at a time"); + *armed = Some(Arc::clone(&gate)); + ReleaseOnDrop(gate) + } + + pub(crate) fn release(&self) { + *self.released.lock().expect("gate release") = true; + self.changed.notify_all(); + } +} + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + self.0.release(); + let mut armed = ARMED.lock().expect("gate registry"); + if armed + .as_ref() + .is_some_and(|gate| Arc::ptr_eq(gate, &self.0)) + { + *armed = None; + } + } +} + +pub(crate) struct Completion(Arc); + +impl Drop for Completion { + fn drop(&mut self) { + self.0.finished.store(true, Ordering::SeqCst); + } +} + +pub(crate) fn before_write(path: &Path) -> std::io::Result> { + let gate = { + let mut armed = ARMED.lock().expect("gate registry"); + if armed.as_ref().is_none_or(|gate| gate.path != path) { + return Ok(None); + } + armed.take().expect("matched gate") + }; + gate.entered.store(true, Ordering::SeqCst); + let (released, timeout) = gate + .changed + .wait_timeout_while( + gate.released.lock().expect("gate release"), + Duration::from_secs(15), + |released| !*released, + ) + .expect("gate wait"); + if timeout.timed_out() && !*released { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "qualification gate was not released", + )); + } + drop(released); + Ok(Some(Completion(gate))) +} diff --git a/services/agents-api/tests/native/retirement/operator_retirement.py b/services/agents-api/tests/native/retirement/operator_retirement.py new file mode 100644 index 000000000..5ecd084c3 --- /dev/null +++ b/services/agents-api/tests/native/retirement/operator_retirement.py @@ -0,0 +1,78 @@ +"""Explicit local controller acceptance used by the native placement fixture.""" + +import hashlib +import json +from pathlib import Path +import subprocess +import uuid + + +class OperatorRetirement: + def __init__(self, binary, token, workspace): + self.binary = str(binary.resolve(strict=True)) + self.token = token + self.workspace = workspace + self.receipt = None + self.environment = str(uuid.uuid4()) + + def command(self, action, instance, *extra): + return [self.binary, "placement", action, "--container", instance, + "--environment", self.environment, *extra] + + def enroll(self, instance): + rejected = subprocess.run(self.command("enroll", instance, "--owner", "wrong-owner", + "--workspace", str(self.workspace)), capture_output=True, timeout=10) + assert rejected.returncode != 0, "unbound owner was accepted" + out = subprocess.check_output(self.command("enroll", instance, "--owner", self.token, + "--workspace", str(self.workspace)), text=True, timeout=10) + enrolled = json.loads(out) + assert enrolled["state"] == "enrolled" + assert enrolled["version"] == 2 and enrolled["environment_id"] == self.environment + for scope in ([], ["--environment", str(uuid.uuid4())], ["--environment", ""]): + rejected = subprocess.run([self.binary, "placement", "retire", "--container", instance, *scope], + capture_output=True, timeout=10) + assert rejected.returncode != 0, "mismatched or omitted Environment was accepted" + state = subprocess.check_output(["docker", "--host", "unix:///var/run/docker.sock", + "inspect", "--format", "{{.State.Running}}", instance], text=True) + assert state.strip() == "true", "scope rejection stopped the placement" + + def retire(self, instance): + # Separate controller processes race on the same exact durable binding. + children = [subprocess.Popen(self.command("retire", instance), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) for _ in range(2)] + results = [] + try: + for child in children: + out, error = child.communicate(timeout=10) + assert child.returncode == 0, error + results.append(json.loads(out)) + finally: + for child in children: + if child.poll() is None: + child.kill() + child.wait() + assert results[0] == results[1] + self.receipt = results[0] + assert self.receipt["state"] == "retired" + assert self.receipt["environment_id"] == self.environment + assert self.receipt["target"]["container"] == instance + assert self.receipt["owner"] == self.token + again = subprocess.check_output(self.command("retire", instance), text=True, timeout=10) + assert json.loads(again) == self.receipt + return {"controller_sha256": hashlib.sha256(Path(self.binary).read_bytes()).hexdigest(), + "local_receipt": self.receipt, "concurrent_and_fresh_process_receipts_equal": True, + "wrong_owner_rejected": True, "wrong_or_missing_environment_rejected": True} + + def observe(self, instance, cgroup, members, process_identity): + assert self.receipt and self.receipt["target"]["container"] == instance + absent = subprocess.run(["docker", "--host", "unix:///var/run/docker.sock", "inspect", instance], + capture_output=True, timeout=10) + assert absent.returncode != 0, "retired exact container must be removed" + events = cgroup / "cgroup.events" + if events.exists() and "populated 0" not in events.read_text().splitlines(): + return False + for pid, original in members.items(): + current = process_identity(pid) + if current and current["start"] == original["start"] and current["state"] not in {"Z", "X"}: + return False + return True diff --git a/services/agents-api/tests/native/retirement/placement.py b/services/agents-api/tests/native/retirement/placement.py new file mode 100644 index 000000000..9459fe18a --- /dev/null +++ b/services/agents-api/tests/native/retirement/placement.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Qualify exact-placement retirement; never a production ownership receipt.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time +import uuid + +from operator_retirement import OperatorRetirement + +TEST = "server::processor::tests::retirement_qualification::native_placement_worker" +LABEL = "parsar.retirement-qualification" + + +def docker(*args, timeout=15): + return subprocess.check_output(["docker", "--host", "unix:///var/run/docker.sock", *args], text=True, timeout=timeout).strip() + + +def inspect(instance): + return json.loads(docker("inspect", instance))[0] + + +def process_identity(pid): + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + return {"start": fields[19], "state": fields[0], "group": fields[2], "session": fields[3]} + except FileNotFoundError: + return None + + +def observe_retired(instance, token, cgroup, members): + state = inspect(instance) + assert state["Config"]["Labels"][LABEL] == token + if state["State"]["Running"] or state["State"]["Pid"] != 0: + return False + events = cgroup / "cgroup.events" + if events.exists() and "populated 0" not in events.read_text().splitlines(): + return False + for pid, original in members.items(): + current = process_identity(pid) + if current and current["start"] == original["start"] and current["state"] != "Z": + return False + return True + + +def await_condition(condition, seconds, label): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if condition(): + return + time.sleep(0.025) + raise RuntimeError("timed out: " + label) + + +def qualify(args): + runtime = (Path.home() / ".parsar").resolve() + root = args.output.resolve() + if not root.is_relative_to(runtime) or root == runtime: + raise ValueError("output must be a new directory below ~/.parsar") + binary = args.binary.resolve(strict=True) + if os.getuid() == 0: + raise ValueError("run qualification as the ordinary executor user") + if not args.image.startswith("sha256:") or len(args.image) != 71: + raise ValueError("use the qualified immutable image ID") + root.mkdir(parents=True, mode=0o700, exist_ok=False) + workspace = root / "workspace" + workspace.mkdir(mode=0o700) + (workspace / "write.bin").write_bytes(b"initial") + filesystem = subprocess.check_output(["stat", "-f", "-c", "%T", str(workspace)], text=True).strip() + if filesystem not in {"ext2/ext3", "xfs", "btrfs", "tmpfs"}: + raise ValueError("this qualification requires a supported local filesystem") + token = uuid.uuid4().hex + instances = [] + removed = set() + controller = OperatorRetirement(args.controller, token, workspace) if args.controller else None + evidence = {"qualified": False, "instrumented": True, "model_calls": 0, + "image": args.image, "filesystem": filesystem, "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "scope": "task-owned local-filesystem Docker placement; no Core/remote authority claim"} + + def start(mode): + name = "parsar-retirement-" + token[:12] + "-" + mode + container_path = "/qualification/.parsar/task" + instance = docker("create", "--name", name, "--label", LABEL + "=" + token, + "--label", "parsar.runtime.placement=" + token, + "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--restart", "no", "--user", f"{os.getuid()}:{os.getgid()}", + "--env", "HOME=/qualification", "--env", "PARSAR_RETIREMENT_MODE=" + mode, + "--env", "PARSAR_RETIREMENT_WORKSPACE=" + container_path, + "--mount", f"type=bind,src={binary},dst=/native-test,readonly", + "--mount", f"type=bind,src={workspace},dst={container_path}", + "--workdir", container_path, "--entrypoint", "/native-test", args.image, + "--exact", TEST, "--ignored", "--nocapture") + instances.append(instance) + docker("start", instance) + return instance + + try: + neighbor = None + if controller: + neighbor = docker("run", "-d", "--label", LABEL + "=" + token, + "--network", "none", "--entrypoint", "sleep", args.image, "60") + instances.append(neighbor) + neighbor_init = inspect(neighbor)["State"]["Pid"] + old = start("held") + await_condition(lambda: (workspace / "worker-entered").exists(), 7, "native worker gate") + state = inspect(old) + init = state["State"]["Pid"] + assert init > 0 and state["State"]["Running"] + host_config = state["HostConfig"] + assert not host_config["Privileged"] and host_config["PidMode"] != "host" + assert host_config["RestartPolicy"]["Name"] == "no" + assert "ALL" in host_config["CapDrop"] + assert "no-new-privileges" in host_config["SecurityOpt"] + groups = Path(f"/proc/{init}/cgroup").read_text().splitlines() + group = next(line[3:] for line in groups if line.startswith("0::")) + cgroup = Path("/sys/fs/cgroup") / group.lstrip("/") + assert cgroup != Path("/sys/fs/cgroup") and (cgroup / "cgroup.events").exists() + assert "populated 1" in (cgroup / "cgroup.events").read_text().splitlines() + members = {} + for procs in cgroup.rglob("cgroup.procs"): + for pid in procs.read_text().splitlines(): + identity = process_identity(pid) + if identity: + members[int(pid)] = identity + assert init in members and len(members) >= 3 + descendant = int((workspace / "descendant.pid").read_text()) + native_command = int((workspace / "command.pid").read_text()) + # The namespace PID has to be the leader of its own session, separately + # from the native command; this is not merely another process-group member. + stat = docker("exec", old, "cat", f"/proc/{descendant}/stat") + fields = stat.rsplit(")", 1)[1].split() + assert int(fields[2]) == descendant and int(fields[3]) == descendant + assert descendant != native_command + assert (workspace / "write.bin").read_bytes() == b"initial" + assert not observe_retired(old, token, cgroup, members) + try: + observe_retired("parsar-missing-" + token, token, cgroup, members) + except subprocess.CalledProcessError: + evidence["missing_retirement_observation_rejected"] = True + else: + raise AssertionError("missing supervisor evidence cannot authorize a successor") + assert len(instances) == (2 if controller else 1) and not (workspace / "successor-receipt").exists() + evidence.update(old_instance=old, cgroup=str(cgroup), observed_members=members, + detached_namespace_pid=descendant, live_owner_rejected=True) + began = time.monotonic() + if controller: + controller.enroll(old) + evidence.update(controller.retire(old)) + removed.add(old) + settled = lambda: controller.observe(old, cgroup, members, process_identity) + assert inspect(neighbor)["State"]["Pid"] == neighbor_init + evidence["neighbor_untouched"] = True + else: + docker("stop", "--timeout", "1", old) + settled = lambda: observe_retired(old, token, cgroup, members) + evidence["stopped_state"] = inspect(old)["State"] + await_condition(settled, 3, "placement retirement") + evidence["stop_seconds"] = time.monotonic() - began + counts = [(workspace / name).stat().st_size for name in ("heartbeat", "descendant-heartbeat")] + assert (workspace / "write.bin").read_bytes() == b"initial" + successor = start("successor") + exit_code = docker("wait", successor) + assert exit_code == "0", "successor native test failed" + assert (workspace / "successor-receipt").read_bytes() == b"acknowledged" + assert (workspace / "write.bin").read_bytes() == b"new-owner" + time.sleep(0.2) + assert [(workspace / name).stat().st_size for name in ("heartbeat", "descendant-heartbeat")] == counts + assert settled() + assert (workspace / "write.bin").read_bytes() == b"new-owner" + evidence.update(qualified=True, successor_instance=successor, old_write_receipt="unknown", + old_mutation_replayed=False, detached_effects_stopped=True, + successor_native_receipt=True, retained_bytes="new-owner") + finally: + cleanup, cleanup_errors = [], [] + for instance in reversed(instances): + if instance in removed: + cleanup.append(instance) + continue + try: + state = inspect(instance) + assert state["Config"]["Labels"][LABEL] == token + logs = subprocess.run(["docker", "logs", instance], text=True, capture_output=True, timeout=10) + (root / (instance + ".log")).write_text(logs.stdout + logs.stderr) + docker("rm", "--force", instance) + cleanup.append(instance) + except Exception as error: + cleanup_errors.append({"instance": instance, "error": str(error)}) + evidence["removed_task_instances"] = cleanup + evidence["cleanup_errors"] = cleanup_errors + (root / "result.json").write_text(json.dumps(evidence, indent=2) + "\n") + if cleanup_errors: + raise RuntimeError("task cleanup incomplete; inspect retained result.json") + print(json.dumps(evidence)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True, help="exact-manifest codex-exec-server test binary") + parser.add_argument("--image", required=True, help="existing qualified immutable executor image ID") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--controller", type=Path, help="candidate parsar-daemon for local Runtime retirement acceptance") + qualify(parser.parse_args()) diff --git a/services/agents-api/tests/native/retirement/placement_test.rs b/services/agents-api/tests/native/retirement/placement_test.rs new file mode 100644 index 000000000..05bdcf3c1 --- /dev/null +++ b/services/agents-api/tests/native/retirement/placement_test.rs @@ -0,0 +1,59 @@ +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires the task-owned Docker placement runner"] +async fn native_placement_worker() { + let root = std::path::PathBuf::from( + std::env::var_os("PARSAR_RETIREMENT_WORKSPACE").expect("qualification workspace"), + ); + let home = std::path::PathBuf::from(std::env::var_os("HOME").expect("private HOME")); + assert!(root.is_absolute() && root.starts_with(home.join(".parsar"))); + let mode = std::env::var("PARSAR_RETIREMENT_MODE").expect("qualification mode"); + assert!(mode == "held" || mode == "successor"); + let file = root.join("write.bin"); + let processor = super::super::ConnectionProcessor::new(test_runtime_paths()); + let (mut writer, mut lines, handler) = + spawn_test_connection(Arc::clone(&processor.session_registry), &mode); + initialize(&mut writer, &mut lines, None).await; + if mode == "successor" { + send_request( + &mut writer, + 2, + FS_WRITE_FILE_METHOD, + &write_params(&file, b"new-owner"), + ) + .await; + let _: FsWriteFileResponse = read_response(&mut lines, 2).await; + assert_eq!(std::fs::read(&file).expect("successor bytes"), b"new-owner"); + std::fs::write(root.join("successor-receipt"), b"acknowledged").expect("receipt marker"); + drop(writer); + handler.await.expect("successor handler"); + processor.shutdown().await; + return; + } + assert_eq!(std::fs::read(&file).expect("initial bytes"), b"initial"); + let mut command = exec_params(ProcessId::from("placement-command")); + command.cwd = PathUri::from_host_native_path(&root).expect("workspace URI"); + command.argv = vec!["/bin/sh".into(), "-c".into(), + "setsid /bin/sh -c 'echo $$ > descendant.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> descendant-heartbeat; sleep 0.02; i=$((i+1)); done' /dev/null 2>&1 & echo $$ > command.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> heartbeat; sleep 0.02; i=$((i+1)); done".into()]; + send_request(&mut writer, 2, EXEC_METHOD, &command).await; + let _: ExecResponse = read_response(&mut lines, 2).await; + wait_for(|| { + root.join("descendant.pid").exists() + && heartbeat(&root) > 0 + && std::fs::metadata(root.join("descendant-heartbeat")).is_ok_and(|m| m.len() > 0) + }) + .await; + let gate = Gate::arm(file.clone()); + send_request( + &mut writer, + 3, + FS_WRITE_FILE_METHOD, + &write_params(&file, b"old-owner"), + ) + .await; + wait_for(|| gate.0.entered.load(Ordering::SeqCst)).await; + std::fs::write(root.join("worker-entered"), b"held-before-truncate").expect("ready marker"); + // The external supervisor must retire this placement while the original + // blocking worker still owns its descriptor; returning would release it. + tokio::time::sleep(Duration::from_secs(12)).await; + panic!("placement was not retired before the qualification deadline"); +} diff --git a/services/agents-api/tests/native/retirement/processor_test.rs b/services/agents-api/tests/native/retirement/processor_test.rs new file mode 100644 index 000000000..06b1c601d --- /dev/null +++ b/services/agents-api/tests/native/retirement/processor_test.rs @@ -0,0 +1,208 @@ +mod retirement_qualification { + use super::*; + use crate::protocol::{FS_WRITE_FILE_METHOD, FsWriteFileParams, FsWriteFileResponse}; + use crate::retirement_gate::Gate; + use base64::Engine; + use base64::prelude::BASE64_STANDARD; + use pretty_assertions::assert_eq; + use std::path::Path; + use std::sync::atomic::Ordering; + + // This is a negative qualification: native detach/shutdown is not a Files + // retirement receipt. The original blocking write is never retried. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn native_retirement_does_not_settle_admitted_write() { + for shutdown in [false, true] { + qualify(shutdown).await; + } + } + + async fn qualify(shutdown: bool) { + let private = std::path::PathBuf::from(std::env::var_os("HOME").expect("caller HOME")) + .join(".parsar") + .canonicalize() + .expect("private state root"); + let temporary = + std::path::PathBuf::from(std::env::var_os("TMPDIR").expect("private TMPDIR")) + .canonicalize() + .expect("temporary state root"); + assert!( + temporary.starts_with(private), + "TMPDIR must be below ~/.parsar" + ); + let root = tempfile::Builder::new() + .prefix("retirement-") + .tempdir_in(temporary) + .expect("private workspace"); + let started = std::time::Instant::now(); + let mut timeline = Vec::new(); + let mut observed = |event: &str| { + timeline.push(serde_json::json!({ + "event": event, "elapsed_us": started.elapsed().as_micros(), + })) + }; + let file = root.path().join("write.bin"); + std::fs::write(&file, b"initial").expect("initial file"); + let processor = super::super::ConnectionProcessor::new(test_runtime_paths()); + let registry = Arc::clone(&processor.session_registry); + let (mut writer, mut lines, first) = spawn_test_connection(Arc::clone(®istry), "old"); + initialize(&mut writer, &mut lines, None).await; + + let mut command = exec_params(ProcessId::from("retirement-command")); + command.cwd = PathUri::from_host_native_path(root.path()).expect("workspace URI"); + command.argv = vec![ + "/bin/sh".into(), "-c".into(), + "echo $$ > command.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> heartbeat; sleep 0.02; i=$((i+1)); done".into(), + ]; + send_request(&mut writer, 2, EXEC_METHOD, &command).await; + let _: ExecResponse = read_response(&mut lines, 2).await; + wait_for(|| root.path().join("command.pid").exists() && heartbeat(root.path()) > 0).await; + let pid: u32 = std::fs::read_to_string(root.path().join("command.pid")) + .expect("command pid") + .trim() + .parse() + .expect("numeric pid"); + let process = format!("/proc/{pid}"); + assert!(Path::new(&process).exists(), "owned command must exist"); + + let gate = Gate::arm(file.clone()); + send_request( + &mut writer, + 3, + FS_WRITE_FILE_METHOD, + &write_params(&file, b"old-owner"), + ) + .await; + wait_for(|| gate.0.entered.load(Ordering::SeqCst)).await; + observed("original_blocking_worker_entered"); + assert_eq!(std::fs::read(&file).expect("held bytes"), b"initial"); + drop(writer); + timeout(Duration::from_secs(2), first) + .await + .expect("disconnected handler should return") + .expect("handler join"); + observed("connection_handler_returned"); + assert!( + lines + .next_line() + .await + .expect("old response stream") + .is_none(), + "held mutation must have no response" + ); + assert!(!gate.0.finished.load(Ordering::SeqCst)); + let before = heartbeat(root.path()); + wait_for(|| heartbeat(root.path()) > before).await; + + if shutdown { + timeout(Duration::from_secs(2), processor.shutdown()) + .await + .expect("native processor shutdown should return"); + observed("native_processor_shutdown_returned"); + wait_for(|| !Path::new(&process).exists()).await; + observed("owned_command_exit_observed"); + assert!( + !gate.0.finished.load(Ordering::SeqCst), + "local command retirement is distinct from the admitted file worker" + ); + } + + // A new native owner for the same path can be admitted while the old + // blocking worker still holds its descriptor. No Core policy is implied. + let successor = super::super::ConnectionProcessor::new(test_runtime_paths()); + let (mut next_writer, mut next_lines, next) = + spawn_test_connection(Arc::clone(&successor.session_registry), "successor"); + initialize(&mut next_writer, &mut next_lines, None).await; + send_request( + &mut next_writer, + 2, + FS_WRITE_FILE_METHOD, + &write_params(&file, b"new-owner"), + ) + .await; + let _: FsWriteFileResponse = + timeout(Duration::from_secs(2), read_response(&mut next_lines, 2)) + .await + .expect("successor write receipt"); + assert_eq!(std::fs::read(&file).expect("successor bytes"), b"new-owner"); + assert!(!gate.0.finished.load(Ordering::SeqCst)); + observed("successor_write_acknowledged_and_bytes_verified"); + gate.0.release(); + wait_for(|| gate.0.finished.load(Ordering::SeqCst)).await; + assert_eq!(std::fs::read(&file).expect("late bytes"), b"old-owner"); + observed("original_worker_finished_and_overwrote_successor"); + + drop(next_writer); + drop(next_lines); + timeout(Duration::from_secs(2), next) + .await + .expect("successor shutdown") + .expect("successor join"); + successor.shutdown().await; + processor.shutdown().await; + wait_for(|| !Path::new(&process).exists()).await; + let stopped = heartbeat(root.path()); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(heartbeat(root.path()), stopped, "owned heartbeat stopped"); + println!( + "{}", + serde_json::json!({ + "qualification": "native-retirement", "instrumented": true, + "boundary": if shutdown { "processor-shutdown" } else { "connection-detach" }, + "write_dispatched_and_worker_entered": true, "old_receipt": "unknown", + "handler_returned_while_write_held": true, + "command_continued_after_detach": true, + "processor_shutdown_before_successor": shutdown, + "successor_receipt_before_old_completion": true, + "bytes": ["initial", "new-owner", "old-owner"], + "owned_command_exit_observed": true, "retirement_barrier": false, + "timeline": timeline, + }) + ); + } + + async fn initialize( + writer: &mut DuplexStream, + lines: &mut Lines>, + resume: Option, + ) { + send_request( + writer, + 1, + INITIALIZE_METHOD, + &InitializeParams { + client_name: "retirement-qualification".into(), + resume_session_id: resume, + }, + ) + .await; + let _: InitializeResponse = read_response(lines, 1).await; + send_notification(writer, INITIALIZED_METHOD, &()).await; + } + + fn write_params(path: &Path, data: &[u8]) -> FsWriteFileParams { + FsWriteFileParams { + path: PathUri::from_host_native_path(path).expect("file URI"), + data_base64: BASE64_STANDARD.encode(data), + follow_symlinks: Some(false), + sandbox: None, + } + } + + fn heartbeat(root: &Path) -> u64 { + std::fs::metadata(root.join("heartbeat")) + .map(|m| m.len()) + .unwrap_or(0) + } + + async fn wait_for(mut predicate: impl FnMut() -> bool) { + timeout(Duration::from_secs(2), async { + while !predicate() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("bounded observation"); + } + include!("placement_qualification.rs"); +} diff --git a/services/agents-api/tests/native/retirement/qualification.patch b/services/agents-api/tests/native/retirement/qualification.patch new file mode 100644 index 000000000..0676082ad --- /dev/null +++ b/services/agents-api/tests/native/retirement/qualification.patch @@ -0,0 +1,55 @@ +--- a/codex-rs/app-server/Cargo.toml ++++ b/codex-rs/app-server/Cargo.toml +@@ -118,6 +118,7 @@ + codex-windows-sandbox = { workspace = true } + + [dev-dependencies] ++codex-app-server-client = { workspace = true } + app_test_support = { workspace = true } + axum = { workspace = true, default-features = false, features = [ + "http1", +--- a/codex-rs/Cargo.lock ++++ b/codex-rs/Cargo.lock +@@ -2032,6 +2032,7 @@ + "clap", + "codex-agent-extension", + "codex-analytics", ++ "codex-app-server-client", + "codex-app-server-protocol", + "codex-app-server-transport", + "codex-arg0", +--- a/codex-rs/exec-server/src/lib.rs ++++ b/codex-rs/exec-server/src/lib.rs +@@ -208,6 +208,9 @@ + pub use server::DEFAULT_LISTEN_URL; + pub use server::ExecServerListenUrlParseError; + pub use server::RequestDispatchMode; ++#[cfg(all(test, target_os = "linux"))] ++mod retirement_gate; ++ + pub use server::run_main; + pub use server::run_main_with_telemetry; + pub use telemetry::ExecServerTelemetry; +--- a/codex-rs/exec-server/src/no_follow/unix.rs ++++ b/codex-rs/exec-server/src/no_follow/unix.rs +@@ -138,6 +138,8 @@ + "path is not a regular file", + )); + } ++ #[cfg(all(test, target_os = "linux"))] ++ let _retirement_write = crate::retirement_gate::before_write(&path)?; + file.set_len(0)?; + file.write_all(&contents) + }) +--- a/codex-rs/exec-server/src/server/processor.rs ++++ b/codex-rs/exec-server/src/server/processor.rs +@@ -481,6 +481,9 @@ + .expect("second processor should join"); + } + ++ #[cfg(target_os = "linux")] ++ include!("retirement_qualification.rs"); ++ + fn spawn_test_connection( + registry: Arc, + label: &str, diff --git a/services/agents-api/tests/native/retirement/source.json b/services/agents-api/tests/native/retirement/source.json new file mode 100644 index 000000000..c21f49617 --- /dev/null +++ b/services/agents-api/tests/native/retirement/source.json @@ -0,0 +1,78 @@ +{ + "repository": "https://github.com/openai/codex", + "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", + "native_version": "0.153.4", + "rust_toolchain": "1.95.0", + "scope": "private Linux executor-retirement qualification; test-only timing overlay, no production adoption", + "patch": { + "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", + "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" + }, + "cargo_lock": { + "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", + "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", + "workspace_packages": 149 + }, + "fixtures": [ + { + "source": "../raw_files/../shared_files_probe.rs", + "target": "codex-rs/app-server/examples/parsar_shared_files_probe.rs" + }, + { + "source": "../raw_files/../raw_files_probe.rs", + "target": "codex-rs/app-server/examples/parsar_raw_files_probe.rs" + }, + { + "source": "../raw_files/../shared_files/files.rs", + "target": "codex-rs/app-server/examples/shared_files/files.rs" + }, + { + "source": "../raw_files/../shared_files/runtime.rs", + "target": "codex-rs/app-server/examples/shared_files/runtime.rs" + }, + { + "source": "../raw_files/../shared_files/raw_runtime.rs", + "target": "codex-rs/app-server/examples/shared_files/raw_runtime.rs" + }, + { + "source": "../raw_files/../shared_files/probe.rs", + "target": "codex-rs/app-server/examples/shared_files/probe.rs" + }, + { + "source": "../raw_files/../shared_files/observations.rs", + "target": "codex-rs/app-server/examples/shared_files/observations.rs" + }, + { + "source": "../raw_files/../shared_files/configuration.rs", + "target": "codex-rs/app-server/examples/shared_files/configuration.rs" + }, + { + "source": "../raw_files/../shared_files/cancellation.rs", + "target": "codex-rs/app-server/examples/shared_files/cancellation.rs" + }, + { + "source": "gate.rs", + "target": "codex-rs/exec-server/src/retirement_gate.rs" + }, + { + "source": "processor_test.rs", + "target": "codex-rs/exec-server/src/server/retirement_qualification.rs" + }, + { + "source": "placement_test.rs", + "target": "codex-rs/exec-server/src/server/placement_qualification.rs" + } + ], + "fixture_patch": { + "file": "qualification.patch", + "sha256": "1d6d990cd026f9299ac4e4eef401280e1b5ef6a98806c90115e14f86af00d32b", + "scope": "existing raw-client test dependency plus cfg(test) Linux scheduling gate in an existing blocking write; unchanged production mutation implementation", + "prepared_files": { + "codex-rs/app-server/Cargo.toml": "c0d1cbed4ab6256ba28a3bbfaad7b8217ae928dce29b8299bd725027c63e350e", + "codex-rs/Cargo.lock": "25dbeba0fe924e6501168dd8b54670a6ec9d0639cd3ad0ccbb5828f1d4b60fa6", + "codex-rs/exec-server/src/lib.rs": "19af0c8d0da8273da1b9c82eeda9df6f77b61d65fd52a8c42ff764067cfe682b", + "codex-rs/exec-server/src/no_follow/unix.rs": "97bad67a8c821dff0b888678507a08e5faa133bd1b01bf9d9ae02142d9a889e6", + "codex-rs/exec-server/src/server/processor.rs": "eb0599df128dd0ccfe9ba6ada65e90b518a985b17ce518df542b11ead61f9f83" + } + } +} diff --git a/services/agents-api/tests/native/shared_files/cancellation.rs b/services/agents-api/tests/native/shared_files/cancellation.rs new file mode 100644 index 000000000..fc095d4da --- /dev/null +++ b/services/agents-api/tests/native/shared_files/cancellation.rs @@ -0,0 +1,394 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use tokio::time::{sleep, timeout}; +use uuid::Uuid; + +use crate::files::RemoteFiles; +use crate::observations::is_gate_command; +use crate::probe::{field, wait_checkpoint}; +use crate::runtime::Runtime; + +pub async fn exercise( + root: &Path, + workspace: &Path, + fs: &RemoteFiles, + client: &Runtime, +) -> Result { + let previous = read_proof(&root.join("first.json")).await?; + let thread = field(&previous, "native_thread_id")?; + let mut files = fs.idle("cancel", &previous).await?; + let resumed = client + .request( + 1, + "thread/resume", + json!({"threadId":thread,"cwd":workspace}), + ) + .await?; + ensure!( + resumed["thread"]["id"] == thread, + "cancel resume changed native thread" + ); + let prompt = "Execute exactly one command: `./shared-gate.sh cancel`. Pass that entire command with its required cancel argument to the native exec_command tool. It waits on a bounded fixture gate and will be interrupted externally. Keep waiting with native polling if needed. Never rerun it or execute another command, and do not open its gate or read/write files yourself."; + let response = client.request(2, "turn/start", json!({"threadId":thread,"input":[{"type":"text","text":prompt}],"environments":[{"environmentId":"remote","cwd":workspace}]})).await?; + let turn = field(&response["turn"], "id")?; + let heartbeat = timeout(Duration::from_secs(150), async { + loop { + client.observations.require_unfinished()?; + if let Some(heartbeat) = fs.optional_text("cancel.heartbeat").await? { + ensure!(!heartbeat.trim().is_empty(), "empty cancellation heartbeat"); + client.observations.wait_active(thread, turn).await?; + return Ok::<_, anyhow::Error>(heartbeat); + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .context("cancel command heartbeat timed out")??; + wait_checkpoint(&root.join("cancel-active-observed"), client).await?; + client.observations.require_active(thread, turn)?; + let started = command_started(&client.observations.events()?, thread, turn, workspace)?; + fs.verify_binary(&files).await?; + let active_directory = fs.names().await?; + require_closed_gate(fs).await?; + let marker = format!("shared-marker-{}", Uuid::now_v7()); + fs.write("cancel-marker.txt", format!("{marker}\n").into_bytes()) + .await?; + ensure!( + fs.text("cancel-marker.txt").await? == format!("{marker}\n"), + "active cancel marker differs" + ); + client.observations.require_active(thread, turn)?; + + // The native acknowledgement can also race natural completion. The separate + // terminal notification must identify this Turn with status interrupted. + let interrupt = client + .request( + 3, + "turn/interrupt", + json!({"threadId":thread,"turnId":turn}), + ) + .await?; + ensure!( + interrupt == json!({}), + "unexpected native interrupt response" + ); + client.observations.wait_completed().await?; + let completed_turn = interrupted_turn(&client.observations.events()?, thread, turn)?; + let before = client + .request( + 4, + "thread/backgroundTerminals/list", + json!({"threadId":thread,"limit":10}), + ) + .await?; + let termination = if let Some(target) = select_target(&before, &started)? { + let process = field(target, "processId")?; + let response = client + .request( + 5, + "thread/backgroundTerminals/terminate", + json!({"threadId":thread,"processId":process}), + ) + .await?; + ensure!( + response["terminated"] == true, + "native targeted termination was not confirmed" + ); + json!({"request_id":5,"process_id":process,"response":response}) + } else { + Value::Null + }; + let after = client + .request( + 6, + "thread/backgroundTerminals/list", + json!({"threadId":thread,"limit":10}), + ) + .await?; + ensure!( + select_target(&after, &started)?.is_none(), + "cancelled native target remains listed" + ); + // This is an observed native target state, not proof of OS quiescence. Go + // independently checks the fixture PID while this owner is still alive. + fs.verify_binary(&files).await?; + ensure!( + fs.text("cancel-marker.txt").await? == format!("{marker}\n"), + "post-cancel marker differs" + ); + require_closed_gate(fs).await?; + ensure!( + fs.text("shared-gate-count") + .await? + .lines() + .collect::>() + == ["first", "cancel"], + "cancel command was repeated" + ); + ensure!( + fs.text("cancel.cwd").await?.trim() == workspace.to_str().context("workspace encoding")?, + "cancel command cwd differs" + ); + files["active_heartbeat"] = json!(heartbeat); + files["active_binary_verified"] = json!(true); + files["active_directory_names"] = json!(active_directory); + files["directory_names"] = json!(fs.names().await?); + let mut proof = json!({ + "phase":"cancel", "native_thread_id":thread, "native_turn_id":turn, + "marker":marker,"history_value":field(&previous,"history_value")?,"files":files, + "shutdown_completed":false,"no_lagged_observed":true, + "cancellation":{ + "interrupt":{"request_id":3,"response":interrupt}, + "turn_completed":completed_turn,"command_started":started,"command_completed":null, + "background_before":before,"termination":termination,"background_after":after, + "files_after":{"binary_verified":true,"marker_verified":true}, + "observation_scope":"Typed native notification bodies observed before the snapshot; absent completion is not reconstructed." + } + }); + refresh(&mut proof, client)?; + crate::runtime::annotate(&mut proof); + Ok(proof) +} + +async fn require_closed_gate(fs: &RemoteFiles) -> Result<()> { + ensure!( + fs.optional_text("cancel.release").await?.is_none(), + "cancel gate was released" + ); + ensure!( + fs.optional_text("cancel-artifact.txt").await?.is_none(), + "cancel command reached placement" + ); + Ok(()) +} + +pub fn refresh(proof: &mut Value, client: &Runtime) -> Result<()> { + let events = client.observations.events()?; + let thread = field(proof, "native_thread_id")?; + let turn = field(proof, "native_turn_id")?; + let terminal = interrupted_turn(&events, thread, turn)?; + let completed = notification(&events, "item/completed", true, false)?; + if !completed.is_null() { + ensure!( + completed["threadId"] == thread + && completed["turnId"] == turn + && completed["item"]["id"] + == proof["cancellation"]["command_started"]["item"]["id"], + "cancel completion belongs to another native command" + ); + let process = &completed["item"]["processId"]; + ensure!( + process.is_null() + || process == &proof["cancellation"]["command_started"]["item"]["processId"], + "cancel completion process changed" + ); + } + proof["command_completed_count"] = json!(usize::from(!completed.is_null())); + proof["cancellation"]["command_completed"] = completed; + proof["cancellation"]["turn_completed"] = terminal; + proof["turn_started_count"] = json!(1); + proof["turn_completed_count"] = json!(1); + proof["command_started_count"] = json!(1); + proof["events"] = json!(events); + Ok(()) +} + +pub async fn recover( + root: &Path, + thread: &str, + fs: &RemoteFiles, + client: &Runtime, +) -> Result> { + let path = root.join("cancel.json"); + if !tokio::fs::try_exists(&path) + .await + .context("inspect cancellation proof")? + { + return Ok(None); + } + let cancelled = read_proof(&path).await?; + ensure!( + cancelled["native_thread_id"] == thread && cancelled["shutdown_completed"] == true, + "cancel owner did not finish on the resumed thread" + ); + let turns = client + .request( + 3, + "thread/turns/list", + json!({"threadId":thread,"limit":10,"sortDirection":"desc"}), + ) + .await?; + validate_recovery(&turns, field(&cancelled, "native_turn_id")?)?; + fs.verify_binary(&cancelled["files"]).await?; + ensure!( + fs.text("cancel-marker.txt").await? == format!("{}\n", field(&cancelled, "marker")?), + "fresh cancel marker differs" + ); + Ok(Some(json!({"native_turns":turns,"files_verified":true}))) +} + +async fn read_proof(path: &Path) -> Result { + serde_json::from_slice( + &tokio::fs::read(path) + .await + .context("read prior native proof")?, + ) + .context("decode prior native proof") +} + +fn notification( + events: &[Value], + method: &str, + command_only: bool, + required: bool, +) -> Result { + let matching: Vec<_> = events + .iter() + .filter(|event| { + event["method"] == method + && (!command_only || event["params"]["item"]["type"] == "commandExecution") + }) + .collect(); + ensure!( + matching.len() <= 1 && (!required || matching.len() == 1), + "native cancellation lifecycle count differs" + ); + Ok(matching + .first() + .map(|event| event["params"].clone()) + .unwrap_or(Value::Null)) +} + +fn command_started(events: &[Value], thread: &str, turn: &str, workspace: &Path) -> Result { + let started = notification(events, "item/started", true, true)?; + ensure!( + started["threadId"] == thread && started["turnId"] == turn, + "cancel command belongs to another Turn" + ); + let item = &started["item"]; + field(item, "id")?; + field(item, "processId")?; + ensure!( + item["cwd"] == workspace.to_str().context("workspace encoding")? + && is_gate_command(field(item, "command")?, "cancel"), + "unexpected cancel command or cwd" + ); + Ok(started) +} + +fn interrupted_turn(events: &[Value], thread: &str, turn: &str) -> Result { + let started = notification(events, "turn/started", false, true)?; + ensure!( + started["threadId"] == thread && started["turn"]["id"] == turn, + "cancel Turn start identity differs" + ); + let terminal = notification(events, "turn/completed", false, true)?; + ensure!( + terminal["threadId"] == thread + && terminal["turn"]["id"] == turn + && terminal["turn"]["status"] == "interrupted", + "native cancel Turn did not end interrupted" + ); + Ok(terminal) +} + +fn select_target<'a>(listed: &'a Value, started: &Value) -> Result> { + ensure!( + listed["nextCursor"].is_null(), + "unexpected background-terminal pagination" + ); + let data = listed["data"] + .as_array() + .context("native background-terminal list missing")?; + ensure!(data.len() <= 1, "ambiguous native background terminals"); + let Some(target) = data.first() else { + return Ok(None); + }; + let item = &started["item"]; + ensure!( + target["itemId"] == item["id"] + && target["processId"] == item["processId"] + && target["cwd"] == item["cwd"] + && is_gate_command(field(target, "command")?, "cancel"), + "background terminal is not the observed current-Turn command" + ); + Ok(Some(target)) +} + +fn validate_recovery(turns: &Value, cancelled_turn: &str) -> Result<()> { + ensure!( + turns["nextCursor"].is_null(), + "unexpected native recovery pagination" + ); + let data = turns["data"] + .as_array() + .context("native recovery turns missing")?; + ensure!( + data.len() == 2, + "native recovery did not retain both prior Turns" + ); + let matching: Vec<_> = data + .iter() + .filter(|turn| turn["id"] == cancelled_turn) + .collect(); + ensure!( + matching.len() == 1 && matching[0]["status"] == "interrupted", + "native history did not retain the cancelled Turn" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{command_started, interrupted_turn, select_target, validate_recovery}; + use serde_json::json; + use std::path::Path; + + #[test] + fn cancellation_target_requires_current_turn_and_process() -> anyhow::Result<()> { + let mut event = json!({"method":"item/started","params":{"threadId":"thread","turnId":"cancel","item":{"type":"commandExecution","id":"item","processId":"42","cwd":"/remote","command":"/bin/sh -lc './shared-gate.sh cancel'"}}}); + let started = command_started(&[event.clone()], "thread", "cancel", Path::new("/remote"))?; + let mut list = json!({"data":[{"itemId":"item","processId":"42","cwd":"/remote","command":"./shared-gate.sh cancel"}],"nextCursor":null}); + assert!(select_target(&list, &started)?.is_some()); + list["data"][0]["processId"] = json!("43"); + assert!(select_target(&list, &started).is_err()); + list["data"][0]["processId"] = json!("42"); + list["data"][0]["itemId"] = json!("other"); + assert!(select_target(&list, &started).is_err()); + event["params"]["turnId"] = json!("previous"); + assert!( + command_started(&[event.clone()], "thread", "cancel", Path::new("/remote")).is_err() + ); + event["params"]["turnId"] = json!("cancel"); + event["params"]["item"]["processId"] = json!(null); + assert!(command_started(&[event], "thread", "cancel", Path::new("/remote")).is_err()); + Ok(()) + } + + #[test] + fn acknowledgement_does_not_substitute_for_interrupted_turn() -> anyhow::Result<()> { + let start = + json!({"method":"turn/started","params":{"threadId":"thread","turn":{"id":"cancel"}}}); + let mut terminal = json!({"method":"turn/completed","params":{"threadId":"thread","turn":{"id":"cancel","status":"interrupted"}}}); + interrupted_turn(&[start.clone(), terminal.clone()], "thread", "cancel")?; + terminal["params"]["turn"]["status"] = json!("completed"); + assert!(interrupted_turn(&[start, terminal], "thread", "cancel").is_err()); + Ok(()) + } + + #[test] + fn recovery_requires_exact_cancelled_turn_once() -> anyhow::Result<()> { + let mut page = json!({"data":[{"id":"first","status":"completed"},{"id":"cancel","status":"interrupted"}],"nextCursor":null}); + validate_recovery(&page, "cancel")?; + assert!(validate_recovery(&page, "other").is_err()); + page["data"][1]["status"] = json!("completed"); + assert!(validate_recovery(&page, "cancel").is_err()); + page["data"] = + json!([{"id":"cancel","status":"interrupted"},{"id":"cancel","status":"interrupted"}]); + assert!(validate_recovery(&page, "cancel").is_err()); + Ok(()) + } +} diff --git a/services/agents-api/tests/native/shared_files/configuration.rs b/services/agents-api/tests/native/shared_files/configuration.rs new file mode 100644 index 000000000..7547e8356 --- /dev/null +++ b/services/agents-api/tests/native/shared_files/configuration.rs @@ -0,0 +1,100 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; + +pub fn overrides() -> Vec<(String, toml::Value)> { + let mut overrides: Vec<(String, toml::Value)> = vec![ + ("model", "MiniMax-M3"), + ("model_provider", "shared_files"), + ("approval_policy", "never"), + ("sandbox_mode", "danger-full-access"), + ("web_search", "disabled"), + ("shell_environment_policy.inherit", "core"), + ( + "model_providers.shared_files.name", + "Shared native files acceptance", + ), + ( + "model_providers.shared_files.base_url", + "https://api.minimax.cn/v1", + ), + ( + "model_providers.shared_files.env_key", + "PARSAR_PROBE_MODEL_KEY", + ), + ("model_providers.shared_files.wire_api", "responses"), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), toml::Value::String(value.to_owned()))) + .collect(); + overrides.push(( + "features.multi_agent".to_owned(), + toml::Value::Boolean(false), + )); + overrides.push(( + "shell_environment_policy.ignore_default_excludes".to_owned(), + toml::Value::Boolean(false), + )); + overrides.push(( + "shell_environment_policy.exclude".to_owned(), + toml::Value::Array(vec![ + toml::Value::String("PARSAR_PLACEMENT_MODEL_KEY_FILE".to_owned()), + toml::Value::String("PARSAR_PROBE_MODEL_KEY".to_owned()), + toml::Value::String("CODEX_EXEC_SERVER_NOISE_*".to_owned()), + ]), + )); + overrides +} + +pub fn caller_state_root() -> Result { + let home = PathBuf::from( + std::env::var_os("PARSAR_SHARED_FILES_CALLER_HOME") + .context("original caller HOME is required")?, + ); + ensure!(home.is_absolute(), "original caller HOME must be absolute"); + std::fs::canonicalize(home.join(".parsar")).context("canonical caller state root is missing") +} + +pub fn proof_root(root: &Path, state_root: &Path) -> Result { + ensure!(root.is_absolute(), "proof root must be absolute"); + let root = std::fs::canonicalize(root).context("canonical proof root is missing")?; + ensure!( + root.is_dir() && root.starts_with(state_root), + "proof root must be under caller ~/.parsar" + ); + Ok(root) +} + +#[cfg(test)] +mod tests { + use super::proof_root; + use std::path::PathBuf; + + #[test] + fn proof_root_uses_canonical_caller_boundary() -> anyhow::Result<()> { + // Even rejected test paths stay below the real caller's private state root. + let private = + PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME missing"))?) + .join(".parsar") + .canonicalize()?; + let fixture = tempfile::Builder::new() + .prefix("files-root-test-") + .tempdir_in(private)?; + let state = fixture.path().join("caller/.parsar"); + let accepted = state.join("proof"); + let outside = fixture.path().join("outside"); + let impostor = outside.join(".parsar/proof"); + std::fs::create_dir_all(&accepted)?; + std::fs::create_dir_all(&impostor)?; + let state = state.canonicalize()?; + assert_eq!(proof_root(&accepted, &state)?, accepted.canonicalize()?); + assert!(proof_root(&impostor, &state).is_err()); + assert!(proof_root(&state.join("../../outside"), &state).is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, state.join("escape"))?; + assert!(proof_root(&state.join("escape"), &state).is_err()); + } + Ok(()) + } +} diff --git a/services/agents-api/tests/native/shared_files/files.rs b/services/agents-api/tests/native/shared_files/files.rs new file mode 100644 index 000000000..072899d74 --- /dev/null +++ b/services/agents-api/tests/native/shared_files/files.rs @@ -0,0 +1,198 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result, ensure}; +use codex_exec_server::{ + ExecutorFileSystem, GetMetadataOptions, ReadFileOptions, WriteFileOptions, +}; +use codex_utils_path_uri::PathUri; +use futures::StreamExt; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +pub struct RemoteFiles { + fs: Arc, + workspace: PathBuf, +} + +impl RemoteFiles { + pub fn new(fs: Arc, workspace: PathBuf) -> Self { + Self { fs, workspace } + } + + fn path(&self, name: &str) -> Result { + PathUri::from_host_native_path(self.workspace.join(name)) + .context("encode remote filesystem path") + } + + pub async fn write(&self, name: &str, bytes: Vec) -> Result<()> { + self.fs + .write_file(&self.path(name)?, bytes, WriteFileOptions::default(), None) + .await + .context("native file write failed") + } + + async fn read(&self, name: &str) -> Result> { + self.fs + .read_file(&self.path(name)?, ReadFileOptions::default(), None) + .await + .context("native file read failed") + } + + async fn streamed(&self, name: &str, limit: usize) -> Result<(Vec, bool, usize)> { + let mut stream = self.fs.read_file_stream(&self.path(name)?, None).await?; + let mut bytes = Vec::new(); + let mut chunks = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("native stream read failed")?; + ensure!( + chunk.len() <= 1024 * 1024, + "native chunk exceeds pinned bound" + ); + chunks += 1; + let remaining = limit - bytes.len(); + bytes.extend_from_slice(&chunk[..remaining.min(chunk.len())]); + if chunk.len() > remaining { + // Native Drop schedules close; this result is not a close receipt. + return Ok((bytes, true, chunks)); + } + } + Ok((bytes, false, chunks)) + } + + async fn verify_stream(&self) -> Result { + let expected: Vec = (0..2 * 1024 * 1024 + 37) + .map(|index| (index % 251) as u8) + .collect(); + let (bytes, truncated, chunks) = self.streamed("stream-binary.bin", expected.len()).await?; + ensure!( + bytes == expected && !truncated && chunks >= 3, + "native multi-chunk bytes differ" + ); + let (prefix, truncated, _) = self.streamed("stream-binary.bin", 4096).await?; + ensure!( + prefix == expected[..4096] && truncated, + "native bounded prefix differs" + ); + let (empty, truncated, _) = self.streamed("stream-empty.bin", 0).await?; + ensure!( + empty.is_empty() && !truncated, + "native empty stream differs" + ); + Ok(json!({ + "bytes": bytes.len(), "sha256": format!("{:x}", Sha256::digest(&bytes)), + "chunks": chunks, "prefix_bytes": prefix.len(), "empty_bytes": empty.len(), + "close_receipt_verified": false, "snapshot_consistency_verified": false + })) + } + + pub async fn text(&self, name: &str) -> Result { + String::from_utf8(self.read(name).await?).context("native file is not UTF-8") + } + + pub async fn optional_text(&self, name: &str) -> Result> { + match self + .fs + .read_file(&self.path(name)?, ReadFileOptions::default(), None) + .await + { + Ok(bytes) => Ok(Some( + String::from_utf8(bytes).context("native checkpoint is not UTF-8")?, + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("native checkpoint read failed"), + } + } + + pub async fn names(&self) -> Result> { + let mut entries = self + .fs + .read_directory(&self.path("")?, None) + .await + .context("native directory listing failed")?; + entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); + ensure!( + entries + .iter() + .any(|entry| entry.file_name == "shared-binary.bin" && entry.is_file), + "binary missing from native directory listing" + ); + Ok(entries.into_iter().map(|entry| entry.file_name).collect()) + } + + pub async fn idle(&self, phase: &str, previous: &Value) -> Result { + if phase == "first" { + self.write( + "stream-binary.bin", + (0..2 * 1024 * 1024 + 37) + .map(|index| (index % 251) as u8) + .collect(), + ) + .await?; + self.write("stream-empty.bin", Vec::new()).await?; + let seed = Uuid::now_v7(); + let bytes: Vec = (0..128 * 1024) + .map(|index| (index % 251) as u8 ^ seed.as_bytes()[index % 16]) + .collect(); + self.write("shared-binary.bin", bytes.clone()).await?; + ensure!( + self.read("shared-binary.bin").await? == bytes, + "idle binary round trip differs" + ); + } else { + self.verify_binary(&previous["files"]).await?; + let expected = previous["marker"] + .as_str() + .context("previous marker is missing")?; + ensure!( + self.text("first-marker.txt").await? == format!("{expected}\n"), + "cold file retention failed" + ); + } + let bytes = self.read("shared-binary.bin").await?; + let metadata = self + .fs + .get_metadata( + &self.path("shared-binary.bin")?, + GetMetadataOptions::default(), + None, + ) + .await + .context("native metadata failed")?; + ensure!( + bytes.len() == 128 * 1024 && metadata.size == bytes.len() as u64 && metadata.is_file, + "native binary metadata differs" + ); + Ok( + json!({"binary_bytes":bytes.len(),"binary_sha256":format!("{:x}",Sha256::digest(&bytes)),"metadata_size":metadata.size,"directory_names":self.names().await?,"stream":self.verify_stream().await?}), + ) + } + + pub async fn verify_binary(&self, proof: &Value) -> Result<()> { + let bytes = self.read("shared-binary.bin").await?; + ensure!(bytes.len() == 128 * 1024, "retained binary size differs"); + let metadata = self + .fs + .get_metadata( + &self.path("shared-binary.bin")?, + GetMetadataOptions::default(), + None, + ) + .await + .context("native binary metadata failed")?; + ensure!( + metadata.size == bytes.len() as u64 && metadata.is_file, + "native binary metadata differs" + ); + let expected = proof["binary_sha256"] + .as_str() + .context("binary proof hash is missing")?; + ensure!( + format!("{:x}", Sha256::digest(&bytes)) == expected, + "retained binary hash differs" + ); + self.verify_stream().await?; + Ok(()) + } +} diff --git a/services/agents-api/tests/native/shared_files/observations.rs b/services/agents-api/tests/native/shared_files/observations.rs new file mode 100644 index 000000000..98fa3e322 --- /dev/null +++ b/services/agents-api/tests/native/shared_files/observations.rs @@ -0,0 +1,315 @@ +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, ensure}; +use codex_app_server_protocol::{ServerNotification, ThreadItem}; +use codex_shell_command::parse_command::extract_shell_command; +use serde_json::{Value, json}; +use tokio::time::sleep; + +#[derive(Clone, Default)] +struct Observed { + failure: Option<&'static str>, + turns_started: Vec<(String, String)>, + turns_completed: Vec<(String, String, Value)>, + commands_started: Vec<(String, String, String)>, + commands_completed: Vec<(String, String, Value)>, + answer: String, + events: Vec, + event_bytes: usize, + event_count: usize, +} + +impl Observed { + fn record(&mut self, notification: ServerNotification) -> Result<()> { + self.event_count += 1; + ensure!( + self.event_count <= 10_000, + "native event count exceeded fixture bound" + ); + let relevant = match ¬ification { + ServerNotification::TurnStarted(event) => { + self.turns_started + .push((event.thread_id.clone(), event.turn.id.clone())); + true + } + ServerNotification::TurnCompleted(event) => { + self.turns_completed.push(( + event.thread_id.clone(), + event.turn.id.clone(), + serde_json::to_value(&event.turn)?, + )); + true + } + ServerNotification::ItemStarted(event) => { + if let ThreadItem::CommandExecution { id, .. } = &event.item { + self.commands_started.push(( + event.thread_id.clone(), + event.turn_id.clone(), + id.clone(), + )); + true + } else { + false + } + } + ServerNotification::ItemCompleted(event) => match &event.item { + ThreadItem::CommandExecution { .. } => { + self.commands_completed.push(( + event.thread_id.clone(), + event.turn_id.clone(), + serde_json::to_value(&event.item)?, + )); + true + } + ThreadItem::AgentMessage { text, phase, .. } => { + let phase = serde_json::to_value(phase)?; + if phase.is_null() || phase == "final_answer" { + ensure!( + self.answer.len() + text.len() <= 256 * 1024, + "answer exceeded fixture bound" + ); + self.answer.push_str(text); + self.answer.push('\n'); + } + true + } + _ => false, + }, + _ => false, + }; + ensure!( + self.turns_started.len() <= 1 + && self.turns_completed.len() <= 1 + && self.commands_started.len() <= 1 + && self.commands_completed.len() <= 1, + "unexpected additional Turn or command" + ); + if relevant { + let value = serde_json::to_value(notification)?; + self.event_bytes += serde_json::to_vec(&value)?.len(); + ensure!( + self.event_bytes <= 4 * 1024 * 1024, + "native observations exceeded fixture byte bound" + ); + self.events.push(value); + } + Ok(()) + } +} + +pub struct Evidence { + pub answer: String, + pub command: Value, + pub events: Vec, +} + +#[derive(Clone, Default)] +pub struct Observations { + state: Arc>, +} + +impl Observations { + pub fn record(&self, notification: ServerNotification) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("observation lock poisoned"))?; + if state.failure.is_none() && state.record(notification).is_err() { + state.failure = Some("native observation bounds or counts failed"); + } + Ok(()) + } + + pub fn fail(&self, reason: &'static str) -> Result<()> { + self.state + .lock() + .map_err(|_| anyhow!("observation lock poisoned"))? + .failure = Some(reason); + Ok(()) + } + + fn snapshot(&self) -> Result { + Ok(self + .state + .lock() + .map_err(|_| anyhow!("observation lock poisoned"))? + .clone()) + } + + pub fn events(&self) -> Result> { + self.healthy()?; + Ok(self.snapshot()?.events) + } + + pub fn healthy(&self) -> Result<()> { + let state = self + .state + .lock() + .map_err(|_| anyhow!("observation lock poisoned"))?; + ensure!( + state.failure.is_none(), + "{}", + state.failure.unwrap_or("native observation failed") + ); + Ok(()) + } + + pub fn require_active(&self, thread: &str, turn: &str) -> Result<()> { + self.healthy()?; + let state = self.snapshot()?; + ensure!( + state.turns_started == vec![(thread.to_owned(), turn.to_owned())] + && state.turns_completed.is_empty(), + "native Turn is not observed active" + ); + ensure!( + state.commands_started.len() == 1 && state.commands_completed.is_empty(), + "native command is not observed active" + ); + ensure!( + state.commands_started[0].0 == thread && state.commands_started[0].1 == turn, + "active command belongs to another Turn" + ); + Ok(()) + } + + pub fn require_unfinished(&self) -> Result<()> { + self.healthy()?; + ensure!( + self.snapshot()?.turns_completed.is_empty(), + "native Turn completed before command heartbeat" + ); + Ok(()) + } + + pub async fn wait_active(&self, thread: &str, turn: &str) -> Result<()> { + loop { + self.healthy()?; + let state = self.snapshot()?; + ensure!( + state.turns_completed.is_empty() && state.commands_completed.is_empty(), + "command completed before active checkpoint" + ); + if state.turns_started.len() == 1 && state.commands_started.len() == 1 { + return self.require_active(thread, turn); + } + sleep(Duration::from_millis(25)).await; + } + } + + pub async fn wait_completed(&self) -> Result<()> { + loop { + self.healthy()?; + if !self.snapshot()?.turns_completed.is_empty() { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + } + + pub fn validate( + &self, + thread: &str, + turn: &str, + phase: &str, + workspace: &Path, + marker: &str, + history: &str, + ) -> Result { + self.healthy()?; + let state = self.snapshot()?; + ensure!( + state.turns_started == vec![(thread.to_owned(), turn.to_owned())] + && state.turns_completed.len() == 1, + "native Turn lifecycle missing" + ); + let completed = &state.turns_completed[0]; + ensure!( + completed.0 == thread && completed.1 == turn && completed.2["status"] == "completed", + "native Turn did not complete successfully" + ); + ensure!( + state.commands_started.len() == 1 && state.commands_completed.len() == 1, + "native command lifecycle missing" + ); + let started = &state.commands_started[0]; + let completed = &state.commands_completed[0]; + let item = &completed.2; + ensure!( + started.0 == thread + && started.1 == turn + && completed.0 == thread + && completed.1 == turn + && item["id"] == started.2, + "command lifecycle identities differ" + ); + let command = item["command"] + .as_str() + .context("native command text missing")?; + ensure!( + is_gate_command(command, phase), + "model executed an unexpected command" + ); + let cwd = workspace.to_str().context("workspace encoding")?; + ensure!( + item["cwd"] == cwd && item["exitCode"] == 7, + "native command cwd or exit differs" + ); + let output = item["aggregatedOutput"] + .as_str() + .context("native command output missing")?; + ensure!( + output.contains(marker) + && output.contains(&format!("remote-stdout:{phase}")) + && output.contains(&format!("remote-stderr:{phase}")), + "native command output observations missing" + ); + ensure!( + !command.contains(history) && !output.contains(history), + "prompt-only history leaked into command or files" + ); + ensure!( + state.answer.contains(marker) && state.answer.contains(history), + "native final answer did not recall marker and history" + ); + Ok(Evidence { + answer: state.answer, + command: json!({"id":item["id"],"command":command,"cwd":cwd,"aggregated_output":output,"exit_code":7,"started":true,"completed":true}), + events: state.events, + }) + } +} + +pub(super) fn is_gate_command(command: &str, phase: &str) -> bool { + let Some(argv) = shlex::split(command) else { + return false; + }; + argv == ["./shared-gate.sh", phase] + || extract_shell_command(&argv) + .is_some_and(|(_, script)| script == format!("./shared-gate.sh {phase}")) +} + +#[cfg(test)] +mod tests { + use super::is_gate_command; + + #[test] + fn exact_gate_accepts_native_presentation_only() { + assert!(is_gate_command("./shared-gate.sh first", "first")); + assert!(is_gate_command( + "/bin/bash -lc './shared-gate.sh first'", + "first" + )); + assert!(!is_gate_command( + "/bin/bash -lc './shared-gate.sh first; echo invented'", + "first" + )); + assert!(!is_gate_command("./shared-gate.sh fresh", "first")); + assert!(!is_gate_command( + "./shared-gate.sh first && echo invented", + "first" + )); + } +} diff --git a/services/agents-api/tests/native/shared_files/probe.rs b/services/agents-api/tests/native/shared_files/probe.rs new file mode 100644 index 000000000..e09f7ad64 --- /dev/null +++ b/services/agents-api/tests/native/shared_files/probe.rs @@ -0,0 +1,324 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use codex_exec_server::EnvironmentManager; +use serde_json::{Value, json}; +use tokio::time::{sleep, timeout}; +use uuid::Uuid; + +use crate::{cancellation, configuration, files, runtime}; + +const PHASE_TIMEOUT: Duration = Duration::from_secs(240); +// Match pinned codex arg0/src/lib.rs and async-utils/src/lib.rs for native futures. +const NATIVE_STACK_BYTES: usize = 16 * 1024 * 1024; + +pub fn main(allow_cancel: bool) -> std::process::ExitCode { + match run_native_runtime(allow_cancel) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + // Display only the safe outer context, not upstream connection/auth error chains. + eprintln!("shared-files probe failed: {error}"); + std::process::ExitCode::FAILURE + } + } +} + +fn run_native_runtime(allow_cancel: bool) -> Result<()> { + std::thread::Builder::new() + .name("shared-files-main".to_owned()) + .stack_size(NATIVE_STACK_BYTES) + .spawn(move || { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(4) + .thread_stack_size(NATIVE_STACK_BYTES) + .build() + .context("native runtime construction failed")? + .block_on(run(allow_cancel)) + }) + .context("native main thread construction failed")? + .join() + .map_err(|_| anyhow::anyhow!("native main thread panicked"))? +} + +async fn run(allow_cancel: bool) -> Result<()> { + let phase = std::env::args().nth(1).context("phase is required")?; + ensure!( + matches!(phase.as_str(), "first" | "fresh") || (allow_cancel && phase == "cancel"), + "invalid phase" + ); + let root = configuration::proof_root( + &required_path("PARSAR_SHARED_FILES_ROOT")?, + &configuration::caller_state_root()?, + )?; + let workspace = required_path("PARSAR_SHARED_FILES_WORKSPACE")?; + ensure!( + !workspace.exists(), + "executor workspace exists on harness host" + ); + let binary = required_path("PARSAR_CODEX_BINARY")?; + ensure!(binary.is_file(), "native resource binary is missing"); + ensure!( + !std::env::var("PARSAR_PROBE_MODEL_KEY") + .unwrap_or_default() + .trim() + .is_empty(), + "explicit model key is missing" + ); + ensure!( + std::env::var_os("CODEX_API_KEY").is_none() && std::env::var_os("OPENAI_API_KEY").is_none(), + "ambient provider authentication is present" + ); + for name in [ + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", + ] { + ensure!( + !std::env::var(name).unwrap_or_default().is_empty(), + "explicit Noise configuration is missing" + ); + } + tokio::fs::create_dir_all(root.join("harness")) + .await + .context("create harness directory")?; + let (client, manager) = runtime::Runtime::start(&root, binary).await?; + let result = timeout(PHASE_TIMEOUT, async { + ensure!( + manager.try_local_environment().is_none(), + "local fallback is configured" + ); + let environment = manager + .default_environment() + .context("remote Environment is absent")?; + ensure!(environment.is_remote(), "Environment is not remote"); + timeout(Duration::from_secs(45), environment.wait_until_ready()) + .await + .context("remote readiness timed out")? + .context("remote readiness failed")?; + let fs = files::RemoteFiles::new(environment.get_filesystem(), workspace.clone()); + if phase == "cancel" { + cancellation::exercise(&root, &workspace, &fs, &client).await + } else { + exercise(&root, &workspace, &phase, &fs, &client, &manager).await + } + }) + .await + .context("shared-files phase timed out") + .and_then(|result| result); + let result = async { + let mut proof = result?; + save(&root, &format!("{phase}-ready.json"), &proof).await?; + wait_checkpoint(&root.join(format!("{phase}-release")), &client).await?; + if phase == "cancel" { + cancellation::refresh(&mut proof, &client)?; + } + Ok::<_, anyhow::Error>(proof) + } + .await; + let stopped = client.shutdown().await; + drop(manager); + stopped?; + let mut proof = result?; + proof["shutdown_completed"] = json!(true); + save(&root, &format!("{phase}.json"), &proof).await +} + +async fn exercise( + root: &Path, + workspace: &Path, + phase: &str, + fs: &files::RemoteFiles, + client: &runtime::Runtime, + manager: &Arc, +) -> Result { + let previous: Value = if phase == "fresh" { + serde_json::from_slice( + &tokio::fs::read(root.join("first.json")) + .await + .context("read first proof")?, + ) + .context("decode first proof")? + } else { + Value::Null + }; + let marker = format!("shared-marker-{}", Uuid::now_v7()); + let history = if phase == "first" { + format!("history-only-{}", Uuid::now_v7()) + } else { + field(&previous, "history_value")?.to_owned() + }; + let mut file_proof = fs.idle(phase, &previous).await?; + let selection = json!([{"environmentId":"remote", "cwd":workspace}]); + let thread_response = if phase == "first" { + client + .request( + 1, + "thread/start", + json!({"cwd":workspace,"environments":selection,"ephemeral":false}), + ) + .await? + } else { + client + .request( + 1, + "thread/resume", + json!({"threadId":field(&previous,"native_thread_id")?,"cwd":workspace}), + ) + .await? + }; + let thread_id = field(&thread_response["thread"], "id")?.to_owned(); + if phase == "fresh" { + ensure!( + thread_id == field(&previous, "native_thread_id")?, + "cold resume changed thread identity" + ); + } + let recovery = if phase == "fresh" { + cancellation::recover(root, &thread_id, fs, client).await? + } else { + None + }; + let memory_instruction = if phase == "first" { + format!( + "Remember this prompt-only history value: {history}. Never put that value in a command, tool argument, or file." + ) + } else { + "Recall the prompt-only history value from our previous turn. It is not in any workspace file; do not search for it.".to_owned() + }; + let prompt = format!( + "{memory_instruction} Execute exactly one command: `./shared-gate.sh {phase}`. The `{phase}` argument is required; pass the entire command including this argument as the exec_command cmd value. Do not omit or change the argument. It waits on a bounded fixture gate and intentionally exits 7 after printing a new random marker, remote stdout and remote stderr. Let it finish; use native polling if needed, but never rerun it or execute any other command. Do not open the gate or read/write any files yourself. Then give one final answer containing both the exact newly printed marker and the prompt-only history value. Do not guess the marker or repair the intentional exit status." + ); + let started = client.request(2, "turn/start", json!({"threadId":thread_id,"input":[{"type":"text","text":prompt}],"environments":selection})).await?; + let turn_id = field(&started["turn"], "id")?.to_owned(); + let heartbeat = timeout(Duration::from_secs(150), async { + loop { + client.observations.require_unfinished()?; + if let Some(value) = fs.optional_text(&format!("{phase}.heartbeat")).await? { + ensure!(!value.trim().is_empty(), "empty active heartbeat"); + client + .observations + .wait_active(&thread_id, &turn_id) + .await?; + break Ok::<_, anyhow::Error>(value); + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .context("real command heartbeat timed out")??; + wait_checkpoint(&root.join(format!("{phase}-active-observed")), client).await?; + client.observations.require_active(&thread_id, &turn_id)?; + fs.verify_binary(&file_proof).await?; + let active_directory = fs.names().await?; + ensure!( + fs.optional_text(&format!("{phase}.release")) + .await? + .is_none(), + "remote gate was already released" + ); + fs.write( + &format!("{phase}-marker.txt"), + format!("{marker}\n").into_bytes(), + ) + .await?; + ensure!( + fs.text(&format!("{phase}-marker.txt")).await? == format!("{marker}\n"), + "active direct file round trip differs" + ); + fs.write(&format!("{phase}.release"), b"release\n".to_vec()) + .await?; + client.observations.wait_completed().await?; + let observation = client + .observations + .validate(&thread_id, &turn_id, phase, workspace, &marker, &history)?; + ensure!( + fs.text(&format!("{phase}.cwd")).await?.trim() + == workspace.to_str().context("workspace path encoding")?, + "remote command cwd differs" + ); + let artifact = fs.text(&format!("{phase}-artifact.txt")).await?; + ensure!( + artifact == format!("{marker}\n"), + "command artifact differs from direct file marker" + ); + let counts = fs.text("shared-gate-count").await?; + let expected = if phase == "first" { + vec!["first"] + } else if recovery.is_some() { + vec!["first", "cancel", "fresh"] + } else { + vec!["first", "fresh"] + }; + ensure!( + counts.lines().collect::>() == expected, + "real command gate ran more than once" + ); + fs.verify_binary(&file_proof).await?; + file_proof["active_heartbeat"] = json!(heartbeat); + file_proof["active_binary_verified"] = json!(true); + file_proof["active_directory_names"] = json!(active_directory); + file_proof["artifact"] = json!(artifact); + file_proof["directory_names"] = json!(fs.names().await?); + ensure!( + manager.try_local_environment().is_none(), + "local fallback appeared" + ); + let mut proof = json!({ + "phase":phase, + "native_thread_id":thread_id,"native_turn_id":turn_id,"marker":marker,"history_value":history, + "answer":observation.answer,"command":observation.command,"files":file_proof, + "turn_started_count":1,"turn_completed_count":1,"command_started_count":1,"command_completed_count":1, + "events":observation.events,"no_lagged_observed":true,"shutdown_completed":false, + }); + if let Some(recovery) = recovery { + proof["cancellation_recovery"] = recovery; + } + runtime::annotate(&mut proof); + Ok(proof) +} + +pub(super) async fn wait_checkpoint(path: &Path, client: &runtime::Runtime) -> Result<()> { + timeout(Duration::from_secs(90), async { + loop { + client.observations.healthy()?; + if tokio::fs::try_exists(path) + .await + .context("read host checkpoint")? + { + return Ok::<_, anyhow::Error>(()); + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .context("fixture checkpoint timed out")? +} + +fn required_path(name: &str) -> Result { + let path = PathBuf::from(std::env::var(name).with_context(|| format!("{name} is required"))?); + ensure!(path.is_absolute(), "{name} must be absolute"); + Ok(path) +} + +pub(super) fn field<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value[key] + .as_str() + .filter(|value| !value.is_empty()) + .with_context(|| format!("missing proof field {key}")) +} + +async fn save(root: &Path, name: &str, value: &Value) -> Result<()> { + let temporary = root.join(format!("{name}.tmp")); + tokio::fs::write( + &temporary, + serde_json::to_vec_pretty(value).context("encode proof")?, + ) + .await + .context("write proof")?; + tokio::fs::rename(temporary, root.join(name)) + .await + .context("publish proof") +} diff --git a/services/agents-api/tests/native/shared_files/raw_runtime.rs b/services/agents-api/tests/native/shared_files/raw_runtime.rs new file mode 100644 index 000000000..d49cc4bdf --- /dev/null +++ b/services/agents-api/tests/native/shared_files/raw_runtime.rs @@ -0,0 +1,294 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use codex_app_server::{ + AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, + PluginStartupTasks, RemoteControlStartupMode, + run_main_with_transport_options_and_environment_manager, +}; +use codex_app_server_client::{ + AppServerEvent, AppServerRequestHandle, RemoteAppServerClient, RemoteAppServerConnectArgs, + RemoteAppServerEndpoint, +}; +use codex_app_server_protocol::{ClientRequest, JSONRPCErrorError}; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_exec_server::EnvironmentManager; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio::time::{sleep, timeout}; + +use super::{configuration, observations::Observations}; + +pub struct Runtime { + sender: AppServerRequestHandle, + pub observations: Observations, + stop: oneshot::Sender<()>, + drain: JoinHandle>, + runner: JoinHandle>, + socket_directory: TempDir, +} + +impl Runtime { + pub async fn start(root: &Path, binary: PathBuf) -> Result<(Self, Arc)> { + let home = root.join("harness/codex"); + ensure!( + std::env::var_os("CODEX_HOME").map(PathBuf::from).as_ref() == Some(&home), + "raw runner requires the isolated native history directory" + ); + tokio::fs::create_dir_all(&home) + .await + .context("create native history directory")?; + // The proof path can exceed Unix socket limits. This dedicated 0700 directory + // stays under the original caller's private root, independently of child HOME. + let socket_directory = tempfile::Builder::new() + .prefix("raw-files-") + .tempdir_in(configuration::caller_state_root()?) + .context("create private raw socket directory")?; + let socket_path = socket_directory.path().join("rpc.sock"); + ensure!( + socket_path.as_os_str().len() < 104, + "private raw socket path is too long" + ); + let absolute_socket = AbsolutePathBuf::from_absolute_path(&socket_path) + .context("invalid private raw socket path")?; + let raw_overrides = configuration::overrides() + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + let transport = AppServerTransport::UnixSocket { + socket_path: absolute_socket.clone(), + }; + let (publish, published) = oneshot::channel(); + let mut runner = tokio::spawn(async move { + run_main_with_transport_options_and_environment_manager( + Arg0DispatchPaths { + codex_self_exe: Some(binary), + ..Default::default() + }, + CliConfigOverrides { raw_overrides }, + LoaderOverrides { + ignore_user_config: true, + ignore_project_config: true, + ..Default::default() + }, + false, + false, + transport, + SessionSource::Exec, + AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions { + plugin_startup_tasks: PluginStartupTasks::Skip, + remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, + install_shutdown_signal_handler: true, + ..Default::default() + }, + publish, + ) + .await + .context("raw native runner failed") + }); + let startup = async { + // Publication is not readiness: also wait for the socket and complete + // the maintained client's initialize/initialized exchange exactly once. + let manager = published.await.context("raw manager was not published")?; + while !tokio::fs::try_exists(&socket_path) + .await + .context("inspect raw socket")? + { + sleep(Duration::from_millis(20)).await; + } + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: absolute_socket, + }, + client_name: "parsar_raw_files_probe".to_owned(), + client_version: "1".to_owned(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 1024, + }) + .await + .context("raw initialize exchange failed")?; + Ok::<_, anyhow::Error>((client, manager)) + }; + let started = tokio::select! { + result = &mut runner => { + result.context("raw runner task failed during startup")??; + bail!("raw runner stopped during startup"); + } + result = timeout(Duration::from_secs(45), startup) => { + result.context("raw startup timed out").and_then(|result| result) + } + }; + let (mut client, manager) = match started { + Ok(started) => started, + Err(error) => { + // No initialized owner is returned on partial startup. The dedicated + // process exits after this bounded failure; no request is replayed. + runner.abort(); + let _ = runner.await; + return Err(error); + } + }; + let sender = AppServerRequestHandle::Remote(client.request_handle()); + let observations = Observations::default(); + let state = observations.clone(); + let (stop, mut stopped) = oneshot::channel(); + let drain = tokio::spawn(async move { + let result = async { + loop { + tokio::select! { + _ = &mut stopped => return Ok::<_, anyhow::Error>(()), + event = client.next_event() => match event { + Some(AppServerEvent::ServerNotification(notification)) => state.record(*notification)?, + Some(AppServerEvent::ServerRequest(request)) => { + state.fail("unexpected native client request")?; + timeout(Duration::from_secs(5), client.reject_server_request(request.id().clone(), JSONRPCErrorError { + code: -32601, + message: "Unexpected client request in shared files probe".to_owned(), + data: None, + })).await.context("native client request rejection timed out")? + .context("native client request rejection failed")?; + } + Some(AppServerEvent::Lagged { .. }) => state.fail("native Lagged event observed")?, + Some(AppServerEvent::Disconnected { .. }) | None => { + state.fail("raw native event stream closed early")?; + return Ok(()); + } + } + } + } + }.await; + // The native client has an unbounded event queue. Observation limits + // bound retained evidence only; this probe does not qualify backpressure. + let stopped = client + .shutdown() + .await + .context("raw native client shutdown failed"); + result.and(stopped) + }); + Ok(( + Self { + sender, + observations, + stop, + drain, + runner, + socket_directory, + }, + manager, + )) + } + + pub async fn request(&self, id: i64, method: &str, params: Value) -> Result { + self.observations.healthy()?; + ensure!( + !self.runner.is_finished(), + "raw native runner stopped before request" + ); + let request: ClientRequest = + serde_json::from_value(json!({"id":id,"method":method,"params":params})) + .context("native typed request parameters failed")?; + timeout(Duration::from_secs(60), self.sender.request(request)) + .await + .with_context(|| format!("native {method} timed out"))? + .with_context(|| format!("native {method} transport failed"))? + .map_err(|_| anyhow!("native {method} request failed")) + } + + pub async fn shutdown(self) -> Result<()> { + let Self { + sender, + observations, + stop, + mut drain, + mut runner, + socket_directory, + } = self; + drop(sender); + let _ = stop.send(()); + let client_stopped = match timeout(Duration::from_secs(15), &mut drain).await { + Ok(result) => result + .context("raw event drain task failed") + .and_then(|result| result), + Err(_) => { + drain.abort(); + let _ = drain.await; + Err(anyhow!("raw client shutdown exceeded fixture deadline")) + } + }; + let runner_stopped = if runner.is_finished() { + let result = (&mut runner).await.context("raw runner task failed")?; + result.and(Err(anyhow!("raw runner stopped before fixture shutdown"))) + } else { + // Closing the socket client does not stop the multi-client raw listener. + // This executable owns its whole process; SIGHUP requests native graceful + // shutdown, and success requires joining that runner, not just the client. + let signal = timeout( + Duration::from_secs(5), + tokio::process::Command::new("/bin/kill") + .args(["-HUP", &std::process::id().to_string()]) + .kill_on_drop(true) + .status(), + ) + .await + .context("raw shutdown signal timed out") + .and_then(|result| result.context("raw shutdown signal failed")) + .and_then(|status| { + ensure!(status.success(), "raw shutdown signal was rejected"); + Ok(()) + }); + match signal { + Ok(()) => match timeout(Duration::from_secs(30), &mut runner).await { + Ok(result) => result + .context("raw runner task failed") + .and_then(|result| result), + Err(_) => { + runner.abort(); + let _ = runner.await; + Err(anyhow!("raw runner shutdown exceeded fixture deadline")) + } + }, + Err(error) => { + runner.abort(); + let _ = runner.await; + Err(error) + } + } + }; + drop(socket_directory); + runner_stopped?; + client_stopped?; + observations.healthy() + } +} + +pub fn annotate(proof: &mut Value) { + proof["status"] = json!("raw_native_files_characterized"); + proof["transport"] = json!("raw_unix_socket"); + proof["initialize_completed"] = json!(true); + let cancellation_limit = if proof["cancellation"].is_object() + || proof["cancellation_recovery"].is_object() + { + "Cancellation observations cover this bounded fixture and its targeted native process only, not universal executor OS quiescence or stale-write fencing." + } else { + "Native cancellation is a required subsequent composition slice and is not exercised here." + }; + proof["limitations"] = json!([ + "The pinned native remote client has an unbounded consumer event queue. This finite workload does not qualify production memory or backpressure.", + "Readiness-gated output checks do not establish complete native early-output capture; that limitation remains deferred.", + "This private raw composition does not authorize production adoption, idle ownership or credential lifetime.", + cancellation_limit, + "Native runner shutdown may abort internal tasks; joining it is not proof of executor OS quiescence.", + "Direct native filesystem checks do not establish authorization, workspace confinement, public pagination or file_id semantics." + ]); +} diff --git a/services/agents-api/tests/native/shared_files/runtime.rs b/services/agents-api/tests/native/shared_files/runtime.rs new file mode 100644 index 000000000..c73966e50 --- /dev/null +++ b/services/agents-api/tests/native/shared_files/runtime.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use codex_app_server::in_process::{ + self, InProcessClientSender, InProcessServerEvent, InProcessStartArgs, +}; +use codex_app_server_protocol::{ClientRequest, InitializeParams, JSONRPCErrorError}; +use codex_arg0::Arg0DispatchPaths; +use codex_config::{CloudConfigBundleLoader, LoaderOverrides, NoopThreadConfigLoader}; +use codex_core::config::{ConfigBuilder, ConfigOverrides}; +use codex_exec_server::{EnvironmentManager, ExecServerRuntimePaths}; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use serde_json::{Value, json}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use super::observations::Observations; + +async fn configuration( + root: &Path, + binary: PathBuf, +) -> Result<(InProcessStartArgs, Arc)> { + let home = root.join("harness/codex"); + tokio::fs::create_dir_all(&home) + .await + .context("create native history directory")?; + let overrides = super::configuration::overrides(); + let loader = LoaderOverrides { + ignore_user_config: true, + ignore_project_config: true, + ..LoaderOverrides::default() + }; + let paths = Arg0DispatchPaths { + codex_self_exe: Some(binary.clone()), + ..Arg0DispatchPaths::default() + }; + let config = Arc::new( + ConfigBuilder::default() + .codex_home(home) + .cli_overrides(overrides.clone()) + .loader_overrides(loader.clone()) + .harness_overrides(ConfigOverrides { + cwd: Some(root.join("harness")), + codex_self_exe: Some(binary.clone()), + ..ConfigOverrides::default() + }) + .build() + .await + .context("native configuration failed")?, + ); + let manager = Arc::new( + EnvironmentManager::from_env( + Some( + ExecServerRuntimePaths::new(binary, None) + .context("native resource paths failed")?, + ), + config.http_client_factory(), + ) + .await + .context("native Environment manager failed")?, + ); + let state_db = codex_rollout::state_db::try_init(config.as_ref()) + .await + .context("native history initialization failed")?; + let initialize: InitializeParams = serde_json::from_value(json!({"clientInfo":{"name":"parsar_shared_files_probe","version":"1"},"capabilities":{"experimentalApi":true}})).context("initialize parameters failed")?; + Ok(( + InProcessStartArgs { + arg0_paths: paths, + config, + cli_overrides: overrides, + loader_overrides: loader, + strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), + thread_config_loader: Arc::new(NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: Some(state_db), + environment_manager: manager.clone(), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + initialize, + channel_capacity: 1024, + }, + manager, + )) +} + +pub struct Runtime { + sender: InProcessClientSender, + pub observations: Observations, + stop: oneshot::Sender<()>, + drain: JoinHandle>, +} + +impl Runtime { + pub async fn start(root: &Path, binary: PathBuf) -> Result<(Self, Arc)> { + let (args, manager) = configuration(root, binary).await?; + let mut handle = timeout(Duration::from_secs(45), in_process::start(args)) + .await + .context("embedded app-server startup timed out")? + .context("embedded app-server startup failed")?; + let sender = handle.sender(); + let observations = Observations::default(); + let state = observations.clone(); + let (stop, mut stopped) = oneshot::channel(); + let drain = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut stopped => break, + event = handle.next_event() => { + match event { + Some(InProcessServerEvent::ServerNotification(notification)) => { + state.record(*notification)?; + } + Some(InProcessServerEvent::ServerRequest(request)) => { + let _ = handle.fail_server_request(request.id().clone(), JSONRPCErrorError { code: -32601, message: "Unexpected client request in shared files probe".to_owned(), data: None }); + state.fail("unexpected native client request")?; + } + Some(InProcessServerEvent::Lagged { .. }) => { + state.fail("native Lagged event observed")?; + } + None => { + state.fail("native event stream closed early")?; + break; + } + } + } + } + } + handle + .shutdown() + .await + .context("embedded app-server shutdown failed") + }); + Ok(( + Self { + sender, + observations, + stop, + drain, + }, + manager, + )) + } + + pub async fn request(&self, id: i64, method: &str, params: Value) -> Result { + self.observations.healthy()?; + let request: ClientRequest = + serde_json::from_value(json!({"id":id,"method":method,"params":params})) + .context("native typed request parameters failed")?; + timeout(Duration::from_secs(60), self.sender.request(request)) + .await + .with_context(|| format!("native {method} timed out"))? + .with_context(|| format!("native {method} transport failed"))? + .map_err(|_| anyhow!("native {method} request failed")) + } + + pub async fn shutdown(self) -> Result<()> { + let observed = self.observations.clone(); + let _ = self.stop.send(()); + timeout(Duration::from_secs(50), self.drain) + .await + .context("embedded shutdown exceeded fixture deadline")? + .context("event drain task failed")??; + observed.healthy() + } +} + +pub fn annotate(proof: &mut Value) { + proof["status"] = json!("shared_native_files_characterized_with_blockers"); + proof["limitations"] = json!([ + "Pinned in-process queues can silently drop non-required notifications; only this bounded workflow's required observations were checked. Production lossless delivery remains blocked.", + "This is typed in-process embedding, not raw stdio compatibility or a production daemon integration.", + "Upstream shutdown may abort internal tasks; returned shutdown is not proof of executor OS quiescence.", + "Direct native filesystem checks do not establish authorization, workspace confinement, public pagination or file_id semantics." + ]); +} diff --git a/services/agents-api/tests/native/shared_files_probe.rs b/services/agents-api/tests/native/shared_files_probe.rs new file mode 100644 index 000000000..9e5b34f85 --- /dev/null +++ b/services/agents-api/tests/native/shared_files_probe.rs @@ -0,0 +1,16 @@ +#[path = "shared_files/cancellation.rs"] +mod cancellation; +#[path = "shared_files/configuration.rs"] +mod configuration; +#[path = "shared_files/files.rs"] +mod files; +#[path = "shared_files/observations.rs"] +mod observations; +#[path = "shared_files/probe.rs"] +mod probe; +#[path = "shared_files/runtime.rs"] +mod runtime; + +fn main() -> std::process::ExitCode { + probe::main(false) +} diff --git a/services/agents-api/tests/native/write/README.md b/services/agents-api/tests/native/write/README.md new file mode 100644 index 000000000..eadde5aa6 --- /dev/null +++ b/services/agents-api/tests/native/write/README.md @@ -0,0 +1,31 @@ +# Private harness file-write qualification + +`TestNativeHarnessFileWrite` reuses the PostgreSQL registry, issued transport +credentials and Docker launcher fixture. It runs the built exact-pin harness, +connects its native EnvironmentManager over Noise and sends bounded binary input +to the existing private file socket. The executor runs the scoped installer under +the native Linux sandbox; no shell command or local file fallback performs writes. + +Set `PARSAR_CODEX_HARNESS_ARTIFACT`, `PARSAR_WRITE_HELPER_ARTIFACT`, +`PARSAR_EXECUTOR_PROOF_DIR`, `PARSAR_EXECUTOR_LAUNCHER`, `PARSAR_CODEX_BINARY` and +`PARSAR_PLACEMENT_EXECUTOR_IMAGE` to the qualified artifacts and private evidence +root. Use the existing PostgreSQL test configuration and run: + +```sh +go test ./services/agents-api/internal/store -run '^TestNativeHarnessFileWrite$' -count=1 -v -timeout=6m +``` + +Synthetic acceptance covers empty/binary/full 50 MiB writes, overwrite, hard-link +preservation, unsafe paths, oversized and incomplete input, caller detachment, +subsequent operation ownership and read-only rejection with configured write +selectors. Host-side bytes and inode observations are independent of the returned +commit receipt. Evidence records the harness digest and each payload digest. +Credentials never enter the record; teardown removes the owned containers and +transport credential files. Fixture placement grants only this Environment's +workspace/staging parent to the installer; it does not grant public admission. + +Run `TestNativePublicEnvironmentFiles` with a real model API and the same harness +artifact separately for unchanged model execution and public Files.list regression. +Native unit tests cover strict receipt decoding, chunk rejection, bounded input, +owner shutdown and unresolved native deadlines. These finite checks do not attest +durable mutation recovery or production replacement safety. diff --git a/services/agents-api/tests/native/write/probe.py b/services/agents-api/tests/native/write/probe.py new file mode 100644 index 000000000..d25cf8b84 --- /dev/null +++ b/services/agents-api/tests/native/write/probe.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Private raw harness qualification; synthetic bytes, no public admission.""" +import hashlib +import json +import os +from pathlib import Path +import socket +import subprocess +import time + +root = Path(os.environ['PARSAR_NATIVE_ENV_PROOF']) +local = Path(os.environ['PARSAR_WRITE_LOCAL']) +workspace = local / 'workspace' +ipc = Path(os.environ['PARSAR_CODEX_HARNESS_IPC_ROOT']) +artifact = os.environ['PARSAR_CODEX_HARNESS_ARTIFACT'] +observations = [] + + +def rpc(process, identifier, method, params): + process.stdin.write(json.dumps(dict(id=identifier, method=method, params=params)).encode() + b'\n') + process.stdin.flush() + while True: + line = process.stdout.readline() + if not line: + raise RuntimeError('harness ended before RPC response') + result = json.loads(line) + if result.get('id') == identifier: + if 'error' in result: + raise RuntimeError('native RPC rejected ' + method) + return result['result'] + + +def request(path, body, *, size=None, detach=False): + frame = dict(environment_id=os.environ['PARSAR_CODEX_HARNESS_ENVIRONMENT'], + path=path, operation='write', size_bytes=len(body) if size is None else size) + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(65) + client.connect(str(ipc / 'files.sock')) + client.sendall(json.dumps(frame).encode() + b'\n' + body) + if detach: + return + with client.makefile('rb') as reader: + return json.loads(reader.readline(8192)) + + +def wait_file(path, expected): + deadline = time.monotonic() + 65 + while time.monotonic() < deadline: + if path.exists() and path.read_bytes() == expected: + return + time.sleep(.05) + raise AssertionError('detached write not independently observed') + + +def exercise(): + for size in (0, 256 * 1024 + 13, 50 * 1024 * 1024): + data = (bytes(range(256)) * (size // 256 + 1))[:size] + start = time.monotonic() + result = request('binary', data) + assert result == {'write': {'size_bytes': size, 'committed': True}}, result + assert (workspace / 'binary').read_bytes() == data + observations.append(dict(size=size, seconds=time.monotonic()-start, + sha256=hashlib.sha256(data).hexdigest(), response=result)) + original = local / 'staging' / 'original' + original.write_bytes(b'original') + os.link(original, workspace / 'linked') + assert request('linked', b'replacement') == {'write': {'size_bytes': 11, 'committed': True}} + assert original.read_bytes() == b'original' + assert (workspace / 'linked').read_bytes() == b'replacement' + assert original.stat().st_ino != (workspace / 'linked').stat().st_ino + for path in ('../outside', '/etc/escape', 'a//b'): + assert request(path, b'') == {'error': 'invalid_path'} + assert request('oversized', b'', size=50 * 1024 * 1024 + 1) == {'error': 'invalid_request'} + request('incomplete', b'x', size=3, detach=True) + # The next serial request establishes completion of pre-admission handling. + assert request('after-incomplete', b'') == {'write': {'size_bytes': 0, 'committed': True}} + assert not (workspace / 'incomplete').exists() + data = bytes(range(256)) * 4096 + request('detached', data, detach=True) + wait_file(workspace / 'detached', data) + assert request('after-detach', b'') == {'write': {'size_bytes': 0, 'committed': True}} + assert sorted(p.name for p in (local / 'staging').iterdir()) == ['original'] + + +for read_only in (False, True): + args = [artifact] + if read_only: + args.append('--workspace-read-only') + args += ['app-server', '--stdio'] + with (root / ('read-only.stderr' if read_only else 'write.stderr')).open('wb') as log: + process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) + try: + rpc(process, 1, 'initialize', {'clientInfo': {'name': 'write_probe', 'version': '1'}, 'capabilities': {'experimentalApi': True}}) + process.stdin.write(b'{"method":"initialized"}\n') + process.stdin.flush() + rpc(process, 2, 'environment/info', {'environmentId': 'remote'}) + if read_only: + assert request('read-only-denied', b'') == {'error': 'unsupported'} + assert not (workspace / 'read-only-denied').exists() + else: + exercise() + process.stdin.close() + assert process.wait(timeout=10) == 0 + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + assert not ipc.exists() + +(root / 'write-native.json').write_text(json.dumps(dict( + artifact_sha256=hashlib.sha256(Path(artifact).read_bytes()).hexdigest(), + synthetic=True, observations=observations, + hard_link_preserved=True, incomplete_not_dispatched=True, + caller_detach_retained=True, read_only_denied=True, + public_admission=False), indent=2) + '\n') diff --git a/services/agents-api/tests/official_agent_delete.py b/services/agents-api/tests/official_agent_delete.py new file mode 100644 index 000000000..98300a681 --- /dev/null +++ b/services/agents-api/tests/official_agent_delete.py @@ -0,0 +1,76 @@ +"""Public saved-Agent deletion with the fixed SDK and real PostgreSQL.""" + +import sys +import uuid + +import httpx2 +from openai import NotFoundError, OpenAI + + +def absent(action): + try: + action() + except NotFoundError: + return + raise AssertionError("expected local not-found behavior") + + +def main(): + base, token, foreign, restarted = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=10) as http: + client = OpenAI(api_key=token, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + other = OpenAI(api_key=foreign, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + agents, sessions = client.beta.agents, client.beta.agents.sessions + original = agents.create(model="saved-model", instructions="Saved instructions.", metadata={"source": "only"}) + peer = agents.create(model="peer-model", name="Unaffected peer") + foreign_agent = other.beta.agents.create(model="foreign-model") + spec = {"agent_id": original.id, "environment": {"type": "none"}, "metadata": {"original": "session"}} + retry = {"Idempotency-Key": "saved-before-delete"} + accepted = sessions.create(**spec, extra_headers=retry) + current = sessions.update(accepted.id, metadata={"current": "session"}) + endpoint = base + "/v1/agents/" + original.id + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + # Rejected requests must not remove any resource. + assert http.delete(endpoint, headers=headers | {"Authorization": "Bearer " + foreign}).status_code == 404 + assert http.delete(base + "/v1/agents/" + foreign_agent.id, headers=headers).status_code == 404 + assert http.delete(endpoint, headers={"Authorization": "Bearer " + token}).status_code == 400 + assert http.delete(endpoint, headers={"OpenAI-Beta": "agents=v1"}).status_code == 401 + assert http.delete(endpoint + "?cascade=true", headers=headers).status_code == 400 + assert http.request("DELETE", endpoint, headers=headers, json={"cascade": True}).status_code == 400 + assert agents.retrieve(original.id) == original + assert sessions.retrieve(current.id) == current + # The result carries the stored canonical ID, independently of path spelling. + raw = agents.with_raw_response.delete(original.id.upper()) + expected = {"id": original.id, "object": "agent.deleted", "deleted": True} + assert raw.status_code == 200 and raw.http_response.json() == expected + assert raw.parse().to_dict() == expected + absent(lambda: agents.retrieve(original.id)) + absent(lambda: agents.update(original.id, name="cannot resurrect")) + absent(lambda: agents.delete(original.id)) + for missing in (str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): + absent(lambda: agents.delete(missing)) + assert [a.id for a in agents.list()] == [peer.id] + assert agents.retrieve(peer.id) == peer + assert other.beta.agents.retrieve(foreign_agent.id) == foreign_agent + assert sessions.retrieve(current.id) == current + assert sessions.create(**spec, extra_headers=retry) == current + absent(lambda: sessions.create(**spec)) + assert {s.id for s in sessions.list()} == {current.id} + assert list(sessions.items.list(current.id)) == [] + assert list(sessions.turns.list(current.id)) == [] + recovered = OpenAI(api_key=token, base_url=restarted + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + absent(lambda: recovered.beta.agents.retrieve(original.id)) + assert recovered.beta.agents.retrieve(peer.id) == peer + assert recovered.beta.agents.sessions.retrieve(current.id) == current + assert recovered.beta.agents.sessions.create(**spec, extra_headers=retry) == current + assert agents.delete(peer.id).to_dict() == {"id": peer.id, "object": "agent.deleted", "deleted": True} + assert list(agents.list()) == [] + assert other.beta.agents.retrieve(foreign_agent.id) == foreign_agent + print("Agent deletion: SDK/raw HTTP canonical response, tenant rejection, read/list persistence, untouched peers/Sessions and recorded retry recovery passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_agent_list.py b/services/agents-api/tests/official_agent_list.py new file mode 100644 index 000000000..032f11100 --- /dev/null +++ b/services/agents-api/tests/official_agent_list.py @@ -0,0 +1,52 @@ +"""Principal Agent list behavior against the pinned client and real HTTP.""" + +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_agent_list(client, other, invalid, saved, expect_error): + agents = client.beta.agents + expected = {agent.id: agent for agent in saved} + # The pinned SDK omits limit=None and empty after from the HTTP query. + assert agents.list(limit=None).data == agents.list().data + assert agents.list(after="").data == agents.list().data + asc = list(agents.list(limit=1, order="asc")) + desc = list(agents.list(limit=2)) + assert {agent.id for agent in asc} == set(expected) + assert [agent.id for agent in desc] == [agent.id for agent in reversed(asc)] + assert all(agent == expected[agent.id] for agent in asc) + assert list(agents.list(after=asc[-1].id, order="asc")) == [] + assert list(agents.list(after=desc[-1].id, order="desc")) == [] + assert agents.list(limit=101).data == desc + assert agents.list(limit=2**63 - 1).data == desc + assert other.beta.agents.list().data == [] + for cursor in (asc[0].id, str(uuid.uuid4())): + expect_error(NotFoundError, lambda: other.beta.agents.list(after=cursor)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.list()) + expect_error(BadRequestError, lambda: agents.list(extra_headers={"OpenAI-Beta": ""})) + with httpx2.Client(trust_env=False, timeout=10) as raw: + url = str(client.base_url).rstrip("/") + "/agents" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + first = raw.get(url, headers=headers, params={"limit": 2, "order": "asc"}).json() + assert set(first) == {"object", "data", "has_more", "first_id", "last_id"} + assert first["object"] == "list" and first["has_more"] is True + assert first["first_id"] == asc[0].id and first["last_id"] == asc[1].id + assert first["data"] == [raw.get(url + "/" + agent.id, headers=headers).json() for agent in asc[:2]] + next_page = raw.get(url, headers=headers, params={"after": first["last_id"], "limit": 2, "order": "asc"}).json() + assert [agent["id"] for agent in next_page["data"]] == [agent.id for agent in asc[2:4]] + empty = raw.get(url, headers=headers, params={"after": asc[-1].id, "order": "asc"}).json() + assert empty == {"object": "list", "data": [], "has_more": False, "first_id": None, "last_id": None} + default = raw.get(url, headers=headers).json() + assert len(default["data"]) == min(20, len(saved)) and default["has_more"] == (len(saved) > 20) + assert [agent["id"] for agent in default["data"]] == [agent.id for agent in desc[:20]] + for params in ({"limit": "0"}, {"limit": "-1"}, {"limit": "1.5"}, {"limit": "null"}, + {"limit": ""}, {"limit": str(2**63)}, {"order": "newest"}, {"after": "invalid-id"}, + {"tenant_id": "other"}, [("limit", "1"), ("limit", "2")]): + response = raw.get(url, headers=headers, params=params) + assert response.status_code == 400, (params, response.status_code) + assert response.json()["error"]["type"] == "invalid_request_error" + assert raw.get(url).status_code == 401 + print("Agent list: fixed SDK auto-pagination/raw HTTP, order/cursors, snapshots, isolation and local limit/envelope behavior passed; exact upstream defaults/caps/errors remain unverified.") + return [agent.id for agent in asc] diff --git a/services/agents-api/tests/official_agent_reference_retry.py b/services/agents-api/tests/official_agent_reference_retry.py new file mode 100644 index 000000000..1e998debd --- /dev/null +++ b/services/agents-api/tests/official_agent_reference_retry.py @@ -0,0 +1,92 @@ +"""Saved-reference retry identity using real PostgreSQL and controlled source mutations.""" + +import concurrent.futures +import json +import sys +import time + +import httpx2 +from openai import OpenAI, ConflictError, NotFoundError, BadRequestError + + +def expect(kind, action): + try: + action() + except kind: + return + raise AssertionError(f"expected {kind.__name__}") + + +def main(): + base, token, foreign, control, restarted = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=15) as transport: + client = OpenAI(api_key=token, base_url=base + "/v1", http_client=transport, max_retries=0) + other = OpenAI(api_key=foreign, base_url=base + "/v1", http_client=transport, max_retries=0) + sessions = client.beta.agents.sessions + agent = client.beta.agents.create(model="original-model", instructions="frozen", text={"verbosity":"medium"}) + spec = {"agent_id":agent.id, "environment":{"type":"none"}, "input":"initial"} + headers = {"Idempotency-Key":"source-independent"} + first = sessions.create(**spec, extra_headers=headers) + initial_items = [item.id for item in sessions.items.list(first.id)] + initial_turns = [turn.id for turn in sessions.turns.list(first.id)] + assert len(initial_items) == len(initial_turns) == 1 + + def mutate(**values): + response = transport.post(control, json={"id":agent.id, **values}) + assert response.status_code == 204 + + mutate(patch={"model":"changed-model", "instructions":"changed"}) + assert sessions.create(**spec, extra_headers=headers) == first + fresh = sessions.create(**spec) + assert fresh.agent.model == "changed-model" and fresh.agent.instructions == "changed" + assert first.agent.model == "original-model" and first.agent.instructions == "frozen" + mutate(patch={"service_tier":"priority"}) + expect(BadRequestError, lambda:sessions.create(**spec)) + assert sessions.create(**spec, extra_headers=headers) == first + for changed in ({"input":"different"}, {"metadata":{"changed":"yes"}}, {"agent":{"instructions":"frozen"}}, {"agent_id":fresh.id}): + expect(ConflictError, lambda:sessions.create(**(spec | changed), extra_headers=headers)) + mutate(delete=True) + expect(NotFoundError, lambda:sessions.create(**spec)) + expect(NotFoundError, lambda:other.beta.agents.sessions.create(**spec, extra_headers=headers)) + updated = sessions.update(first.id, metadata={"current":"retained"}) + assert sessions.create(**spec, metadata=None, stream=False, extra_headers=headers) == updated + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as workers: + retries = list(workers.map(lambda _:sessions.create(**spec, extra_headers=headers), range(16))) + assert all(result == updated for result in retries) + assert [item.id for item in sessions.items.list(first.id)] == initial_items + assert [turn.id for turn in sessions.turns.list(first.id)] == initial_turns + restarted_client = OpenAI(api_key=token, base_url=restarted + "/v1", http_client=transport, max_retries=0) + assert restarted_client.beta.agents.sessions.create(**spec, extra_headers=headers) == updated + + auth = {"Authorization":"Bearer " + token,"OpenAI-Beta":"agents=v1", **headers} + endpoint = base + "/v1/agents/sessions" + # Wire validation still precedes lookup, and string/array input normalization is stable. + for bad in ({"stream":None}, {"agent_id":None}, {"metadata":{"bad":None}}): + assert transport.post(endpoint, headers=auth, json=spec | bad).status_code == 400 + equivalent = spec | {"input":[{"role":"user","content":[{"type":"input_text","text":"initial"}]}], "metadata":{}, "stream":False} + assert transport.post(endpoint, headers=auth, json=equivalent).json()["id"] == first.id + with transport.stream("POST", endpoint, headers=auth, json=spec | {"stream":True}) as stream: + assert stream.status_code == 200 + sessions.events.create(first.id, events=[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"future-after-retry"}]}]}]) + found = False + deadline = time.monotonic() + 15 + for line in stream.iter_lines(): + assert time.monotonic() < deadline, "future event was not observed" + if not line.startswith("data:"): + continue + event = json.loads(line[5:]) + assert event["type"] != "agent.session.created" + if event["type"] == "agent.session.turn.item.added": + text = json.dumps(event) + assert "initial" not in text, "retry replayed initial work" + if "future-after-retry" in text: + found = True + break + assert found + assert len(list(sessions.turns.list(first.id))) == 1 + assert len(list(sessions.items.list(first.id))) == 2 + print("Saved-reference retries: SDK/raw HTTP mutation/deletion, concurrent recovery, initial-input-once, current metadata, restart and future-only SSE passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_agent_references.py b/services/agents-api/tests/official_agent_references.py new file mode 100644 index 000000000..a8f630e26 --- /dev/null +++ b/services/agents-api/tests/official_agent_references.py @@ -0,0 +1,97 @@ +"""Saved-Agent Session references through the pinned SDK and real database.""" + +import uuid + +import httpx2 +from openai import BadRequestError, ConflictError, NotFoundError + + +def verify_agent_references(client, other, expect_error): + agents, sessions = client.beta.agents, client.beta.agents.sessions + tool = {"type": "function", "name": "lookup", "description": "Return a value.", + "parameters": {"type": "object", "properties": {"value": {"const": 9007199254740993}}}} + resource = agents.create(model=" requested-model ", name="Reusable configuration", + instructions="Saved instructions.", metadata={"business": "not-session-metadata"}, + text={"verbosity": "high"}, tools=[tool]) + spec = {"agent_id": resource.id, "environment": {"type": "none"}} + headers = {"Idempotency-Key": "saved-agent-reference"} + first = sessions.create(**spec, extra_headers=headers) + expected = resource.to_dict(mode="json") + for field in ("object", "metadata", "created_at", "updated_at"): + expected.pop(field) + assert first.agent.to_dict(mode="json") == expected + assert first.metadata == {} and first.id != resource.id + assert first.agent.id == resource.id + assert sessions.create(**spec, extra_headers=headers) == first + sibling = sessions.create(**spec) + assert sibling.id != first.id and sibling.agent == first.agent + recovered = [first, sibling] + + # Whole-field replacement: an object containing only format resets verbosity + # to its known default instead of merging the saved high value. + for override in ({}, {"model": " override-model "}, {"instructions": ""}, + {"instructions": None}, {"tools": None}, {"tools": []}, + {"tools": [dict(tool, name="replacement")]}, + {"text": {"format": {"type": "text"}}}, {"text": None}): + item = sessions.create(**spec, agent=override) + assert item.agent.id == resource.id and item.agent.name == resource.name + assert item.agent.model == override.get("model", resource.model) + assert item.agent.instructions == override.get("instructions", resource.instructions) + if "text" in override: + assert item.agent.text.verbosity == "medium" + else: + assert item.agent.text == resource.text + if "tools" in override: + assert [t.name for t in item.agent.tools] == [t["name"] for t in (override["tools"] or [])] + else: + assert [t.to_dict(mode="json") for t in item.agent.tools] == [t.to_dict(mode="json") for t in resource.tools] + recovered.append(item) + assert agents.retrieve(resource.id) == resource + assert sessions.retrieve(first.id) == first + expect_error(ConflictError, lambda: sessions.create(**spec, agent={"instructions": "Changed"}, extra_headers=headers)) + same_config = agents.create(model=resource.model, name=resource.name, instructions=resource.instructions, + text={"verbosity": "high"}, tools=[tool]) + expect_error(ConflictError, lambda: sessions.create(agent_id=same_config.id, environment={"type": "none"}, extra_headers=headers)) + expect_error(NotFoundError, lambda: other.beta.agents.sessions.create(**spec)) + for missing in (str(uuid.uuid4()), "not-an-agent", str(uuid.UUID(int=0))): + expect_error(NotFoundError, lambda: sessions.create(agent_id=missing, environment={"type": "none"})) + + # Configuration storage is broader than execution. Never silently drop an + # unsupported saved option, but admit a supported whole-field replacement. + for field, value, replacement in ( + ("reasoning", {"effort": "high"}, None), + ("reasoning", {"summary": "auto"}, {}), + ("service_tier", "fast", "auto"), + ("multi_agent", {"enabled": True}, {"enabled": False}), + ("text", {"format": {"type": "json_schema", "schema": {"type": "object"}}}, {"verbosity": "medium"}), + ("tools", [dict(tool, defer_loading=True)], None), + ("tools", [{"type": "tool_search"}], []), + ("tools", [{"type": "programmatic_tool_calling", "enabled": False}], []), + ): + unsupported = agents.create(model="model", **{field: value}) + reference = {"agent_id": unsupported.id, "environment": {"type": "none"}} + expect_error(BadRequestError, lambda: sessions.create(**reference)) + recovered.append(sessions.create(**reference, agent={field: replacement})) + expect_error(BadRequestError, lambda: sessions.create(agent={"model": "model", field: value}, environment={"type": "none"})) + assert agents.retrieve(unsupported.id) == unsupported + + base = str(client.base_url).rstrip("/") + "/agents/sessions" + auth = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + body = raw.post(base, headers=auth, json=spec) + assert body.status_code == 200 and body.json()["agent"] == expected + recovered.append(sessions.retrieve(body.json()["id"])) + before = {item.id for item in sessions.list()} + for override in (None, [], {"model": None}, {"model": 1}, {"model": ""}, + {"name": "not-a-session-override"}, {"reasoning": {"unknown": True}}, + {"tools": [{"type": "function", "name": "x", "description": "", "parameters": {}, "unknown": True}]}, + {"text": {"format": {"type": "text", "unknown": True}}}): + response = raw.post(base, headers=auth, json={**spec, "agent": override}) + assert response.status_code == 400, (override, response.status_code) + assert {item.id for item in sessions.list()} == before, "rejected configuration created a Session" + # Metadata updates must not change the creation identity or copied config. + updated = sessions.update(first.id, metadata={"current": "value"}) + assert sessions.create(**spec, extra_headers=headers) == updated + recovered[0] = updated + print("Saved-Agent Session references: SDK/raw HTTP inheritance, whole-field replacement, isolation, immutable snapshots and retries passed; unsupported execution settings rejected.") + return recovered, (spec, headers, updated) diff --git a/services/agents-api/tests/official_agent_update.py b/services/agents-api/tests/official_agent_update.py new file mode 100644 index 000000000..fb62e09dd --- /dev/null +++ b/services/agents-api/tests/official_agent_update.py @@ -0,0 +1,102 @@ +"""Public Agent update main flows against fixed SDK and real PostgreSQL.""" + +import concurrent.futures +import sys +import uuid + +import httpx2 +from openai import OpenAI + + +def main(): + base, token, foreign, restarted = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=10) as http: + client = OpenAI(api_key=token, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + agents, sessions = client.beta.agents, client.beta.agents.sessions + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + tool = {"type": "function", "name": "lookup", "description": "Read a value.", + "parameters": {"type": "object", "properties": {"value": {"const": 9007199254740993}}}} + original = agents.create(model="original-model", name="Original", instructions="Keep original.", + metadata={"old": "value"}, tools=[tool]) + endpoint = base + "/v1/agents/" + original.id + spec = {"agent_id": original.id, "environment": {"type": "none"}} + retry = {"Idempotency-Key": "before-agent-update"} + old = sessions.create(**spec, extra_headers=retry) + updated = agents.update(original.id, instructions="Use updated instructions.", + name="更新", model=" updated-model ", metadata={"new": "value"}) + assert updated.id == original.id and updated.created_at == original.created_at + assert updated.updated_at >= original.updated_at + assert updated.model == " updated-model " and updated.name == "更新" + assert updated.instructions == "Use updated instructions." and updated.metadata == {"new": "value"} + assert updated.tools == original.tools and updated.text == original.text + assert updated.reasoning == original.reasoning and updated.multi_agent == original.multi_agent + assert agents.retrieve(original.id) == updated + assert next(a for a in agents.list() if a.id == original.id) == updated + assert sessions.retrieve(old.id) == old and sessions.create(**spec, extra_headers=retry) == old + fresh = sessions.create(**spec) + assert fresh.id != old.id and fresh.agent.instructions == updated.instructions + assert fresh.agent.model == updated.model + assert [t.to_dict() for t in fresh.agent.tools] == [t.to_dict() for t in updated.tools] + assert fresh.metadata == {} and old.agent.instructions == original.instructions + # A request without fields is a local no-op, including its update timestamp. + assert agents.update(original.id) == updated + for body in (None, [], {"model": None}, {"model": 3}, {"name": "x" * 129}, + {"metadata": {"bad": None}}, {"text": {"unexpected": True}}, + {"metadata": {"replace": "no"}, "instructions": False}, + {"updated_at": 1}, {"tools": [{"type": "unknown"}]}): + response = http.post(endpoint, headers=headers, content="null" if body is None else None, + json=body if body is not None else None) + assert response.status_code == 400, (body, response.status_code, response.text) + assert agents.retrieve(original.id) == updated + for target in (original.id, str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): + response = http.post(base + "/v1/agents/" + target, + headers=headers | {"Authorization": "Bearer " + foreign}, json={"name": "foreign"}) + assert response.status_code == 404 + assert http.post(endpoint + "?unknown=1", headers=headers, json={}).status_code == 400 + assert http.post(endpoint, headers={"Authorization": "Bearer " + token}, json={}).status_code == 400 + assert http.post(endpoint, headers={"OpenAI-Beta": "agents=v1"}, json={}).status_code == 401 + assert agents.retrieve(original.id) == updated + + configured = agents.update(original.id, multi_agent={"enabled": True, "max_concurrent_subagents": 3}, + reasoning={"effort": "high", "summary": "auto"}, service_tier="priority", + text={"verbosity": "high", "format": {"type": "json_schema", "schema": {"const": 9007199254740993}}}) + assert configured.multi_agent.max_concurrent_subagents == 3 + assert configured.text.format.to_dict()["schema"]["const"] == 9007199254740993 + assert http.get(endpoint, headers=headers).json()["tools"][0]["parameters"]["properties"]["value"]["const"] == 9007199254740993 + # Local nested replacement/default policy remains explicitly subject to hosted comparison. + replaced = agents.update(original.id, text={"format": {"type": "text"}}, reasoning={"summary": "detailed"}, + metadata={"only": "this"}, tools=[]) + assert replaced.text.verbosity == "medium" and replaced.reasoning.effort is None + assert replaced.reasoning.summary == "detailed" and replaced.metadata == {"only": "this"} + assert replaced.tools == [] and replaced.multi_agent == configured.multi_agent + cleared = agents.update(original.id, name=None, instructions=None, metadata=None, multi_agent=None, + reasoning=None, service_tier=None, text=None, tools=None) + assert cleared.name is None and cleared.instructions is None and cleared.metadata == {} + assert not cleared.multi_agent.enabled and cleared.multi_agent.max_concurrent_subagents is None + assert cleared.reasoning.effort is None and cleared.reasoning.summary is None + assert cleared.service_tier == "auto" and cleared.text.verbosity == "medium" and cleared.tools == [] + assert cleared.model == updated.model + assert agents.update(original.id, metadata={}).metadata == {} + + # Different fields written concurrently must all survive; each metadata map is a replacement. + changes = [{"name": "concurrent-name"}, {"instructions": "concurrent-instructions"}, + {"model": "concurrent-model"}, {"metadata": {"concurrent": "metadata"}}, + {"text": {"verbosity": "high"}}] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(changes)) as pool: + results = list(pool.map(lambda patch: http.post(endpoint, headers=headers, json=patch), changes)) + assert all(r.status_code == 200 for r in results) + current = agents.retrieve(original.id) + assert current.name == "concurrent-name" and current.instructions == "concurrent-instructions" + assert current.model == "concurrent-model" and current.metadata == {"concurrent": "metadata"} + assert current.text.verbosity == "high" and current.tools == [] + recovered = OpenAI(api_key=token, base_url=restarted + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + assert recovered.beta.agents.retrieve(original.id) == current + assert recovered.beta.agents.sessions.retrieve(old.id) == old + assert recovered.beta.agents.sessions.create(**spec, extra_headers=retry) == old + print("Agent update: SDK/raw HTTP, omission/null/replacement, isolation, concurrent fields, old/new Sessions and recreated handler/store passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py new file mode 100644 index 000000000..99acba024 --- /dev/null +++ b/services/agents-api/tests/official_agents.py @@ -0,0 +1,117 @@ +"""Reusable Agent resource checks against the fixed SDK and real HTTP service.""" + +import time +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_agents(client, other, invalid, expect_error): + agents = client.beta.agents + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + base = str(client.base_url).rstrip("/") + "/agents" + saved = [] + with httpx2.Client(trust_env=False, timeout=10) as raw: + # These assertions cover known resource defaults; they do not assert a + # model-derived reasoning default that the service cannot yet resolve. + for values in ({}, {"name": None, "instructions": None, "metadata": None, + "multi_agent": None, "reasoning": None, + "service_tier": None, "text": None, "tools": None}): + response = agents.with_raw_response.create(model=" caller-model ", **values) + body, agent = response.http_response.json(), response.parse() + assert set(body) == {"id", "object", "created_at", "updated_at", "metadata", "model", "name", + "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools"} + assert body["object"] == "agent" and body["model"] == " caller-model " + assert body["name"] is None and body["instructions"] is None and body["metadata"] == {} + assert body["multi_agent"] == {"enabled": False, "max_concurrent_subagents": None} + assert body["text"] == {"format": {"type": "text"}, "verbosity": "medium"} + assert body["tools"] == [] + assert agent.created_at == agent.updated_at and abs(agent.created_at - time.time()) < 10 + assert agents.retrieve(agent.id) == agent + saved.append(agent) + schema = {"type": "object", "properties": {"number": {"const": 9007199254740993}}} + tools = [{"type": "function", "name": "lookup", "description": "", "parameters": schema, + "defer_loading": True}, {"type": "tool_search"}, {"type": "programmatic_tool_calling"}] + metadata = {"empty": "", "🧪" * 64: "值" * 512} + request = {"model": "arbitrary-provider-model", "name": " ", "instructions": " preserve whitespace ", + "metadata": metadata, "multi_agent": {"enabled": True}, + "reasoning": {"effort": "max", "summary": "detailed"}, "service_tier": "fast", + "text": {"format": {"type": "json_schema", "schema": schema}, "verbosity": "high"}, + "tools": tools} + response = agents.with_raw_response.create(**request) + body, agent = response.http_response.json(), response.parse() + for field in ("model", "name", "instructions", "metadata", "reasoning", "service_tier", "text"): + assert body[field] == request[field], field + assert body["multi_agent"] == {"enabled": True, "max_concurrent_subagents": 6} + assert body["tools"] == [*tools[:2], {"type": "programmatic_tool_calling", "enabled": True}] + assert raw.get(base + "/" + agent.id, headers=headers).json() == body + assert agents.retrieve(agent.id) == agent + saved.append(agent) + for fields in [ + {"model": "", "name": "🧪" * 128, "instructions": "", "metadata": {}}, + {"multi_agent": {"enabled": True, "max_concurrent_subagents": 4294967295}}, + {"multi_agent": {"enabled": False, "max_concurrent_subagents": 4}}, + {"text": {"format": None, "verbosity": None}}, + {"tools": [{"type": "function", "name": "", "description": "", "parameters": {}}]}, + {"tools": [{"type": "programmatic_tool_calling", "enabled": False}]}, + ]: + item = agents.create(**{"model": "resource-model", **fields}) + assert agents.retrieve(item.id) == item + saved.append(item) + assert saved[-1].tools[0].enabled is False + assert saved[-2].tools[0].defer_loading is False + assert saved[-4].multi_agent.max_concurrent_subagents is None + for tier in ("auto", "default", "flex", "priority", "fast"): + item = agents.create(model="resource-model", service_tier=tier) + assert item.service_tier == tier + saved.append(item) + for effort in ("none", "minimal", "low", "medium", "high", "xhigh", "max"): + item = agents.create(model="resource-model", reasoning={"effort": effort}) + assert item.reasoning.effort == effort + saved.append(item) + + invalid_fields = [ + {"model": None}, {"model": 4}, {"name": "x" * 129}, {"instructions": False}, + {"metadata": {"bad": None}}, {"metadata": {"x" * 65: "v"}}, + {"metadata": {str(i): "v" for i in range(17)}}, {"metadata": {"x": "v" * 513}}, + {"tenant_id": str(uuid.uuid4())}, {"reasoning": {"effort": "automatic"}}, + {"reasoning": {"summary": "never"}}, {"reasoning": {"unknown": 1}}, + {"service_tier": "premium"}, {"multi_agent": {}}, {"multi_agent": {"enabled": None}}, + {"multi_agent": {"enabled": True, "max_concurrent_subagents": None}}, + {"multi_agent": {"enabled": False, "max_concurrent_subagents": 0}}, + {"multi_agent": {"enabled": True, "max_concurrent_subagents": 1.5}}, + {"multi_agent": {"enabled": True, "max_concurrent_subagents": 4294967296}}, + {"text": {"format": {}}}, {"text": {"format": {"type": None}}}, + {"text": {"format": {"type": "text", "schema": {}}}}, + {"text": {"format": {"type": "json_schema", "schema": None}}}, + {"text": {"format": {"type": "json_schema", "schema": []}}}, + {"text": {"verbosity": "automatic"}}, {"text": {"unknown": True}}, + {"tools": [None]}, {"tools": [{"type": "function", "name": None}]}, + {"tools": [{"type": "function", "name": "x", "description": "", "parameters": None}]}, + {"tools": [{"type": "function", "name": "x", "description": "", "parameters": {}, "defer_loading": None}]}, + {"tools": [{"type": "programmatic_tool_calling", "enabled": None}]}, + {"tools": [{"type": "tool_search", "unknown": 1}]}, + ] + for fields in invalid_fields: + response = raw.post(base, headers=headers, json={"model": "resource-model", **fields}) + assert response.status_code == 400, (fields, response.status_code) + assert response.json()["error"]["type"] == "invalid_request_error" + expect_error(BadRequestError, lambda: agents.create(model="resource-model", extra_body=fields)) + for content in ("{}", "null", "[]", '{"model":"x"} {}'): + assert raw.post(base, headers=headers, content=content).status_code == 400 + assert raw.post(base, headers=headers, content='{"model":"' + "x" * (1024 * 1024) + '"}').status_code == 413 + assert raw.post(base, headers=headers, params={"tenant_id": "other"}, json={"model": "x"}).status_code == 400 + assert raw.get(base + "/" + saved[0].id, headers=headers, params={"tenant_id": "other"}).status_code == 400 + for resource_id in (str(uuid.uuid4()), "unrecognized-agent", str(uuid.UUID(int=0))): + expect_error(NotFoundError, lambda: agents.retrieve(resource_id)) + expect_error(NotFoundError, lambda: other.beta.agents.retrieve(saved[0].id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.retrieve(saved[0].id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.create(model="x")) + expect_error(BadRequestError, lambda: agents.create(model="x", extra_headers={"OpenAI-Beta": ""})) + assert raw.post(base, json={"model": "x"}).status_code == 401 + # Unsupported families are explicit gaps, not schema-conformance evidence. + for tool in ({"type": "web_search"}, {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}}): + expect_error(BadRequestError, lambda: agents.create(model="x", tools=[tool])) + print("Reusable Agents: fixed SDK/raw HTTP create/retrieve, explicit configuration, known defaults, isolation and validation passed; model defaults/MCP/web_search/retry semantics remain gaps.") + return saved diff --git a/services/agents-api/tests/official_auth.py b/services/agents-api/tests/official_auth.py new file mode 100644 index 000000000..774672acc --- /dev/null +++ b/services/agents-api/tests/official_auth.py @@ -0,0 +1,55 @@ +"""Exercise caller scope through the pinned SDK, raw HTTP and service restart.""" + +import json +import subprocess +import uuid + +import httpx2 as httpx +from openai import AuthenticationError + + +def verify_caller_principals(client, base, binding, token, rotated, peer, session): + scope = {"organization": binding["organization_id"], "project": binding["project_id"]} + for key in (token, rotated, peer): + with client(key, **scope) as scoped: + assert scoped.beta.agents.sessions.retrieve(session.id) == session + assert session.id in {item.id for item in scoped.beta.agents.sessions.list()} + for mismatch in ({**scope, "organization": "wrong-org"}, {**scope, "project": "wrong-project"}): + with client(token, **mismatch) as invalid: + try: + invalid.beta.agents.sessions.retrieve(session.id) + except AuthenticationError as error: + assert error.body["code"] == "invalid_api_key" + else: + raise AssertionError("Untrusted scope header was accepted") + headers = [("Authorization", "Bearer " + token), ("OpenAI-Beta", "agents=v1")] + path = base + "/v1/agents/sessions/" + session.id + with httpx.Client(trust_env=False, timeout=10) as raw: + for extra in ([('OpenAI-Project', binding['project_id'])] * 2, + [('OpenAI-Organization', binding['organization_id']), ('OpenAI-Organization', 'other')], + [('Authorization', 'Bearer ' + peer)]): + response = raw.get(path, headers=headers + extra) + assert response.status_code == 401 and response.json()["error"]["code"] == "invalid_api_key" + response = raw.get(path, headers=headers + [("X-Tenant-ID", str(uuid.uuid4())), ("X-User-ID", "forged")]) + assert response.status_code == 200 and response.json()["id"] == session.id + + +def verify_scope_bootstrap(binary, env, keys_file, bindings, log): + # The previous process must be stopped before checking persisted configuration. + for name in ("organization_id", "project_id", "tenant_id"): + changed = [{**binding, name: str(uuid.uuid4())} if binding["tenant_id"] == bindings[0]["tenant_id"] else binding + for binding in bindings] + # Keep same-project caller entries internally consistent, so PostgreSQL is the authority. + replacement = str(uuid.uuid4()) + for old, new in zip(bindings, changed): + if old["tenant_id"] == bindings[0]["tenant_id"]: + new[name] = replacement + keys_file.write_text(json.dumps(changed)) + process = subprocess.Popen([binary], env=env, stdout=log, stderr=log) + try: + assert process.wait(timeout=20) != 0, "Persisted scope remapping started successfully" + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=15) + keys_file.write_text(json.dumps(bindings)) diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py new file mode 100644 index 000000000..67edc350c --- /dev/null +++ b/services/agents-api/tests/official_client.py @@ -0,0 +1,322 @@ +"""Exercise the real service and PostgreSQL with the pinned official Python SDK.""" + +import base64 +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import secrets +import socket +import subprocess +import tempfile +import time +from urllib.parse import parse_qs, urlsplit +import uuid +import sys + +sys.dont_write_bytecode = True + +import httpx2 +from jsonschema import Draft4Validator +from official_items import verify_items +from official_agents import verify_agents +from official_vaults import verify_vaults, verify_vault_recovery +from official_vault_list import verify_vault_list, verify_vault_list_recovery +from official_credentials import verify_credentials, verify_credential_recovery, verify_credential_storage_disabled +from official_credential_list import verify_credential_list, verify_credential_list_recovery +from official_mcp_credentials import verify_mcp_credentials, verify_mcp_credential_recovery +from official_credential_rotation import verify_credential_rotation, verify_rotation_recovery +from official_credential_delete import verify_credential_deletion, verify_credential_deletion_recovery, verify_keyless_credential_deletion +from official_vault_delete import verify_vault_deletion, verify_vault_deletion_recovery, verify_keyless_vault_deletion +from official_agent_list import verify_agent_list +from official_agent_references import verify_agent_references +from official_session_requests import verify_session_create_requests +from official_session_metadata import verify_session_metadata, verify_active_session_metadata +from official_session_creators import verify_session_creators, verify_creator_recovery +from official_source_file_list import verify_source_file_list, verify_source_file_list_recovery +from openai import AuthenticationError, BadRequestError, ConflictError, InternalServerError, NotFoundError, OpenAI +import yaml + + +def nullable_schema(value): + """Translate Swagger 2's nullable extension for JSON Schema validation.""" + if isinstance(value, list): + return [nullable_schema(item) for item in value] + if not isinstance(value, dict): + return value + converted = {key: nullable_schema(item) for key, item in value.items() if key != "x-nullable"} + return {"anyOf": [{"type": "null"}, converted]} if value.get("x-nullable") else converted + + +def main(): + root = Path(__file__).resolve().parents[3] + contract = nullable_schema(yaml.safe_load((root / "contracts/agents-api/openapi.yaml").read_text())) + + def validate_response(response): + response.read() + path = response.request.url.path.removeprefix("/v1") + if path.startswith("/agents/sessions/"): + suffix = path.split("/")[4:] + path = "/agents/sessions/{session_id}" + if suffix and suffix[0] == "turns": + path += "/turns" + ("/{turn_id}" if len(suffix) > 1 else "") + elif suffix and suffix[0] == "items": + path += "/items" + elif path.startswith("/vaults/"): + suffix = path.split("/")[3:] + path = "/vaults/{vault_id}" + if suffix and suffix[0] == "credentials": + path += "/credentials" + ("/{credential_id}" if len(suffix) > 1 else "") + elif path.startswith("/agents/") and path != "/agents/sessions": + path = "/agents/{agent_id}" + elif path.startswith("/files/"): + path = "/files/{file_id}/content" if path.endswith("/content") else "/files/{file_id}" + schema = contract["paths"][path][response.request.method.lower()]["responses"][str(response.status_code)]["schema"] + Draft4Validator({"definitions": contract["definitions"], **schema}).validate(response.json()) + pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert source.get("vcs_info", {}).get("commit_id") == pin["commit"], "Install the pinned SDK commit first" + dsn = os.environ["PARSAR_AGENTS_API_TEST_DATABASE_URL"] + parts = urlsplit(dsn) + database = parse_qs(parts.query).get("dbname", [parts.path.lstrip("/")])[0] + assert parts.scheme in ("postgres", "postgresql") and database.startswith("parsar_agents_api_") and database.endswith("_tests"), "A dedicated execution test database is required" + binary = os.environ["AGENTS_API_SERVER_BIN"] + with socket.socket() as address: + address.bind(("127.0.0.1", 0)) + port = address.getsockname()[1] + base = f"http://127.0.0.1:{port}" + tokens = [secrets.token_hex(32) for _ in range(4)] + bindings = [{"tenant_id": str(uuid.uuid4()), "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "organization_id": "test-org", "project_id": str(uuid.uuid4()), + "subject_kind": "service_account", "subject_id": "test-runner"} for token in tokens] + same_principal, peer_principal, same_subject_id = [secrets.token_hex(32) for _ in range(3)] + bindings.extend([{**bindings[0], "token_sha256": hashlib.sha256(same_principal.encode()).hexdigest()}, + {**bindings[0], "token_sha256": hashlib.sha256(peer_principal.encode()).hexdigest(), + "subject_kind": "user", "subject_id": "test-peer"}, + {**bindings[0], "token_sha256": hashlib.sha256(same_subject_id.encode()).hexdigest(), + "subject_kind": "user"}]) + process = None + credential_canary = secrets.token_hex(32) + with tempfile.TemporaryDirectory(prefix="agents-api-test-") as directory: + keys = Path(directory) / "keys.json" + keys.write_text(json.dumps(bindings)) + keys.chmod(0o600) + credential_key = Path(directory) / "credential-key.txt" + credential_key.touch(mode=0o600) + credential_key.write_text(base64.b64encode(secrets.token_bytes(32)).decode() + "\n") + env = dict(os.environ, AGENTS_API_DATABASE_URL=dsn, AGENTS_API_KEYS_FILE=str(keys), AGENTS_API_ADDR=f"127.0.0.1:{port}", AGENTS_API_ENGINE="codex") + env["AGENTS_API_CREDENTIAL_KEY_FILE"] = str(credential_key) + with (Path(directory) / "server.log").open("w+") as log: + def start(): + child = subprocess.Popen([binary], env=env, stdout=log, stderr=log) + try: + deadline = time.monotonic() + 20 + with httpx2.Client(trust_env=False, timeout=1) as probe: + while time.monotonic() < deadline: + if child.poll() is not None: + raise AssertionError("Agents API exited during startup") + try: + if probe.get(base + "/healthz").status_code == 200: + return child + except httpx2.TransportError: + pass + time.sleep(0.1) + raise AssertionError("Agents API did not become healthy") + except BaseException: + child.terminate() + child.wait(timeout=15) + raise + + def client(token, **scope): + return OpenAI(api_key=token, **scope, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10, event_hooks={"response": [validate_response]})) + + def expect_error(error, operation): + try: + operation() + except error as result: + assert isinstance(result.body, dict) and result.body.get("code") + return result + else: + raise AssertionError(f"Expected {error.__name__}") + + try: + process = start() + with client(tokens[0]) as a, client(tokens[1]) as b, client("invalid-key") as invalid: + with client(peer_principal, organization=bindings[0]["organization_id"], + project=bindings[0]["project_id"]) as peer: + saved_vaults = verify_vaults(a, b, invalid, peer, bindings[0], expect_error) + saved_credentials = verify_credentials(a, b, invalid, peer, saved_vaults, credential_canary, expect_error) + listed_vaults = verify_vault_list(a, b, invalid, peer, bindings[0], saved_vaults, + root, directory, expect_error) + listed_credentials = verify_credential_list( + a, b, invalid, peer, bindings[0], saved_vaults, saved_credentials, + listed_vaults, root, directory, credential_canary, expect_error) + listed_files = verify_source_file_list(a, b, invalid, peer, expect_error) + saved_agents = verify_agents(a, b, invalid, expect_error) + listed_agents = verify_agent_list(a, b, invalid, saved_agents, expect_error) + sessions = a.beta.agents.sessions + spec = {"agent": {"model": "requested-test-model", "instructions": "Keep the configuration."}, "environment": {"type": "none"}} + headers = {"Idempotency-Key": "same-key"} + first = sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) + assert first.object == "agent.session" and first.status == "idle" + assert first.agent.model == spec["agent"]["model"] and first.agent.instructions == spec["agent"]["instructions"] + assert first.environment.type == "none" and first.required_actions == [] and first.vault_ids == [] + assert first.agent.tools == [] and first.agent.multi_agent.enabled is False + assert first.created_at == first.last_active_at and isinstance(first.created_at, int) + replay = sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) + assert replay == first + assert sessions.retrieve(first.id) == first + from official_auth import verify_caller_principals + verify_caller_principals(client, base, bindings[0], tokens[0], same_principal, peer_principal, first) + changed = {**spec, "agent": {**spec["agent"], "instructions": "Changed"}} + expect_error(ConflictError, lambda: sessions.create(**changed, metadata={"workspace": "untrusted-reference"}, extra_headers=headers)) + others = [sessions.create(**spec) for _ in range(2)] + expected = {first.id, *(item.id for item in others)} + asc = list(sessions.list(limit=1, order="asc")) + desc = list(sessions.list(limit=2, order="desc")) + assert {item.id for item in asc} == expected + assert [item.id for item in asc] == list(reversed([item.id for item in desc])) + assert list(sessions.list(after=asc[-1].id, order="asc")) == [] + other = b.beta.agents.sessions.create(**spec, extra_headers=headers) + assert other.id != first.id + assert [item.id for item in b.beta.agents.sessions.list()] == [other.id] + expect_error(NotFoundError, lambda: b.beta.agents.sessions.retrieve(first.id)) + expect_error(NotFoundError, lambda: b.beta.agents.sessions.list(after=first.id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.retrieve(first.id)) + expect_error(BadRequestError, lambda: sessions.retrieve(first.id, extra_headers={"OpenAI-Beta": ""})) + expect_error(BadRequestError, lambda: sessions.create(**spec, input=[{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/image.png"}]}])) + unavailable = expect_error(InternalServerError, lambda: sessions.create(agent=spec["agent"], environment={"type": "self_hosted", "workspace_directory": "/workspace"})) + assert unavailable.status_code == 503 and unavailable.body["code"] == "execution_unavailable" + expect_error(BadRequestError, lambda: sessions.create(**spec, extra_body={"tenant_id": bindings[1]["tenant_id"]})) + assert list(sessions.list(agent_id="unknown-agent")) == [] + assert list(sessions.list(agent_id=first.agent.id)) == [first] + assert list(b.beta.agents.sessions.list(agent_id=first.agent.id)) == [] + expect_error(BadRequestError, lambda: sessions.list(limit=0)) + assert {item.id for item in sessions.list()} == expected + metadata = {str(i): "🧪" * 512 for i in range(16)} + large = sessions.create(**spec, metadata=metadata) + assert sessions.retrieve(large.id).metadata == metadata + request_sessions = verify_session_create_requests(a, spec) + request_sessions.append(verify_session_metadata(a, b, invalid, spec, expect_error)) + turn_session = sessions.create(**spec) + fixture = Path(directory) / "turns.json" + fixture.write_text(json.dumps({"tenant": bindings[0]["tenant_id"], "session": turn_session.id})) + subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + env=dict(os.environ, AGENTS_API_TURN_FIXTURE=str(fixture)), check=True, timeout=120) + turn_ids = json.loads(fixture.read_text())["turns"] + turns = sessions.turns + recovered = list(turns.list(turn_session.id, limit=1, order="asc")) + assert [turn.id for turn in recovered] == turn_ids + assert [turn.status for turn in recovered] == ["completed", "failed", "cancelled", "in_progress"] + assert all(turn.agent_id == turn_session.agent.id and turn.session_id == turn_session.id for turn in recovered) + assert all(turn.started_at is not None for turn in recovered) + assert all(turn.completed_at is not None for turn in recovered[:3]) and recovered[-1].completed_at is None + assert recovered[1].error.code == "internal_error" and all(turn.error is None for turn in [recovered[0], *recovered[2:]]) + assert all(turn.usage is None for turn in recovered) + assert "SECRET" not in repr(recovered) and "PRIVATE" not in repr(recovered) + assert [turn.id for turn in turns.list(turn_session.id, limit=2)] == list(reversed(turn_ids)) + assert list(turns.list(turn_session.id, after=turn_ids[-1], order="asc")) == [] + assert list(turns.list(first.id)) == [] + assert turns.retrieve(turn_ids[0], session_id=turn_session.id) == recovered[0] + expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.list(turn_session.id)) + expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.retrieve(turn_ids[0], session_id=turn_session.id)) + expect_error(NotFoundError, lambda: turns.retrieve(turn_ids[0], session_id=first.id)) + expect_error(NotFoundError, lambda: turns.list(first.id, after=turn_ids[0])) + expect_error(BadRequestError, lambda: turns.list(turn_session.id, limit=101)) + saved_items = verify_items(a, b, invalid, turn_session.id, first.id, turn_ids, expect_error) + request_sessions.append(verify_active_session_metadata(a, turn_session.id)) + referenced, reference_retry = verify_agent_references(a, b, expect_error) + request_sessions.extend(referenced) + creator_retries = verify_session_creators( + client, a, b, same_principal, peer_principal, same_subject_id, spec, expect_error) + request_sessions.extend(result for _, _, result in creator_retries) + from official_mcp import verify_mcp_configuration + mcp_sessions, mcp_agents = verify_mcp_configuration(a, b, expect_error) + request_sessions.extend(mcp_sessions) + saved_agents.extend(mcp_agents) + with client(peer_principal) as peer: + mcp_credentials = verify_mcp_credentials(a, b, peer, credential_canary, expect_error) + credential_rotation = verify_credential_rotation( + a, b, invalid, peer, saved_vaults, saved_credentials, credential_canary, expect_error) + credential_deletion = verify_credential_deletion(a, b, invalid, peer, credential_canary, expect_error) + vault_deletion = verify_vault_deletion(a, b, invalid, peer, credential_canary, expect_error) + process.terminate() + process.wait(timeout=15) + process = start() + with client(peer_principal) as peer: + verify_vault_recovery(a, b, peer, saved_vaults) + verify_vault_list_recovery(a, b, peer, listed_vaults) + verify_credential_recovery(a, b, peer, saved_credentials) + verify_credential_list_recovery(a, b, peer, listed_credentials, credential_canary) + verify_source_file_list_recovery(a, b, peer, listed_files) + verify_mcp_credential_recovery(a, mcp_credentials) + verify_rotation_recovery(a, peer, credential_rotation) + verify_credential_deletion_recovery(a, b, credential_deletion, expect_error) + verify_vault_deletion_recovery(a, b, vault_deletion, expect_error) + assert [a.beta.agents.retrieve(item.id) for item in saved_agents] == saved_agents + assert [item.id for item in a.beta.agents.list(limit=2, order="asc") if item.id in listed_agents] == listed_agents + assert [sessions.retrieve(item.id) for item in request_sessions] == request_sessions + reference_spec, reference_headers, reference_result = reference_retry + assert sessions.create(**reference_spec, extra_headers=reference_headers) == reference_result + assert list(sessions.items.list(turn_session.id, order="asc")) == saved_items + assert list(turns.list(turn_session.id, order="asc")) == recovered + assert sessions.retrieve(first.id) == first + assert sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) == first + verify_creator_recovery(client, same_principal, peer_principal, same_subject_id, + creator_retries, expect_error) + go_env = dict(os.environ, AGENTS_API_CLIENT_TEST_BASE_URL=base + "/v1", + AGENTS_API_CLIENT_TEST_KEY=tokens[2], AGENTS_API_CLIENT_TEST_OTHER_KEY=tokens[3]) + subprocess.run(["go", "test", "./packages/agents-client/v1", "-run", "^TestService$", "-count=1"], + cwd=root, env=go_env, check=True, timeout=120) + with client(tokens[2]) as go_tenant: + go_sessions = list(go_tenant.beta.agents.sessions.list()) + assert len(go_sessions) == 3 and all(item.agent.model == "go-client-test-model" for item in go_sessions) + process.terminate() + process.wait(timeout=15) + from official_auth import verify_scope_bootstrap + verify_scope_bootstrap(binary, env, keys, bindings, log) + process = start() + with client(tokens[0]) as restored: + assert restored.beta.agents.sessions.retrieve(first.id) == first + process.terminate() + process.wait(timeout=15) + # Reuse the same credential contract with the second public engine. + env["AGENTS_API_ENGINE"] = "claude_sdk" + process = start() + with client(tokens[0]) as a, client(tokens[1]) as b, client(peer_principal) as peer: + claude_credentials = verify_mcp_credentials(a, b, peer, credential_canary, expect_error) + process.terminate() + process.wait(timeout=15) + process = start() + with client(tokens[0]) as a: + verify_mcp_credential_recovery(a, claude_credentials) + process.terminate() + process.wait(timeout=15) + env["AGENTS_API_ENGINE"] = "codex" + env.pop("AGENTS_API_CREDENTIAL_KEY_FILE") + process = start() + with client(tokens[0]) as without_key, client(tokens[1]) as other, client(peer_principal) as peer: + verify_credential_storage_disabled(without_key, saved_credentials[0][0], credential_canary, expect_error) + verify_keyless_credential_deletion(without_key, credential_deletion, expect_error) + verify_keyless_vault_deletion(without_key, vault_deletion, expect_error) + verify_credential_list_recovery(without_key, other, peer, listed_credentials, + credential_canary, phase="restart without the storage key") + print("Caller principal: SDK/raw HTTP scope checks, shared project access and persistent startup conflict passed.") + print("Official Turn client: lifecycle, Agent identity, safe errors, restart recovery, pagination and tenant/Session isolation passed.") + print("Official Go client: creation/retries, retrieval, bidirectional pagination and tenant isolation passed.") + print("Official client: upstream and generated response schemas, persistence/restart, retries, pagination, tenant isolation and explicit unsupported options passed.") + finally: + if process and process.poll() is None: + process.terminate() + process.wait(timeout=15) + log.flush() + log.seek(0) + output = log.read() + assert credential_canary not in output, "Credential token leaked into the service log" + assert credential_key.read_text().strip() not in output, "Credential key leaked into the service log" + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_credential_delete.py b/services/agents-api/tests/official_credential_delete.py new file mode 100644 index 000000000..857275e65 --- /dev/null +++ b/services/agents-api/tests/official_credential_delete.py @@ -0,0 +1,77 @@ +"""Pinned SDK/raw Credential deletion and retained absence after restart.""" + +import uuid + +import httpx2 +from openai import AuthenticationError, NotFoundError + + +def verify_credential_deletion(client, other, invalid, peer, canary, expect_error): + vault = client.beta.agents.vaults.create(name="Credential deletion") + foreign_vault = other.beta.agents.vaults.create(name="Foreign deletion scope") + credentials = client.beta.agents.vaults.credentials + auth = {"type": "static_bearer", "mcp_server_url": "https://example.invalid/delete", "token": canary} + values = [credentials.create(vault.id, name=name, auth=auth) + for name in ["SDK target", "HTTP target", "Keyless target", "Retained sibling"]] + foreign = other.beta.agents.vaults.credentials.create(foreign_vault.id, name="Foreign", auth=auth) + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + endpoint = str(client.base_url).rstrip("/") + "/vaults/" + vault.id + "/credentials" + target = values[0] + expect_error(NotFoundError, lambda: other.beta.agents.vaults.credentials.delete(target.id, vault_id=vault.id)) + expect_error(NotFoundError, lambda: credentials.delete(target.id, vault_id=foreign_vault.id)) + expect_error(NotFoundError, lambda: credentials.delete(str(uuid.uuid4()), vault_id=vault.id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.delete(target.id, vault_id=vault.id)) + with httpx2.Client(trust_env=False, timeout=10) as raw: + url = endpoint + "/" + target.id + assert raw.delete(url).status_code == 401 + response = raw.delete(url, headers={"Authorization": headers["Authorization"]}) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + for kwargs in [{"params": {"include": "token"}}, {"content": b"{}"}]: + assert raw.request("DELETE", url, headers=headers, **kwargs).status_code == 400 + assert credentials.retrieve(target.id, vault_id=vault.id) == target + response = peer.beta.agents.vaults.credentials.with_raw_response.delete(target.id, vault_id=vault.id) + expected = {"id": target.id, "deleted": True, "object": "vault.credential.deleted"} + assert response.status_code == 200 and response.parse().to_dict() == expected + assert response.http_response.json() == expected and canary not in response.http_response.text + assert response.headers["cache-control"] == "no-store" + response = raw.delete(endpoint + "/" + values[1].id, headers=headers) + assert response.status_code == 200 and response.json() == {**expected, "id": values[1].id} + assert canary not in response.text + for value in values[:2]: + url = endpoint + "/" + value.id + for method in ["GET", "DELETE"]: + response = raw.request(method, url, headers=headers) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + expect_error(NotFoundError, lambda: credentials.update(value.id, vault_id=vault.id, + auth={"type": "static_bearer", "token": canary})) + for status in [None, ["active"], ["active", "archived"]]: + args = {} if status is None else {"status": status} + assert list(credentials.list(vault.id, order="asc", limit=1, **args)) == values[2:] + assert list(credentials.list(vault.id, status=["archived"])) == [] + assert client.beta.agents.vaults.retrieve(vault.id) == vault + assert other.beta.agents.vaults.credentials.retrieve(foreign.id, vault_id=foreign_vault.id) == foreign + print("Credential deletion: SDK/raw confirmation, project/Vault scope, safe errors and retained sibling passed.") + return values, foreign + + +def verify_credential_deletion_recovery(client, other, saved, expect_error): + values, foreign = saved + credentials = client.beta.agents.vaults.credentials + for value in values[:2]: + expect_error(NotFoundError, lambda: credentials.retrieve(value.id, vault_id=value.vault_id)) + expect_error(NotFoundError, lambda: credentials.delete(value.id, vault_id=value.vault_id)) + assert list(credentials.list(values[0].vault_id, order="asc", limit=1)) == values[2:] + assert other.beta.agents.vaults.credentials.retrieve(foreign.id, vault_id=foreign.vault_id) == foreign + print("Credential deletion: absent resources and unaffected siblings survived API restart.") + + +def verify_keyless_credential_deletion(client, saved, expect_error): + values, _ = saved + target, sibling = values[2:] + credentials = client.beta.agents.vaults.credentials + result = credentials.delete(target.id, vault_id=target.vault_id) + assert result.to_dict() == {"id": target.id, "deleted": True, "object": "vault.credential.deleted"} + expect_error(NotFoundError, lambda: credentials.retrieve(target.id, vault_id=target.vault_id)) + assert credentials.retrieve(sibling.id, vault_id=sibling.vault_id) == sibling + assert list(credentials.list(target.vault_id)) == [sibling] + print("Credential deletion: no storage key required; safe sibling metadata remains available.") diff --git a/services/agents-api/tests/official_credential_list.py b/services/agents-api/tests/official_credential_list.py new file mode 100644 index 000000000..c993393d5 --- /dev/null +++ b/services/agents-api/tests/official_credential_list.py @@ -0,0 +1,184 @@ +"""Credential discovery through the pinned SDK and HTTP with private status fixtures.""" + +import json +import os +from pathlib import Path +import subprocess +from urllib.parse import parse_qs, urlsplit +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + +from official_credentials import verify_credential + + +def verify_page(response, vault_id, expected, has_more, canary): + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + assert canary not in response.text, "Credential token appeared in a list response" + body = response.json() + assert type(body["has_more"]) is bool + assert body == {"object": "list", "data": [value.to_dict() for value in expected], + "has_more": has_more, "first_id": expected[0].id if expected else None, + "last_id": expected[-1].id if expected else None} + for value in body["data"]: + verify_credential(value, vault_id, value["name"], value["auth"]["mcp_server_url"]) + + +def seed_archived_fixture(root, directory, tenant, vault_id, values): + path = Path(directory) / "credential-list.json" + path.write_text(json.dumps({"tenant": tenant, "vault": vault_id, + "credentials": [value.id for value in values]})) + subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + env=dict(os.environ, AGENTS_API_CREDENTIAL_LIST_FIXTURE=str(path)), + check=True, timeout=120) + + +def verify_credential_list(client, other, invalid, peer, binding, saved_vaults, saved_credentials, + listed_vaults, root, directory, canary, expect_error): + vault = listed_vaults[1][0] + empty_vault = saved_vaults[0][1] + sibling, foreign = saved_credentials[0][0], saved_credentials[1] + credentials = client.beta.agents.vaults.credentials + destination = "https://discovery.example.invalid/mcp?scope=private%2Ffixture" + expected = [credentials.create(vault.id, name=f"Discovered Credential {index}", + auth={"type": "static_bearer", "mcp_server_url": destination, "token": canary}) + for index in range(105)] + # Credential classification is independent of this already-archived Vault. + assert list(credentials.list(vault.id, status="active", order="asc")) == expected + assert list(credentials.list(vault.id, status="archived")) == [] + archived = expected[::3] + archived_ids = {value.id for value in archived} + active = [value for value in expected if value.id not in archived_ids] + seed_archived_fixture(root, directory, binding["tenant_id"], vault.id, archived) + descending = list(reversed(expected)) + + def sdk_page(values, has_more, query, **request): + response = credentials.with_raw_response.list(vault.id, **request) + assert parse_qs(urlsplit(str(response.http_response.request.url)).query, keep_blank_values=True) == query + verify_page(response.http_response, vault.id, values, has_more, canary) + assert response.parse().data == values + + sdk_page(descending[:20], True, {}) + sdk_page(descending[:20], True, {}, limit=None) + sdk_page(descending[:20], True, {}, status=[]) + for limit, size in ((0, 1), (-7, 1), (101, 100)): + sdk_page(descending[:size], True, {"limit": [str(limit)]}, limit=limit) + sdk_page(list(reversed(archived))[:20], True, {"status": ["archived"]}, status="archived") + sdk_page(active[:20], True, {"status[]": ["active"], "order": ["asc"]}, + status=["active"], order="asc") + sdk_page(descending[:20], True, {"status[]": ["active", "archived"]}, + status=["active", "archived"]) + sdk_page(expected[7:14], True, {"after": [expected[6].id], "limit": ["7"], "order": ["asc"]}, + after=expected[6].id, limit=7, order="asc") + sdk_page(active[:7], True, {"after": [archived[0].id], "limit": ["7"], + "order": ["asc"], "status": ["active"]}, + after=archived[0].id, limit=7, order="asc", status="active") + + pages = list(credentials.list(vault.id, limit=100, order="asc").iter_pages()) + assert [len(page.data) for page in pages] == [100, 5] + assert [value for page in pages for value in page.data] == expected + assert pages[0].has_next_page() and not pages[-1].has_next_page() + assert list(credentials.list(vault.id, limit=17)) == descending + assert list(credentials.list(vault.id, status="archived", limit=7, order="asc")) == archived + assert list(credentials.list(vault.id, status=["active"], limit=19, order="asc")) == active + assert list(peer.beta.agents.vaults.credentials.list(vault.id, order="asc")) == expected + assert list(other.beta.agents.vaults.credentials.list(foreign.vault_id)) == [foreign] + assert list(credentials.list(empty_vault.id)) == [] + assert credentials.retrieve(archived[0].id, vault_id=vault.id) == archived[0] + + base = str(client.base_url).rstrip("/") + "/vaults/" + endpoint = base + vault.id + "/credentials" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + + def safe_error(response, status): + assert response.status_code == status + assert canary not in response.text + for value in (expected[0], sibling, foreign): + assert all(part not in response.text for part in (value.id, value.name, value.vault_id, + value.auth.mcp_server_url)) + body = response.json()["error"] + if status == 404: + assert body["code"] == "not_found" + elif status == 400: + assert body["type"] == "invalid_request_error" + return body + + with httpx2.Client(trust_env=False, timeout=10) as raw: + verify_page(raw.get(endpoint, headers=headers), vault.id, descending[:20], True, canary) + for limit, size in (("0", 1), ("-7", 1), ("101", 100)): + verify_page(raw.get(endpoint, headers=headers, params={"limit": limit}), + vault.id, descending[:size], True, canary) + for params, values in (({"status": "active"}, list(reversed(active))), + ([("status[]", "archived")], list(reversed(archived))), + ([("status[]", "active"), ("status[]", "archived")], descending)): + verify_page(raw.get(endpoint, headers=headers, params=params), vault.id, values[:20], True, canary) + for order, values in (("asc", expected), ("desc", descending)): + params = {"order": order, "limit": "100"} + first = raw.get(endpoint, headers=headers, params=params) + verify_page(first, vault.id, values[:100], True, canary) + verify_page(raw.get(endpoint, headers=headers, params=params | {"after": first.json()["last_id"]}), + vault.id, values[100:], False, canary) + verify_page(raw.get(endpoint, headers=headers, params=params | {"after": values[-1].id}), + vault.id, [], False, canary) + assert list(credentials.list(vault.id, after=values[-1].id, order=order)) == [] + verify_page(raw.get(base + empty_vault.id + "/credentials", headers=headers), + empty_vault.id, [], False, canary) + verify_page(raw.get(base + foreign.vault_id + "/credentials", + headers=headers | {"Authorization": f"Bearer {other.api_key}"}, + params={"status": "archived"}), foreign.vault_id, [], False, canary) + for owner in (foreign.vault_id, str(uuid.uuid4()), "invalid-vault", str(uuid.UUID(int=0))): + safe_error(raw.get(base + owner + "/credentials", headers=headers), 404) + expect_error(NotFoundError, lambda: credentials.list(owner)) + for owner, cursor in ((vault.id, sibling.id), (vault.id, foreign.id), + (empty_vault.id, expected[0].id), (vault.id, str(uuid.uuid4()))): + safe_error(raw.get(base + owner + "/credentials", headers=headers, params={"after": cursor}), 404) + expect_error(NotFoundError, lambda: credentials.list(owner, after=cursor)) + invalid_queries = [ + {"after": "invalid-credential"}, {"status": "unknown"}, {"limit": "null"}, + {"order": "invalid"}, {"include": "token"}, {"tenant_id": "foreign"}, + [("status", "active"), ("status", "archived")], + [("status", "active"), ("status[]", "archived")], + ] + for params in invalid_queries: + safe_error(raw.get(endpoint, headers=headers, params=params), 400) + for suffix in (vault.id, "invalid-vault"): + safe_error(raw.get(base + suffix + "/credentials", params={"after": "invalid"}), 401) + for beta in (None, "agents=v2"): + auth = {"Authorization": headers["Authorization"]} + if beta is not None: + auth["OpenAI-Beta"] = beta + assert safe_error(raw.get(endpoint, headers=auth), 400)["code"] == "invalid_beta_header" + for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): + safe_error(raw.get(endpoint, headers=headers | scope), 401) + expect_error(AuthenticationError, lambda: credentials.list(vault.id, extra_headers=scope)) + expect_error(NotFoundError, lambda: other.beta.agents.vaults.credentials.list(vault.id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.list(vault.id)) + expect_error(BadRequestError, lambda: credentials.list(vault.id, extra_headers={"OpenAI-Beta": ""})) + print("Credential list: fixed SDK serialization, safe HTTP envelopes, >100-row pagination, independent stored status and project/Vault isolation passed; archived rows use a private SQL fixture.") + return vault, expected, archived, empty_vault, foreign + + +def verify_credential_list_recovery(client, other, peer, saved, canary, phase="API restart"): + vault, expected, archived, empty_vault, foreign = saved + for caller in (client, peer): + credentials = caller.beta.agents.vaults.credentials + assert list(credentials.list(vault.id, limit=100, order="asc")) == expected + assert list(credentials.list(vault.id, status=["archived"], limit=7, order="asc")) == archived + assert list(credentials.list(empty_vault.id)) == [] + for value in (expected[0], expected[1], expected[-1]): + assert credentials.retrieve(value.id, vault_id=vault.id) == value + assert list(other.beta.agents.vaults.credentials.list(foreign.vault_id)) == [foreign] + endpoint = str(client.base_url).rstrip("/") + "/vaults/" + vault.id + "/credentials" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + for params, values, has_more in (({"order": "asc", "limit": "100"}, expected[:100], True), + ({"order": "asc", "after": expected[99].id}, expected[100:], False), + ({"status[]": "archived", "order": "asc", "limit": "100"}, archived, False)): + verify_page(raw.get(endpoint, headers=headers, params=params), vault.id, values, has_more, canary) + response = raw.get(endpoint, headers=headers | {"Authorization": f"Bearer {other.api_key}"}) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert canary not in response.text and expected[0].id not in response.text + print(f"Credential list: exact metadata, stored status, discovery/retrieval and project isolation survived {phase}.") diff --git a/services/agents-api/tests/official_credential_rotation.py b/services/agents-api/tests/official_credential_rotation.py new file mode 100644 index 000000000..7a7f07e92 --- /dev/null +++ b/services/agents-api/tests/official_credential_rotation.py @@ -0,0 +1,123 @@ +"""Public token replacement without changing Credential or Session identity.""" + +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_credential_rotation(client, other, invalid, peer, saved_vaults, saved_credentials, canary, expect_error): + vaults, foreign_vault = saved_vaults + vault, wrong_vault = vaults[:2] + untouched, foreign = saved_credentials + credentials = client.beta.agents.vaults.credentials + destination = "https://rotation.example.invalid/mcp" + original = credentials.create(vault.id, name="Stable rotation identity", auth={ + "type": "static_bearer", "mcp_server_url": destination, "token": canary + "initial"}) + stable = {key: value for key, value in original.to_dict().items() if key != "updated_at"} + endpoint = str(client.base_url).rstrip("/") + "/vaults/" + vault.id + "/credentials/" + original.id + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + replacement = {"auth": {"type": "static_bearer", "token": canary + "replacement"}} + sessions = [] + for selected in (None, original.id): + request = {"agent": {"model": "requested-model", "tools": [{ + "type": "mcp", "server_label": "rotating", "credential_id": selected, + "connection_origin": "service", "allowed_tools": [], + "transport": {"type": "http", "server_url": destination}}]}, + "environment": {"type": "none"}, "vault_ids": [vault.id]} + key = {"Idempotency-Key": "credential-rotation-" + str(uuid.uuid4())} + sessions.append((request, key, client.beta.agents.sessions.create(**request, extra_headers=key))) + + def safe(response, expected): + assert response.status_code == expected + assert canary not in response.text, "Credential replacement leaked into the public response" + assert response.headers["cache-control"] == "no-store" + return response.json() + + def metadata(response, previous): + body = safe(response, 200) + assert set(body) == set(original.to_dict()) + assert {key: value for key, value in body.items() if key != "updated_at"} == stable + assert type(body["updated_at"]) is int and body["updated_at"] >= previous.updated_at + current = credentials.retrieve(original.id, vault_id=vault.id) + assert current.to_dict() == body + assert peer.beta.agents.vaults.credentials.retrieve(original.id, vault_id=vault.id) == current + return current + + with httpx2.Client(trust_env=False, timeout=10) as raw: + response = credentials.with_raw_response.update(original.id, vault_id=vault.id, **replacement) + current = metadata(response.http_response, original) + assert response.parse() == current + # The public boundary accepts opaque values; private Store tests verify + # their bytes. The final value also supplies a log-scan canary. + for token in ("", " \t" + canary + "\n雪 ", canary + "final"): + response = raw.post(endpoint, headers=headers, json={"auth": {"type": "static_bearer", "token": token}}) + current = metadata(response, current) + response = peer.beta.agents.vaults.credentials.with_raw_response.update( + original.id, vault_id=vault.id, auth={"type": "static_bearer", "token": canary + "peer"}) + current = metadata(response.http_response, current) + assert response.parse() == current + + invalid_bodies = [ + {}, {"auth": None}, {"auth": []}, {"auth": {}}, + {"auth": {"type": "static_bearer"}}, {"auth": {"token": canary}}, + {"auth": {"type": None, "token": canary}}, + {"auth": {"type": 3, "token": canary}}, + {"auth": {"type": "static_bearer", "token": None}}, + {"auth": {"type": "static_bearer", "token": 3}}, + {"auth": {"type": "mcp_oauth", "access_token": canary}}, + {**replacement, "name": "Unexpected rename"}, + {**replacement, "metadata": {}}, + {"auth": {**replacement["auth"], "mcp_server_url": destination + "/other"}}, + ] + for body in invalid_bodies: + safe(raw.post(endpoint, headers=headers, json=body), 400) + for body in ("null", "[]", "{} {}"): + safe(raw.post(endpoint, headers=headers, content=body), 400) + for override in ({"auth": None}, {"auth": {"type": "static_bearer", "token": None}}): + error = expect_error(BadRequestError, lambda: credentials.update( + original.id, vault_id=vault.id, **replacement, extra_body=override)) + safe(error.response, 400) + + base = str(client.base_url).rstrip("/") + "/vaults/" + for owner, credential_id in ((wrong_vault.id, original.id), (foreign_vault.id, foreign.id), + (vault.id, str(uuid.uuid4())), (vault.id, "invalid"), + (vault.id, str(uuid.UUID(int=0))), ("invalid", original.id), + (str(uuid.UUID(int=0)), original.id)): + response = raw.post(base + owner + "/credentials/" + credential_id, headers=headers, json=replacement) + assert safe(response, 404)["error"]["code"] == "not_found" + assert original.id not in response.text and foreign.id not in response.text + error = expect_error(NotFoundError, lambda: credentials.update(credential_id, vault_id=owner, **replacement)) + safe(error.response, 404) + expect_error(NotFoundError, lambda: other.beta.agents.vaults.credentials.update( + original.id, vault_id=vault.id, **replacement)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.update( + original.id, vault_id=vault.id, **replacement)) + safe(raw.post(endpoint, json=replacement), 401) + assert safe(raw.post(endpoint, headers={"Authorization": headers["Authorization"]}, + json=replacement), 400)["error"]["code"] == "invalid_beta_header" + for scope in ({"OpenAI-Project": "other-project"}, {"OpenAI-Organization": "other-organization"}): + expect_error(AuthenticationError, lambda: credentials.update( + original.id, vault_id=vault.id, **replacement, extra_headers=scope)) + safe(raw.post(endpoint, headers=headers, params={"include": "token"}, json=replacement), 400) + assert safe(raw.get(endpoint, headers=headers), 200) == current.to_dict() + + assert credentials.retrieve(original.id, vault_id=vault.id) == current + assert [credentials.retrieve(value.id, vault_id=value.vault_id) for value in untouched] == untouched + assert other.beta.agents.vaults.credentials.retrieve(foreign.id, vault_id=foreign.vault_id) == foreign + assert [client.beta.agents.vaults.retrieve(value.id) for value in vaults] == vaults + state = current, sessions + verify_rotation_recovery(client, peer, state) + print("Credential rotation: SDK/raw opaque replacement, stable metadata and bindings, owner scope and rejection non-mutation passed.") + return state + + +def verify_rotation_recovery(client, peer, state): + credential, sessions = state + assert client.beta.agents.vaults.credentials.retrieve(credential.id, vault_id=credential.vault_id) == credential + assert peer.beta.agents.vaults.credentials.retrieve(credential.id, vault_id=credential.vault_id) == credential + for request, key, original in sessions: + assert client.beta.agents.sessions.retrieve(original.id) == original + assert client.beta.agents.sessions.create(**request, extra_headers=key) == original + assert list(client.beta.agents.sessions.turns.list(original.id)) == [] + assert list(client.beta.agents.sessions.items.list(original.id)) == [] diff --git a/services/agents-api/tests/official_credentials.py b/services/agents-api/tests/official_credentials.py new file mode 100644 index 000000000..aae9d08c4 --- /dev/null +++ b/services/agents-api/tests/official_credentials.py @@ -0,0 +1,161 @@ +"""Static-bearer Credential metadata through the pinned SDK and real HTTP.""" + +import time +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, InternalServerError, NotFoundError + + +def verify_credential(body, vault_id, name, destination): + assert set(body) == {"id", "auth", "created_at", "name", "object", "updated_at", "vault_id"} + assert uuid.UUID(body["id"]).int != 0 and body["object"] == "vault.credential" + assert body["vault_id"] == vault_id and body["name"] == name + assert body["auth"] == {"type": "static_bearer", "mcp_server_url": destination} + assert type(body["created_at"]) is int and type(body["updated_at"]) is int + assert body["created_at"] == body["updated_at"] and body["created_at"] > 0 + + +def verify_credentials(client, other, invalid, peer, saved_vaults, canary, expect_error): + vaults, foreign_vault = saved_vaults + vault, wrong_vault = vaults[:2] + credentials = client.beta.agents.vaults.credentials + base = str(client.base_url).rstrip("/") + "/vaults/" + endpoint = base + vault.id + "/credentials" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + destination = "https://example.invalid/mcp?tenant=alpha%2Fbeta" + auth = {"type": "static_bearer", "mcp_server_url": destination, "token": " \t" + canary + "\n雪 "} + request = {"name": " \tCredential 資源\u3000", "auth": auth} + sessions_before = [list(api.beta.agents.sessions.list()) for api in (client, other)] + saved = [] + + def safe_body(response, status): + assert response.status_code == status + assert canary not in response.text, "Credential token appeared in an HTTP response" + return response.json() + + with httpx2.Client(trust_env=False, timeout=10) as raw: + response = credentials.with_raw_response.create(vault.id, **request) + body, value = safe_body(response.http_response, 200), response.parse() + verify_credential(body, vault.id, "Credential 資源", destination) + assert value.to_dict() == body and abs(value.created_at - time.time()) < 10 + saved.append(value) + + # These successful writes exercise opaque strings, not a public token + # round-trip. Byte preservation is verified by private Store tests. + for name, token in (("🧪" * 64, canary + "x" * 1024), ("Empty opaque token", "")): + response = raw.post(endpoint, headers=headers, json={"name": " " + name + "\n", + "auth": {**auth, "token": token}}) + body = safe_body(response, 200) + verify_credential(body, vault.id, name, destination) + saved.append(credentials.retrieve(body["id"], vault_id=vault.id)) + assert saved[-1].to_dict() == body + user_value = peer.beta.agents.vaults.credentials.create(vault.id, name="Project user", auth=auth) + verify_credential(user_value.to_dict(), vault.id, "Project user", destination) + saved.append(user_value) + foreign = other.beta.agents.vaults.credentials.create(foreign_vault.id, name="Foreign project", auth=auth) + verify_credential(foreign.to_dict(), foreign_vault.id, "Foreign project", destination) + + for value in saved: + assert credentials.retrieve(value.id, vault_id=vault.id) == value + assert peer.beta.agents.vaults.credentials.retrieve(value.id, vault_id=vault.id) == value + response = raw.get(endpoint + "/" + value.id, headers=headers) + assert safe_body(response, 200) == value.to_dict() + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + + invalid_requests = [ + {}, {"name": "missing auth"}, {"auth": auth}, + {**request, "name": None}, {**request, "name": 3}, {**request, "name": " \t\n"}, + {**request, "name": " " + "🧪" * 64 + "a "}, + {**request, "auth": None}, {**request, "auth": []}, + {**request, "auth": {**auth, "token": 3}}, + {**request, "auth": {"type": "mcp_oauth", "mcp_server_url": destination, "access_token": canary}}, + {**request, "metadata": {"unexpected": "field"}}, + ] + for field in ("type", "mcp_server_url", "token"): + invalid_requests.extend([{**request, "auth": {key: value for key, value in auth.items() if key != field}}, + {**request, "auth": {**auth, field: None}}]) + for url in ("http://example.invalid/mcp", "https://user:" + canary + "@example.invalid/mcp", + "https://example.invalid/mcp#fragment", "not-a-url"): + invalid_requests.append({**request, "auth": {**auth, "mcp_server_url": url}}) + for body in invalid_requests: + response = raw.post(endpoint, headers=headers, json=body) + assert safe_body(response, 400)["error"]["type"] == "invalid_request_error" + for body in ("null", "[]", "{} {}"): + safe_body(raw.post(endpoint, headers=headers, content=body), 400) + for override in ({"name": None}, {"auth": None}, {"auth": {**auth, "token": None}}): + error = expect_error(BadRequestError, lambda: credentials.create(vault.id, **request, extra_body=override)) + safe_body(error.response, 400) + + for owner, credential_id in ((wrong_vault.id, saved[0].id), (foreign_vault.id, foreign.id), + (vault.id, str(uuid.uuid4())), (vault.id, "invalid"), + (vault.id, str(uuid.UUID(int=0))), ("invalid", saved[0].id), + (str(uuid.UUID(int=0)), saved[0].id)): + response = raw.get(base + owner + "/credentials/" + credential_id, headers=headers) + assert safe_body(response, 404)["error"]["code"] == "not_found" + assert saved[0].id not in response.text and foreign.id not in response.text + error = expect_error(NotFoundError, lambda: credentials.retrieve(credential_id, vault_id=owner)) + safe_body(error.response, 404) + for owner in (foreign_vault.id, str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): + response = raw.post(base + owner + "/credentials", headers=headers, json=request) + assert safe_body(response, 404)["error"]["code"] == "not_found" + expect_error(NotFoundError, lambda: other.beta.agents.vaults.credentials.retrieve(saved[0].id, vault_id=vault.id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.create(vault.id, **request)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.credentials.retrieve(saved[0].id, vault_id=vault.id)) + for scope in ({"OpenAI-Project": "other-project"}, {"OpenAI-Organization": "other-organization"}): + expect_error(AuthenticationError, lambda: credentials.create(vault.id, **request, extra_headers=scope)) + expect_error(AuthenticationError, lambda: credentials.retrieve(saved[0].id, vault_id=vault.id, extra_headers=scope)) + for method, url in (("POST", endpoint), ("GET", endpoint + "/" + saved[0].id)): + body = {"json": request} if method == "POST" else {} + safe_body(raw.request(method, url, **body), 401) + response = raw.request(method, url, headers={"Authorization": headers["Authorization"]}, **body) + assert safe_body(response, 400)["error"]["code"] == "invalid_beta_header" + safe_body(raw.post(endpoint, headers=headers, params={"tenant_id": "other"}, json=request), 400) + safe_body(raw.get(endpoint + "/" + saved[0].id, headers=headers, params={"include": "token"}), 400) + + assert [list(api.beta.agents.sessions.list()) for api in (client, other)] == sessions_before + assert [client.beta.agents.vaults.retrieve(value.id) for value in vaults] == vaults + assert other.beta.agents.vaults.retrieve(foreign_vault.id) == foreign_vault + print("Static credentials: SDK/raw safe metadata, required fields, names, local HTTPS profile, project ownership and validation passed; no execution or token round-trip claim.") + return saved, foreign + + +def verify_credential_recovery(client, other, peer, saved): + values, foreign = saved + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + base = str(client.base_url).rstrip("/") + "/vaults/" + with httpx2.Client(trust_env=False, timeout=10) as raw: + for value in values: + assert client.beta.agents.vaults.credentials.retrieve(value.id, vault_id=value.vault_id) == value + assert peer.beta.agents.vaults.credentials.retrieve(value.id, vault_id=value.vault_id) == value + response = raw.get(base + value.vault_id + "/credentials/" + value.id, headers=headers) + assert response.status_code == 200 and response.json() == value.to_dict() + assert other.beta.agents.vaults.credentials.retrieve(foreign.id, vault_id=foreign.vault_id) == foreign + print("Static credentials: exact SDK/raw metadata and shared-project reads survived the service restart.") + + +def verify_credential_storage_disabled(client, value, canary, expect_error): + credentials = client.beta.agents.vaults.credentials + assert credentials.retrieve(value.id, vault_id=value.vault_id) == value + request = {"name": "Disabled storage", "auth": {**value.auth.to_dict(), "token": canary}} + error = expect_error(InternalServerError, lambda: credentials.create(value.vault_id, **request)) + assert error.status_code == 503 and error.body["code"] == "credential_storage_unavailable" + assert canary not in error.response.text + with httpx2.Client(trust_env=False, timeout=10) as raw: + response = raw.post(str(client.base_url).rstrip("/") + "/vaults/" + value.vault_id + "/credentials", + headers={"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"}, + json=request) + assert response.status_code == 503 and canary not in response.text + replacement = {"auth": {"type": "static_bearer", "token": canary + "replacement"}} + error = expect_error(InternalServerError, lambda: credentials.update(value.id, vault_id=value.vault_id, **replacement)) + assert error.status_code == 503 and error.body["code"] == "credential_storage_unavailable" + assert canary not in error.response.text + response = raw.post(str(client.base_url).rstrip("/") + "/vaults/" + value.vault_id + "/credentials/" + value.id, + headers={"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"}, + json=replacement) + assert response.status_code == 503 and canary not in response.text + assert credentials.retrieve(value.id, vault_id=value.vault_id) == value + vault = client.beta.agents.vaults.create(name="Non-secret resource without credential key") + assert client.beta.agents.vaults.retrieve(vault.id) == vault + print("Static credentials: absent key rejects SDK/raw writes while safe reads and Vault creation remain available.") diff --git a/services/agents-api/tests/official_e2b_v1.py b/services/agents-api/tests/official_e2b_v1.py new file mode 100644 index 000000000..f16773063 --- /dev/null +++ b/services/agents-api/tests/official_e2b_v1.py @@ -0,0 +1,532 @@ +"""Opt-in standalone acceptance using actual E2B, native harnesses and model APIs. + +Pass one private operator JSON configuration path. Provider file/command calls in +this fixture observe effects or inject faults; public execution and Files always +use the deployed Core and daemon. No synthetic model server is provided. +""" +import base64 +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys +import tempfile +import time +import traceback +import uuid + +import httpx2 +from e2b import Sandbox, SandboxQuery +from openai import OpenAI + +from official_environment_files import verify_environment_files, verify_file_tenant_isolation +from official_session_artifacts import verify_session_artifacts + +config = json.loads(Path(sys.argv[1]).read_text()) +if config.get('verify_initial_files') and not config.get('verify_environment_templates'): + raise ValueError('Initial-file acceptance requires verify_environment_templates') +if config.get('verify_environment_setup') and not config.get('verify_initial_files'): + raise ValueError('Setup acceptance requires verify_initial_files') +if config.get('verify_system_packages') and not config.get('verify_environment_setup'): + raise ValueError('System-package acceptance requires verify_environment_setup') +if config.get('verify_initialization_restart') and not config.get('verify_initial_files'): + raise ValueError('Initialization restart acceptance requires verify_initial_files') +root = Path(config['proof_root']) +package = Path(config['package']) +root.mkdir(parents=True, exist_ok=True) +run = Path(tempfile.mkdtemp(prefix=config['engine'] + '-', dir=root)) +run.chmod(0o700) +pin = json.loads((package / 'upstream.json').read_text()) +distribution = importlib.metadata.distribution('openai') +assert distribution.version == pin['sdk_version'] +assert json.loads(distribution.read_text('direct_url.json'))['vcs_info']['commit_id'] == pin['commit'] +e2b_key = Path(config['e2b_key_file']).read_text().strip() +model_key = Path(config['model_key_file']).read_text().strip() +tokens = [secrets.token_hex(32), secrets.token_hex(32)] +provider = str(uuid.uuid4()) +created = [] +public_templates = [] +sources = [] +cloud = {} +handles = [] +process = None +record = {'engine': config['engine'], 'model': config['model'], 'template': config['template'], + 'started': time.time(), 'checks': [], 'provider': provider, + 'core_sha256': hashlib.sha256((package / 'bin/agents-api').read_bytes()).hexdigest(), + 'protocol': pin, 'real_e2b': True, 'real_model': True} +base = 'http://127.0.0.1:' + str(config['port']) +public = config['core_public_url'].rstrip('/') +http = httpx2.Client(trust_env=False, timeout=360) +client = OpenAI(base_url=base + '/v1', api_key=tokens[0], max_retries=0, + _strict_response_validation=True, http_client=http) +foreign = OpenAI(base_url=base + '/v1', api_key=tokens[1], max_retries=0, + _strict_response_validation=True, http_client=http) +sessions, files = client.beta.agents.sessions, client.beta.agents.environments.files +headers = {'Authorization': 'Bearer ' + tokens[0], 'OpenAI-Beta': 'agents=v1'} + + +def private(name, value): + path = run / name + path.write_text(json.dumps(value)) + path.chmod(0o600) + return str(path) + + +keys = [dict(token_sha256=hashlib.sha256(token.encode()).hexdigest(), tenant_id=str(uuid.uuid4()), + organization_id='e2b-acceptance', project_id=provider + '-' + str(i), + subject_kind='user', subject_id='caller-' + str(i)) for i, token in enumerate(tokens)] +env = {'HOME': str(Path.home()), 'PATH': '/usr/bin:/bin', 'PARSAR_HOME': str(run / 'state'), + 'AGENTS_API_DATABASE_URL': Path(config['database_file']).read_text().strip(), + 'AGENTS_API_KEYS_FILE': private('keys.json', keys), 'AGENTS_API_ADDR': '127.0.0.1:' + str(config['port']), + 'AGENTS_API_DAEMON_WS_URL': public.replace('https://', 'wss://') + '/api/v1/agent-daemon/ws', + 'AGENTS_API_ENGINE': config['engine'], + 'AGENTS_API_EXECUTION_OPTIONS_FILE': config['options_file'], + 'AGENTS_API_MANAGED_RUNTIMES_FILE': private('managed.json', {'core_url': public + '/api/v1', + 'default_provider': provider, 'e2b': {provider: {'api_key_file': config['e2b_key_file'], + 'template': config['template'], 'lease_seconds': 7200}}})} +if config.get('verify_initial_files'): + key_path = run / 'initial-file-encryption.key' + key_path.write_text(base64.b64encode(secrets.token_bytes(32)).decode()) + key_path.chmod(0o600) + env['AGENTS_API_CREDENTIAL_KEY_FILE'] = str(key_path) +if os.getenv('HTTPS_PROXY'): + env['HTTPS_PROXY'] = os.environ['HTTPS_PROXY'] + + +def until(fn, timeout=120): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = fn() + if result: + return result + time.sleep(.3) + raise AssertionError('Timed out: ' + fn.__name__) + + +def start(): + global process + output = (run / ('core-' + str(len(handles)) + '.log')).open('w') + handles.append(output) + process = subprocess.Popen([str(package / 'bin/agents-api')], cwd=package, env=env, + stdout=output, stderr=subprocess.STDOUT) + def ready(): + assert process.poll() is None, 'Core exited' + try: + return http.get(base + '/v1/agents/sessions', headers=headers).status_code == 200 + except httpx2.TransportError: + return False + until(ready, 30) + + +def stop(crash=False): + global process + if process and process.poll() is None: + process.kill() if crash else process.terminate() + process.wait(timeout=45) + process = None + + +def owned(): + pages = Sandbox.list(query=SandboxQuery(metadata={'io.parsar.agents-api.installation': provider}), api_key=e2b_key) + result = [] + while pages.has_next: + result.extend(pages.next_items()) + return result + + +def runtime(eid): + if eid not in cloud: + matching = [a for a in owned() if a.metadata.get('io.parsar.agents-api.environment') == eid] + assert len(matching) == 1, 'Missing or duplicate owned E2B VM' + cloud[eid] = Sandbox.connect(matching[0].sandbox_id, timeout=7200, api_key=e2b_key) + return cloud[eid] + + +def read(vm, path): + return vm.files.read(path, user='runtime', format='bytes') + + +def exists(vm, path): + return vm.files.exists(path, user='runtime') + + +def upload(eid, path, data): + if isinstance(data, str): + data = data.encode() + result = files.create(eid, type='inline', path=path, data=base64.b64encode(data).decode()) + assert result.size_bytes == len(data) + return len(data) + + +def message(text): + return {'type': 'agent.session.input.message', 'input': [{'role': 'user', 'content': [{'type': 'input_text', 'text': text}]}]} + + +def waitturn(sid, n, status='completed'): + def finished(): + turns = sessions.turns.list(sid, limit=100, order='asc').data + if len(turns) < n: + return False + turn = turns[n - 1] + if turn.status in ['completed', 'cancelled', 'failed']: + assert turn.status == status, ('Unexpected terminal Turn', turn.to_dict()) + return turn + return False + return until(finished, 240) + + +def prompt(sid, text, n): + with sessions.events.stream(sid, timeout=300) as stream: + sessions.events.create(sid, events=[message(text)], idempotency_key='prompt-' + str(n)) + types = [] + for event in stream: + types.append(event.type) + if event.type == 'agent.session.failed' or (event.type == 'agent.session.idle' and 'agent.session.turn.completed' in types): + break + assert 'agent.session.turn.completed' in types, types + assert types.index('agent.session.turn.created') < types.index('agent.session.turn.completed') + assert types[-1] == 'agent.session.idle', types + return waitturn(sid, n) + + +def connected(eid): + return until(lambda: client.beta.agents.environments.retrieve(eid).status == 'connected', + 300 if config.get('verify_system_packages') else 120) + + +def native_id(sid): + sql = "SELECT native_session_id FROM session_devices WHERE session_id='" + sid + "'" + return subprocess.check_output(config['psql_command'] + ['-At', '-c', sql], text=True).strip() + + +def restart_runtime(vm): + vm.commands.run('pkill -KILL -u 1000 || true', user='root') + script = '''import json,subprocess +from pathlib import Path +root=Path('/root/.parsar/e2b');r=json.loads((root/'ready.json').read_text()) +e=json.loads(Path('/etc/parsar-runtime-env.json').read_text()) +e.update(PATH='/usr/local/bin:/usr/bin:/bin',PARSAR_RUNTIME_ENVIRONMENT_ID=r['EnvironmentID'],PARSAR_RUNTIME_SESSION_ID=r['session_id'],PARSAR_RUNTIME_NETWORK_ACCESS='enabled') +f=open('/home/runtime/.parsar/parsar-daemon/default/restarted.log','ab') +subprocess.Popen(['/usr/local/bin/parsar-daemon','connect','--profile','default'],cwd='/environment/workspace',env=e,user=1000,group=1000,extra_groups=[],start_new_session=True,stdin=subprocess.DEVNULL,stdout=f,stderr=f,umask=0o077) +''' + vm.files.write('/root/.parsar/e2b/restart-proof.py', script, user='root') + vm.commands.run('/usr/bin/python3 /root/.parsar/e2b/restart-proof.py', user='root') + + +def check(name): + record['checks'].append(name) + print(name, flush=True) + + +try: + with (run / 'migrate.log').open('w') as output: + subprocess.run([str(package / 'bin/agents-api-migrate')], cwd=package, env=env, + stdout=output, stderr=subprocess.STDOUT, check=True) + start() + for authorization in [None, 'Bearer invalid-e2b-key']: + h = {'OpenAI-Beta': 'agents=v1'} + if authorization: + h['Authorization'] = authorization + assert http.get(base + '/v1/agents/sessions', headers=h).status_code == 401 + check('independent_deployment_and_authentication') + agent = {'model': config['model'], 'instructions': 'Run the exact requested native shell commands. Never modify supplied scripts or repeat interrupted commands. Preserve conversation history.'} + environment = {'type': 'openai_hosted'} + if config.get('verify_environment_templates'): + from official_environment_templates import verify_environment_templates, verify_template_session_rejections + enabled_template, disabled_template = verify_environment_templates(client, foreign, http) + public_templates.extend([enabled_template, disabled_template]) + verify_template_session_rejections(client, foreign, http, agent, enabled_template, disabled_template) + if config.get('verify_system_packages'): + from official_environment_setup import verify_system_package_configuration + verify_system_package_configuration(client, http) + environment['environment_template_id'] = enabled_template + check('template_sdk_http_crud_pagination_redaction_and_tenant_isolation') + initial_expected = {} + if config.get('verify_initial_files'): + from official_environment_initial_files import initial_files, verify_initial_snapshot, assert_initial_bytes_script + environment, initial_source, initial_expected = initial_files(client, foreign, http, agent, enabled_template) + sources.append(initial_source) + if config.get('verify_environment_setup'): + from official_environment_setup import setup_configuration, attach_setup, verify_setup_metadata, native_setup_script + setup, setup_marker = setup_configuration(system_packages=config.get('verify_system_packages', False)) + environment = attach_setup(client, foreign, http, environment, setup, enabled_template, network='enabled') + session = sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}) + created.append(session.id) + if config.get('verify_environment_setup'): + verify_setup_metadata(client, session, setup) + eid = session.environment.id + if initial_expected: + verify_initial_snapshot(client, http, session, initial_expected, initial_source) + sources.remove(initial_source) + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + if public_templates: + api = client.beta.agents.environments.templates + api.update(enabled_template, network={'access': 'disabled'}) + assert sessions.retrieve(session.id).environment.network.access == 'enabled' + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + api.delete(enabled_template) + public_templates.remove(enabled_template) + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + changed = {**environment, 'network': {'access': 'enabled'}} + conflict = http.post(base + '/v1/agents/sessions', headers={**headers, 'Idempotency-Key': 'idle'}, json={'agent': agent, 'environment': changed}) + assert conflict.status_code == 409 + check('template_snapshot_and_creation_retry_survive_update_and_delete') + connected(eid) + vm = runtime(eid) + expected_init = Path(__file__).parents[1] / 'deploy/e2b/init.py' + deployed_init = vm.files.read('/opt/parsar-e2b/init.py', user='root', format='bytes') + assert deployed_init == expected_init.read_bytes(), 'Template bootstrap differs from this checkout' + record['bootstrap_sha256'] = hashlib.sha256(deployed_init).hexdigest() + protection = vm.commands.run("""python3 - <<'CHECK' +import os,subprocess +for path in ['/usr/local', '/usr/local/bin', '/usr/local/bin/parsar-daemon', + '/opt/parsar-e2b', '/opt/parsar-e2b/init.py', '/usr/bin/envd', + '/etc/inittab', '/etc/init.d/rcS']: + if path == '/etc/init.d/rcS' and not os.path.exists(path): + continue + stat = os.stat(path) + assert stat.st_uid == 0 and stat.st_mode & 0o022 == 0, path + assert not os.access(path, os.W_OK), path +result = subprocess.run(['su', 'user', '-c', 'id -u'], input='', text=True, capture_output=True, timeout=8) +assert result.returncode != 0 +print('protected') +CHECK""", user='runtime') + assert protection.stdout == 'protected\n' + check('actual_runtime_code_ownership_and_privileged_account_denial') + if config.get('verify_system_packages'): + seed = vm.commands.run("""python3 -I -S - <<'CHECK' +from pathlib import Path +import hashlib,json,os +root = Path('/opt/agents-runtime') +for path in [root, root/'system-root.tar.gz', root/'system-root.json', Path('/usr/local/bin/agents-api-tool-root')]: + stat = path.stat() + assert stat.st_uid == 0 and stat.st_mode & 0o022 == 0 + assert not os.access(path, os.W_OK) +assert Path('/usr/local/bin/agents-api-tool-root').stat().st_mode & 0o777 == 0o555 +with (root/'system-root.tar.gz').open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() +assert json.loads((root/'system-root.json').read_text()) == { + 'version': 1, 'sha256': digest, 'size_bytes': (root/'system-root.tar.gz').stat().st_size} +print(digest) +CHECK""", user='runtime') + record['system_seed_sha256'] = seed.stdout.strip() + check('finalized_system_seed_and_launcher_are_immutable_and_verified') + + for resource in ['/agents/sessions/' + session.id, '/agents/environments/' + eid]: + assert http.get(base + '/v1' + resource, headers={**headers, 'Authorization': 'Bearer ' + tokens[1]}).status_code == 404 + marker, memory = secrets.token_hex(24), secrets.token_hex(24) + expected_files = {p: len(body) for p, body in initial_expected.items()} + if config.get('verify_environment_setup'): + expected_files.update({'/workspace/setup-once': 11, '/workspace/setup-version': 6}) + if config.get('verify_system_packages'): + for path in ['/workspace/system-library.c', '/workspace/system-library']: + expected_files[path] = len(read(vm, path)) + for name, data in [('input.txt', marker.encode()), ('binary.bin', bytes(range(256))), ('empty', b'')]: + expected_files['/workspace/' + name] = upload(eid, '/workspace/' + name, data) + source = client.files.create(file=('source.bin', b'source-bytes\x00\xff'), purpose='user_data') + sources.append(source.id) + files.create(eid, type='file_id', file_id=source.id, path='/workspace/source.bin') + expected_files['/workspace/source.bin'] = len(b'source-bytes\x00\xff') + _, continuation = verify_environment_files(client, http, eid, '/workspace', expected_files) + verify_file_tenant_isolation(client, foreign, http, eid, '/workspace', continuation, list(expected_files)) + check('public_session_binding_files_bytes_sort_pages_and_tenant_isolation') + script = 'from pathlib import Path\np=Path("/workspace/outputs");p.mkdir(exist_ok=True)\n(p/"a.bin").write_bytes(Path("/workspace/binary.bin").read_bytes());(p/"empty").write_bytes(b"")\nprint(Path("/workspace/input.txt").read_text())\n' + if initial_expected: + script = 'from pathlib import Path\n' + assert_initial_bytes_script(initial_expected) + script + if config.get('verify_environment_setup'): + script = native_setup_script(setup_marker, system_packages=config.get('verify_system_packages', False)) + script + execution_prompt = 'Run exactly `python3 /workspace/publish.py`.' + if config.get('verify_system_packages') and config['engine'] == 'codex': + script += '''import sys +assert str(Path.cwd()) == sys.argv[1] +Path('/workspace/cwd-' + Path.cwd().name).write_text(str(Path.cwd())) +''' + execution_prompt = ( + 'Make two separate native shell tool calls. Set the tool workdir parameter; do not use cd. ' + 'First use workdir /environment/workspace with exactly ' + '`python3 /workspace/publish.py /environment/workspace`. ' + 'Then use workdir /environment/workspace/setup-sub with exactly ' + '`python3 /workspace/publish.py /environment/workspace/setup-sub`. Do not modify the script.') + upload(eid, '/workspace/publish.py', script) + first = prompt(session.id, execution_prompt + ' Remember this conversation-only marker: ' + memory, 1) + if config.get('verify_system_packages') and config['engine'] == 'codex': + commands = [item for item in sessions.items.list(session.id, limit=100).data + if item.type == 'command_execution' and item.turn_id == first.id] + for cwd in ['/environment/workspace', '/environment/workspace/setup-sub']: + assert read(vm, '/workspace/cwd-' + Path(cwd).name).decode() == cwd + assert any(item.cwd == cwd and item.exit_code == 0 for item in commands), cwd + check('real_native_default_workspace_and_subdirectory_preserved') + identity = native_id(session.id) + assert identity + expected_artifacts = {first.id: {'/workspace/outputs/a.bin': bytes(range(256)), '/workspace/outputs/empty': b''}} + verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) + committed = {item.id: item.to_dict() for item in sessions.items.list(session.id, limit=100).data} + check('real_native_execution_and_immutable_artifacts_sdk_http') + if initial_expected: + check('template_initial_files_exact_native_bytes_and_frozen_source_deletion') + upload(eid, '/workspace/initial-inline.bin', b'retained-user-change') + if config.get('verify_environment_setup'): + check('template_real_registry_packages_ordered_setup_and_native_visibility') + upload(eid, '/workspace/setup-once', b'preserved-setup-change') + # The native isolation script is the same actual-tool probe used to qualify all profiles. + history = config['native_history_root'] + '/e2b-isolation-canary' + vm.files.write(history, 'synthetic-private-history', user='runtime') + vm.files.write('/environment/staging/canary', 'synthetic-private-staging', user='runtime') + auth = json.loads(vm.files.read('/home/runtime/.parsar/parsar-daemon/default/auth.json', user='runtime')) + fixture = {'outer_pid_namespace': vm.commands.run('readlink /proc/self/ns/pid', user='root').stdout.strip(), + 'history_path': history, + 'secret_hashes': [hashlib.sha256(value.encode()).hexdigest() for value in [model_key, auth['runner_credential']]]} + upload(eid, '/workspace/isolation-fixture.json', json.dumps(fixture)) + isolation = Path(__file__).with_name('e2b_native_isolation.py').read_text() + upload(eid, '/workspace/isolation.py', isolation) + second = prompt(session.id, 'Run exactly `python3 /workspace/isolation.py`. Do not modify it.', 2) + assert json.loads(read(vm, '/workspace/isolation-result.json'))['passed'] + expected_artifacts[second.id] = expected_artifacts[first.id] + verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) + check('real_native_credential_history_process_and_envd_isolation') + long_script = 'from pathlib import Path\nimport os,time\np=Path("/workspace");f=(p/"starts").open("a");f.write("started\\n");f.flush();os.fsync(f.fileno());f.close()\nwhile True:\n (p/"heartbeat").write_text(str(time.time_ns()))\n time.sleep(.2)\n' + upload(eid, '/workspace/long.py', long_script) + sessions.events.create(session.id, events=[message('Run exactly `python3 /workspace/long.py` and wait. Do not background it.')], idempotency_key='cancel-work') + until(lambda: exists(vm, '/workspace/heartbeat')) + for _ in range(2): + sessions.events.create(session.id, events=[{'type': 'agent.session.input.cancel'}], idempotency_key='cancel') + waitturn(session.id, 3, 'cancelled') + heartbeat = read(vm, '/workspace/heartbeat') + time.sleep(2) + assert read(vm, '/workspace/heartbeat') == heartbeat + verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) + check('public_cancel_retry_stops_effects_without_publishing_cancelled_outputs') + for number, fault in [(4, 'core'), (5, 'runtime')]: + request = [message('Run exactly `python3 /workspace/long.py` once and wait. Do not restart it.')] + before = read(vm, '/workspace/starts') + sessions.events.create(session.id, events=request, idempotency_key='crash-' + fault) + until(lambda: read(vm, '/workspace/starts') != before) + count = read(vm, '/workspace/starts') + if fault == 'core': + stop(crash=True) + start() + else: + restart_runtime(vm) + waitturn(session.id, number, 'failed') + connected(eid) + def stable(): + value = read(vm, '/workspace/heartbeat') + time.sleep(.6) + return value if read(vm, '/workspace/heartbeat') == value else False + stopped = until(stable, 45) + sessions.events.create(session.id, events=request, idempotency_key='crash-' + fault) + time.sleep(1) + assert read(vm, '/workspace/starts') == count and read(vm, '/workspace/heartbeat') == stopped + assert len(sessions.turns.list(session.id).data) == number + assert native_id(session.id) == identity + current = {item.id: item.to_dict() for item in sessions.items.list(session.id, limit=100).data} + assert all(current[key] == value for key, value in committed.items()) + verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) + check(fault + '_crash_queries_history_artifacts_and_no_automatic_or_retry_replay') + prompt(session.id, 'Reply with the conversation-only marker I asked you to remember. Do not run any tools or previous commands.', 6) + answers = [item for item in sessions.items.list(session.id, order='asc', limit=100).data if item.type == 'message' and item.role == 'assistant'] + assert memory in ''.join(part.text for part in answers[-1].content if part.type == 'output_text') + assert native_id(session.id) == identity + check('same_native_history_continues_after_core_and_runtime_recovery') + disabled_environment = {'type': 'openai_hosted', 'network': {'access': 'disabled'}} + if public_templates: + disabled_environment = {'type': 'openai_hosted', 'environment_template_id': disabled_template} + inline_expected = {} + if config.get('verify_initial_files'): + disabled_environment, inline_source, inline_expected = initial_files(client, foreign, http, agent) + sources.append(inline_source) + assert read(vm, '/workspace/initial-inline.bin') == b'retained-user-change' + check('recovery_preserves_user_changes_without_reinstalling_initial_files') + if config.get('verify_environment_setup'): + assert read(vm, '/workspace/setup-once') == b'preserved-setup-change' + inline_setup, inline_setup_marker = setup_configuration(system_packages=config.get('verify_system_packages', False)) + disabled_environment = attach_setup(client, foreign, http, disabled_environment, inline_setup) + check('recovery_does_not_repeat_completed_setup') + disabled = sessions.create(agent=agent, environment=disabled_environment) + if inline_expected: + verify_initial_snapshot(client, http, disabled, inline_expected, inline_source) + sources.remove(inline_source) + assert disabled.environment.id != eid + assert disabled.environment.network.access == 'disabled' + if public_templates: + api.delete(disabled_template) + public_templates.remove(disabled_template) + check('template_inheritance_and_distinct_environment_ownership') + created.append(disabled.id) + connected(disabled.environment.id) + restricted = runtime(disabled.environment.id) + network_script = 'import urllib.request,urllib.error,json\ntry:\n urllib.request.urlopen("https://api.moonshot.cn/v1/models",timeout=8)\nexcept urllib.error.HTTPError as e:\n assert e.code==403,e.code\nexcept (urllib.error.URLError,PermissionError,TimeoutError):pass\nelse:raise AssertionError("native network was allowed")\nopen("/workspace/network-result.json","w").write(json.dumps({"blocked":True}))\n' + if inline_expected: + network_script = 'from pathlib import Path\n' + assert_initial_bytes_script(inline_expected) + network_script + if config.get('verify_environment_setup'): + verify_setup_metadata(client, disabled, inline_setup) + network_script = native_setup_script(inline_setup_marker, system_packages=config.get('verify_system_packages', False)) + network_script + upload(disabled.environment.id, '/workspace/network.py', network_script) + prompt(disabled.id, 'Run exactly `python3 /workspace/network.py`. Do not modify it.', 1) + assert json.loads(read(restricted, '/workspace/network-result.json')) == {'blocked': True} + check('real_model_execution_with_disabled_native_tool_network') + if inline_expected: + check('inline_initial_files_exact_native_bytes_and_frozen_source_deletion') + if config.get('verify_initialization_restart'): + interrupted = sessions.create(agent=agent, environment={'type': 'openai_hosted', 'files': [ + {'type': 'inline', 'path': '/workspace/step-' + str(i), 'data': base64.b64encode(b'startup-data').decode()} + for i in range(3)]}, input='Write /workspace/should-not-run containing executed.') + created.append(interrupted.id) + sql = "SELECT a.initialization FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id WHERE e.session_id='" + interrupted.id + "'" + until(lambda: subprocess.check_output(config['psql_command'] + ['-At', '-c', sql], text=True).strip() == 'running') + response = http.get(base + '/v1/agents/environments/' + interrupted.environment.id + '/files', headers=headers) + assert response.status_code in (409, 503), response.status_code + assert sessions.turns.list(interrupted.id).data == [] and not native_id(interrupted.id) + stop(crash=True) + start() + until(lambda: client.beta.agents.environments.retrieve(interrupted.environment.id).status == 'failed') + assert sessions.turns.list(interrupted.id).data == [] and not native_id(interrupted.id) + check('actual_core_restart_during_initialization_fails_without_native_execution_or_replay') + record['passed'] = True +except BaseException: + record['passed'] = False + record['failure'] = traceback.format_exc() +finally: + cleanup_errors = [] + if process is not None and process.poll() is None: + for template_id in public_templates: + try: + client.beta.agents.environments.templates.delete(template_id) + except Exception: + cleanup_errors.append('template_delete_failed') + for source_id in sources: + try: + client.files.delete(source_id) + except Exception: + cleanup_errors.append('source_delete_failed') + for sid in created: + try: + sessions.delete(sid) + except Exception: + cleanup_errors.append('public_delete_failed') + try: + until(lambda: not owned(), 90) + except Exception: + cleanup_errors.append('owned_cleanup_not_confirmed') + stop() + for handle in handles: + handle.close() + # A failed deployment cannot leave billable instances behind. Record fallback + # reclamation separately so it cannot masquerade as passing Core cleanup. + for allocation in owned(): + Sandbox.kill(allocation.sandbox_id, api_key=e2b_key) + cleanup_errors.append('direct_cleanup_required') + record.update(cleanup_errors=cleanup_errors, elapsed=time.time() - record['started']) + for path in run.iterdir(): + if path.is_file(): + text = path.read_text() + for secret in [e2b_key, model_key, *tokens]: + text = text.replace(secret, '[REDACTED]') + path.write_text(text) + (run / 'result.json').write_text(json.dumps(record, indent=2)) + (root / (config['engine'] + '-latest.json')).write_text(json.dumps({'run': str(run), 'passed': record['passed'], 'cleanup_errors': cleanup_errors})) + print(json.dumps(record, indent=2), flush=True) + sys.exit(0 if record['passed'] and not cleanup_errors else 1) diff --git a/services/agents-api/tests/official_environment_activity.py b/services/agents-api/tests/official_environment_activity.py new file mode 100644 index 000000000..9ba3070f5 --- /dev/null +++ b/services/agents-api/tests/official_environment_activity.py @@ -0,0 +1,252 @@ +"""Accept public reads/SSE for privately provisioned, caller-connected native work.""" + +import importlib.metadata +import json +import os +from pathlib import Path +import sys +import threading +import time + +sys.dont_write_bytecode = True + +import httpx2 +from openai import NotFoundError, OpenAI + + +def main(): + settings = json.load(sys.stdin) + base, token, foreign = (settings[key] for key in ("base", "token", "foreign_token")) + session_id, agent_id = settings["session_id"], settings["agent_id"] + directory = Path(settings["evidence"]) + os.umask(0o077) + root = Path(__file__).resolve().parents[3] + pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] + assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + expected_environment = { + "id": settings["environment_id"], "type": "self_hosted", "capability_directories": [], + "remote_url": settings["remote_url"], "workspace_directory": settings["workspace_directory"], + } + action = {"type": "environment_connection", "environment_id": expected_environment["id"]} + proof = {"scope": "private Session provisioning/input reservation; public read/SSE acceptance; non-text initial and mixed self-hosted input remain gated", + "sdk_commit": pin["commit"], "sdk_version": distribution.version, "snapshots": {}} + observations = {"sdk": [], "raw": []} + ready = {name: threading.Event() for name in observations} + waiting = {name: threading.Event() for name in observations} + done = {name: threading.Event() for name in observations} + failures = [] + lock = threading.Lock() + + def write_private(name, value): + text = json.dumps(value, indent=2) + assert token not in text and foreign not in text, "caller credential appeared in public evidence" + temporary = directory / (name + ".tmp") + temporary.write_text(text) + temporary.chmod(0o600) + temporary.replace(directory / (name + ".json")) + + def client(key): + return OpenAI(api_key=key, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=30)) + + def check_session(value, status): + assert value["id"] == session_id and value["agent"]["id"] == agent_id + assert value["object"] == "agent.session" and value["environment"] == expected_environment + assert value["status"] == status and value["error"] is None + assert value["required_actions"] == ([action] if status == "requires_action" else []) + assert value["vault_ids"] == [] and isinstance(value["metadata"], dict) + assert isinstance(value["last_active_at"], int) and isinstance(value["created_at"], int) + + def await_observers(signals, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + if failures: + raise AssertionError("public event observer failed") from failures[0] + if all(signal.is_set() for signal in signals.values()): + return + time.sleep(0.025) + raise AssertionError(label + " timed out") + + def observe(name): + completed = set() + + def accept(value): + with lock: + observations[name].append(value) + kind = value["type"] + assert kind != "error" and kind not in ("agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") + if kind == "agent.session.requires_action": + check_session(value["session"], "requires_action") + waiting[name].set() + if kind == "agent.session.turn.completed": + completed.add(value["turn"]["id"]) + return kind == "agent.session.idle" and len(completed) == 2 + + try: + if name == "sdk": + with client(token) as api: + with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: + ready[name].set() + for event in stream: + if accept(event.to_dict()): + return + else: + with httpx2.Client(trust_env=False, timeout=450) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream" + ready[name].set() + for line in response.iter_lines(): + if line.startswith("data: ") and accept(json.loads(line[6:])): + return + raise AssertionError("live stream ended before both native Turns completed") + except BaseException as error: + with lock: + failures.append(error) + finally: + done[name].set() + + try: + with client(token) as api, client(foreign) as stranger, httpx2.Client( + base_url=base + "/v1", trust_env=False, timeout=15, + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1", "Host": "untrusted.example"}, + ) as raw: + sessions = api.beta.agents.sessions + + def snapshot(name, status): + retrieved = sessions.retrieve(session_id) + check_session(retrieved.to_dict(), status) + listed = list(sessions.list(agent_id=agent_id, limit=1)) + assert listed == [retrieved] + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 + value = response.json() + check_session(value, status) + page = raw.get("/agents/sessions", params={"agent_id": agent_id, "limit": 1}) + assert page.status_code == 200 and page.json() == {"data": [value], "has_more": False} + proof["snapshots"][name] = value + return value + + initial = snapshot("initial", "idle") + assert initial["created_at"] == initial["last_active_at"] and initial["usage"] is None + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + assert list(stranger.beta.agents.sessions.list(agent_id=agent_id)) == [] + for operation in ( + lambda: stranger.beta.agents.sessions.retrieve(session_id), + lambda: stranger.beta.agents.sessions.events.stream(session_id), + lambda: stranger.beta.agents.sessions.turns.list(session_id), + lambda: stranger.beta.agents.sessions.items.list(session_id), + ): + try: + operation() + except NotFoundError: + pass + else: + raise AssertionError("foreign tenant accessed the target Session") + for suffix in ("", "/events", "/turns", "/items"): + assert raw.get("/agents/sessions/" + session_id + suffix, + headers={"Authorization": "Bearer " + foreign}).status_code == 404 + create = raw.post("/agents/sessions", json={"agent": {"model": "MiniMax-M3"}, "input": [{"role": "user", "content": [ + {"type": "input_image", "image_url": "https://example.com/image.png"}]}], + "environment": {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]}}) + assert create.status_code == 400 + submit = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [{ + "type": "agent.session.input.message", "input": [{"role": "user", "content": [ + {"type": "input_text", "text": "Mixed public input must remain disabled."}]}]}, + {"type": "agent.session.input.cancel"}]}) + assert submit.status_code == 400 + proof["tenant_isolation"] = True + proof["unsupported_nontext_initial_and_mixed_input_rejected"] = True + + for name in observations: + threading.Thread(target=observe, args=(name,), daemon=True).start() + await_observers(ready, 25, "SDK and raw live subscriptions") + write_private("ready", {}) + await_observers(waiting, 25, "pre-Turn connection action") + pending = snapshot("waiting", "requires_action") + assert pending["usage"] is None + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + assert raw.get("/agents/sessions/" + session_id + "/turns").json()["data"] == [] + assert raw.get("/agents/sessions/" + session_id + "/items").json()["data"] == [] + with lock: + for values in observations.values(): + assert len(values) == 1 and values[0]["type"] == "agent.session.requires_action" + assert values[0]["session"] == pending + assert set(values[0]) == {"type", "event_id", "session"} + write_private("waiting", {"remote_url": pending["environment"]["remote_url"], + "environment_id": pending["environment"]["id"]}) + await_observers(done, 420, "first and resumed native Turns") + + final = snapshot("final", "idle") + turns = list(sessions.turns.list(session_id, order="asc")) + assert len(turns) == 2 and all(turn.status == "completed" for turn in turns) + turn_ids = [turn.id for turn in turns] + items = list(sessions.items.list(session_id, limit=100, order="asc")) + proof["turns"] = [turn.to_dict() for turn in turns] + proof["items"] = [item.to_dict() for item in items] + for turn, phase in zip(turns, ("first", "resumed")): + assert sessions.turns.retrieve(turn.id, session_id=session_id) == turn + group = [item.to_dict() for item in items if item.turn_id == turn.id] + commands = [item for item in group if item["type"] == "command_execution" + and "remote-stdout:" + phase in item.get("output", "") + and "remote-stderr:" + phase in item.get("output", "")] + assert len(commands) == 1 + command = commands[0] + assert command["exit_code"] == 7 and command["cwd"] == settings["workspace_directory"] + assert "remote-stdout:" + phase in command["output"] and "remote-stderr:" + phase in command["output"] + text = "\n".join(part.get("text", "") for item in group + if item["type"] == "message" and item.get("role") == "assistant" + for part in item["content"]) + assert settings["memory"] in text and settings["instruction"] in text + assert "WRONG_LOCAL_INSTRUCTIONS" not in text + assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] + assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] + + for values in observations.values(): + ids = [event["event_id"] for event in values] + assert len(ids) == len(set(ids)) + types = [event["type"] for event in values] + request = types.index("agent.session.requires_action") + connected = types.index("agent.session.environment.connected") + cleared = types.index("agent.session.idle") + first_turn = types.index("agent.session.turn.created") + assert request < connected < cleared < first_turn + assert values[request]["session"] == pending + clear = values[cleared] + assert set(clear) == {"type", "event_id", "session"} + check_session(clear["session"], "idle") + assert clear["session"]["usage"] is None + assert clear["session"]["last_active_at"] == pending["last_active_at"] + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == turn_ids + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == turn_ids + check_session(values[-1]["session"], "idle") + for event in values: + if event["type"].startswith("agent.session.environment."): + assert set(event) == {"type", "event_id", "session_id", "environment"} + assert event["environment"]["id"] == expected_environment["id"] + assert event["environment"]["error"] is None + assert [event["event_id"] for event in observations["sdk"]] == [event["event_id"] for event in observations["raw"]] + with client(token) as recovered: + restored = recovered.beta.agents.sessions + assert restored.retrieve(session_id).to_dict() == final + assert list(restored.turns.list(session_id, order="asc")) == turns + assert list(restored.items.list(session_id, limit=100, order="asc")) == items + proof["client_reconnect_recovery"] = True + proof["pre_turn_action_cleared_before_turn"] = True + proof["status"] = "public_read_sse_and_remote_first_resumed_verified" + print("Pinned SDK/raw HTTP/live SSE and remote first/resumed recovery passed.", flush=True) + finally: + with lock: + proof["sdk_events"] = list(observations["sdk"]) + proof["raw_events"] = list(observations["raw"]) + write_private("public-environment-proof", proof) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_environment_events.py b/services/agents-api/tests/official_environment_events.py new file mode 100644 index 000000000..631f0555f --- /dev/null +++ b/services/agents-api/tests/official_environment_events.py @@ -0,0 +1,44 @@ +"""Validate retained connection observations against the pinned SDK and raw shape.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +from openai._models import validate_type +from openai.types.beta.agent_session_environment_connected_event import AgentSessionEnvironmentConnectedEvent +from openai.types.beta.agent_session_environment_disconnected_event import AgentSessionEnvironmentDisconnectedEvent + + +def main(): + root = Path(__file__).resolve().parents[3] + pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == "3.13.0" + assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + events = json.loads(Path(sys.argv[1]).read_text()) + models = {"connected": AgentSessionEnvironmentConnectedEvent, + "disconnected": AgentSessionEnvironmentDisconnectedEvent} + assert len(events) >= 3 + ids = set() + previous = None + for event in events: + assert set(event) == {"type", "event_id", "session_id", "environment"} + state = event["environment"] + assert set(state) == {"id", "type", "status", "error"} + assert state["type"] == "self_hosted" and state["error"] is None + status = state["status"] + assert status in models and status != previous + assert event["type"] == "agent.session.environment." + status + parsed = validate_type(type_=models[status], value=event) + assert parsed.environment.id == state["id"] + assert parsed.session_id == event["session_id"] and parsed.turn_id is None + assert event["event_id"] not in ids + ids.add(event["event_id"]) + previous = status + print(f"Pinned SDK and raw Environment event snapshots passed: {len(events)} transitions.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_environment_files.py b/services/agents-api/tests/official_environment_files.py new file mode 100644 index 000000000..dfeaf2f30 --- /dev/null +++ b/services/agents-api/tests/official_environment_files.py @@ -0,0 +1,95 @@ +"""Pinned Files.list checks for a known, unchanged directory of regular files.""" + +from pathlib import PurePosixPath + +from openai import NotFoundError + + +def verify_file_page(value, environment_id, limit): + assert isinstance(value, dict) and isinstance(value.get("data"), list), "Invalid file page" + assert len(value["data"]) <= limit, "File page exceeds the requested limit" + assert value.get("has_more") is None or type(value["has_more"]) is bool, "Invalid has_more" + assert value.get("next") is None or isinstance(value["next"], str), "Invalid next token" + for item in value["data"]: + assert isinstance(item, dict), "Invalid file entry" + assert item.get("environment_id") == environment_id, "Wrong file Environment" + assert item.get("object") == "agent.environment.file", "Wrong file object" + assert isinstance(item.get("path"), str) and item["path"].startswith("/"), "Invalid file path" + assert type(item.get("size_bytes")) is int and item["size_bytes"] >= 0, "Invalid file size" + more = value.get("has_more") is not False and bool(value.get("next")) + assert value.get("has_more") is not True or more, "Missing continuation token" + assert not more or value["data"], "Empty page cannot continue through the pinned SDK" + return more + + +def verify_environment_files(client, http, environment_id, directory, expected): + assert PurePosixPath(directory).is_absolute() and expected, "Expected directory and files required" + assert all(str(PurePosixPath(path).parent) == directory for path in expected), "Use a flat fixture directory" + resource = client.beta.agents.environments.files + endpoint = str(client.base_url).rstrip("/") + "/agents/environments/" + environment_id + "/files" + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + summary, continuation = [], None + for order, limit in (("asc", 1), ("desc", 2), (None, 2)): + params = {"path": directory, "limit": limit} + if order is not None: + params["order"] = order + wanted = sorted(expected, key=lambda path: PurePosixPath(path).parts, reverse=order != "asc") + found, tokens, page_sizes = [], set(), [] + for _ in range(len(expected) + 1): + response = http.get(endpoint, headers=headers, params=params) + assert response.status_code == 200, "Raw Files.list failed" + assert response.headers.get("content-type", "").startswith("application/json"), "Wrong file page content type" + value = response.json() + more = verify_file_page(value, environment_id, limit) + found.extend(value["data"]) + page_sizes.append(len(value["data"])) + if not more: + break + token = value["next"] + assert token not in tokens, "Files.list repeated a continuation token" + tokens.add(token) + if order == "asc" and continuation is None: + continuation = token + params["page"] = token + else: + raise AssertionError("Files.list did not terminate") + assert [item["path"] for item in found] == wanted, "Raw file order, filtering or completeness differs" + assert {item["path"]: item["size_bytes"] for item in found} == expected, "Raw file sizes differ" + + params.pop("page", None) + page = resource.list(environment_id, **params) + sdk_files, sdk_tokens = [], set() + for _ in range(len(expected) + 1): + more = verify_file_page(page.to_dict(), environment_id, limit) + sdk_files.extend(item.to_dict() for item in page.data) + assert page.has_next_page() == more, "SDK continuation disagrees with the wire page" + if not more: + break + assert page.next not in sdk_tokens, "SDK repeated a continuation token" + sdk_tokens.add(page.next) + page = page.get_next_page() + else: + raise AssertionError("SDK Files.list did not terminate") + assert sdk_files == found, "SDK file pages differ from raw HTTP" + summary.append({"order": order or "default", "limit": limit, "page_sizes": page_sizes, + "files": found}) + assert continuation, "The fixture must exercise continuation" + return summary, continuation + + +def verify_file_tenant_isolation(client, other, http, environment_id, directory, page, private_paths): + endpoint = str(client.base_url).rstrip("/") + "/agents/environments/" + environment_id + "/files" + for params in ({"path": directory, "limit": 1, "order": "asc"}, + {"path": directory, "limit": 1, "order": "asc", "page": page}): + response = http.get(endpoint, params=params, headers={ + "Authorization": "Bearer " + other.api_key, "OpenAI-Beta": "agents=v1"}) + assert response.status_code == 404, "Foreign tenant can access Files.list" + assert isinstance(response.json().get("error"), dict), "Missing safe error envelope" + assert all(secret not in response.text for secret in ( + client.api_key, other.api_key, environment_id, *private_paths)), "Foreign response exposes private data" + try: + other.beta.agents.environments.files.list(environment_id, **params) + except NotFoundError: + pass + else: + raise AssertionError("Foreign tenant can access SDK Files.list") diff --git a/services/agents-api/tests/official_environment_files_create.py b/services/agents-api/tests/official_environment_files_create.py new file mode 100644 index 000000000..0ebc82af3 --- /dev/null +++ b/services/agents-api/tests/official_environment_files_create.py @@ -0,0 +1,84 @@ +"""Opt-in inline/source upload acceptance for a preconfigured local Environment. + +Private Environment provisioning and subsequent real-model execution belong to +the invoking native fixture. This does not prove public hosted Session admission. +""" + +import base64 +import hashlib +import importlib.metadata +import json +from pathlib import Path +import sys + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_files import verify_environment_files, verify_file_tenant_isolation +from official_environment_files_native import caller_token +from official_source_files import verify_source_files + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"], "Install the pinned SDK" + token, foreign_token = (caller_token(value) for value in settings["callers"]) + assert token != foreign_token + environment = settings["environment_id"] + base = settings["base"].rstrip("/") + "/v1" + endpoint = base + "/agents/environments/" + environment + "/files" + directory = "/workspace/uploads" + cases = { + "empty.bin": b"", + "binary.bin": bytes(range(256)), + "chunked.bin": bytes(range(256)) * 8193, + "large.bin": b"x" * (50 << 20), + "model-input.txt": settings["model_input"].encode(), + } + expected, receipts = {}, [] + with httpx2.Client(trust_env=False, timeout=215) as http: + client = OpenAI(api_key=token, base_url=base, max_retries=0, _strict_response_validation=True, http_client=http) + foreign = OpenAI(api_key=foreign_token, base_url=base, max_retries=0, _strict_response_validation=True, http_client=http) + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + for index, (name, content) in enumerate(cases.items()): + path = directory + "/" + name + body = {"type": "inline", "data": base64.b64encode(content).decode(), "path": path} + if index % 2: + response = http.post(endpoint, headers=headers, json=body) + assert response.status_code == 200, "Raw inline upload failed" + receipt = response.json() + else: + receipt = client.beta.agents.environments.files.create(environment, **body).to_dict() + assert receipt == {"environment_id": environment, "object": "agent.environment.file", "path": path, "size_bytes": len(content)}, "Wrong upload metadata" + expected[path] = len(content) + receipts.append({"path": path, "size": len(content), "sha256": hashlib.sha256(content).hexdigest()}) + source_expected, source_receipts, source_proof = verify_source_files(client, foreign, http, environment, directory, cases) + expected.update(source_expected) + receipts.extend(source_receipts) + pages, continuation = verify_environment_files(client, http, environment, directory, expected) + verify_file_tenant_isolation(client, foreign, http, environment, directory, continuation, list(expected)) + target = directory + "/model-input.txt" + for body in ( + {"type": "inline", "data": "?", "path": target}, + {"type": "inline", "data": "", "path": "/workspace/../escape"}, + {"type": "inline", "data": "", "path": "/environment/staging/canary"}, + {"type": "inline", "data": "", "path": "/workspace/stage-link/canary"}, + ): + response = http.post(endpoint, headers=headers, json=body) + assert response.status_code == 400 and "error" in response.json(), "Invalid/unsupported upload accepted" + response = http.post(endpoint, headers=headers, json={"type": "file_id", "file_id": "missing", "path": target}) + assert response.status_code == 404, "Unknown source accepted" + response = http.post(endpoint, headers={**headers, "Authorization": "Bearer " + foreign_token}, + json={"type": "inline", "data": "", "path": target}) + assert response.status_code == 404, "Foreign tenant upload accepted" + assert token not in response.text and foreign_token not in response.text, "Credential leaked in error" + print(json.dumps({"sdk": pin["sdk_version"], "commit": pin["commit"], "uploads": receipts, "listing": pages, "sources": source_proof, + "limits": ["Private Environment setup", "Other source purposes/expiration/listing unimplemented", "Overwrite metadata/error parity unverified", "Real-model consumption verified by invoking fixture"]})) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_environment_files_native.py b/services/agents-api/tests/official_environment_files_native.py new file mode 100644 index 000000000..2f3356e3c --- /dev/null +++ b/services/agents-api/tests/official_environment_files_native.py @@ -0,0 +1,119 @@ +"""Opt-in live check; stdin supplies engine, base and two tenants with session_id and token_file/token_env.""" + +import importlib.metadata +import json +import os +from pathlib import Path, PurePosixPath +import shlex +import sys +import time +import traceback +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_files import verify_environment_files, verify_file_tenant_isolation + + +UNVERIFIED = [ + "Recursive traversal, directory entries, symlinks and missing paths are unspecified by the pinned SDK.", + "The scope when path is omitted is not asserted.", + "Default limit, changed-filter cursors and exact invalid-parameter errors are not asserted.", + "The operator must qualify the real provider, native engine and isolated placement separately.", + "This fixture uses existing Sessions; it does not qualify Session creation or executor installation.", + "Generated fixture directories remain in the caller-owned workspaces for independent inspection.", +] + + +def caller_token(settings): + assert ("token_file" in settings) != ("token_env" in settings), "Choose one private token source" + if "token_file" in settings: + location = Path(settings["token_file"]).expanduser() + assert location.is_absolute() and location.stat().st_mode & 0o077 == 0, "Token file must be private" + token = location.read_text().strip() + else: + token = os.environ[settings["token_env"]].strip() + assert token, "Empty caller token" + return token + + +def generate_files(client, session_id, label): + sessions = client.beta.agents.sessions + session = sessions.retrieve(session_id) + assert session.status == "idle" and not session.required_actions, "An idle prepared Session is required" + assert session.environment.type == "self_hosted", "A self-hosted Environment is required" + environment_id = session.environment.id + workspace = PurePosixPath(session.environment.workspace_directory) + assert workspace.is_absolute(), "Absolute workspace required" + assert client.beta.agents.environments.retrieve(environment_id).status == "connected", "Connect the Environment first" + directory = str(workspace / ("files-list-" + label + "-" + uuid.uuid4().hex)) + contents = {"A.txt": "A\n", "a-b.txt": "three\n", "a.txt": "fourteen-bytes\n", "z.txt": "last\n"} + expected = {directory + "/" + name: len(content.encode()) for name, content in contents.items()} + sibling = directory + "-sibling" + sibling_expected = {sibling + "/one.txt": 3, sibling + "/two.txt": 3} + command = "mkdir -- " + shlex.quote(directory) + " " + shlex.quote(sibling) + for name, content in contents.items(): + command += " && printf %s " + shlex.quote(content) + " > " + shlex.quote(directory + "/" + name) + for name in ("one", "two"): + command += " && printf %s " + name + " > " + shlex.quote(sibling + "/" + name + ".txt") + prompt = "Use your native shell tool to execute the following command exactly once. Create no additional files in that directory. Do not delegate. Reply done only after the command succeeds.\n" + command + before = {turn.id for turn in sessions.turns.list(session_id)} + sessions.events.create(session_id, events=[{"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], idempotency_key=str(uuid.uuid4())) + deadline = time.monotonic() + 240 + while time.monotonic() < deadline: + turns = [turn for turn in sessions.turns.list(session_id) if turn.id not in before] + assert len(turns) <= 1, "One input created multiple Turns" + if turns: + assert turns[0].status not in ("failed", "cancelled"), "File generation Turn failed" + if turns[0].status == "completed" and sessions.retrieve(session_id).status == "idle": + return {"session_id": session_id, "environment_id": environment_id, "turn_id": turns[0].id, + "directory": directory, "expected": expected, + "sibling_directory": sibling, "sibling_expected": sibling_expected} + time.sleep(0.2) + raise AssertionError("File generation Turn did not complete") + + +def main(): + settings = json.load(sys.stdin) + assert settings["engine"] in ("codex", "claude_sdk"), "Select one qualified native engine" + assert len(settings["tenants"]) == 2, "Two independent tenant Sessions are required" + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"], "Install the pinned SDK" + tokens = [caller_token(tenant) for tenant in settings["tenants"]] + assert tokens[0] != tokens[1], "Distinct tenant credentials required" + base = settings["base"].rstrip("/") + "/v1" + with httpx2.Client(trust_env=False, timeout=30) as http: + clients = [OpenAI(api_key=token, base_url=base, max_retries=0, _strict_response_validation=True, + http_client=http) for token in tokens] + generated = [generate_files(client, tenant["session_id"], str(index)) + for index, (client, tenant) in enumerate(zip(clients, settings["tenants"]))] + assert generated[0]["environment_id"] != generated[1]["environment_id"], "Distinct Environments required" + for index, (client, fixture) in enumerate(zip(clients, generated)): + before = [turn.to_dict() for turn in client.beta.agents.sessions.turns.list(fixture["session_id"])] + fixture["list_checks"], page = verify_environment_files( + client, http, fixture["environment_id"], fixture["directory"], fixture["expected"]) + fixture["sibling_checks"], _ = verify_environment_files( + client, http, fixture["environment_id"], fixture["sibling_directory"], fixture["sibling_expected"]) + verify_file_tenant_isolation(client, clients[1 - index], http, fixture["environment_id"], + fixture["directory"], page, fixture["expected"] | fixture["sibling_expected"]) + assert [turn.to_dict() for turn in client.beta.agents.sessions.turns.list(fixture["session_id"])] == before, "Files.list changed Turns" + fixture["cross_tenant_denied"] = True + proof = {"engine": settings["engine"], "sdk_version": distribution.version, "sdk_commit": pin["commit"], + "scope": "Public input-generated flat files, SDK/raw listing, sorting, pagination and two-tenant isolation", + "fixtures": generated, "unverified": UNVERIFIED} + serialized = json.dumps(proof, indent=2) + assert all(token not in serialized for token in tokens), "Credential in acceptance evidence" + print(serialized) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + locations = " -> ".join(f"{Path(frame.filename).name}:{frame.lineno}" for frame in traceback.extract_tb(error.__traceback__)) + raise SystemExit(f"Files.list acceptance failed ({type(error).__name__} at {locations}); response bodies withheld.") from None diff --git a/services/agents-api/tests/official_environment_initial_failure.py b/services/agents-api/tests/official_environment_initial_failure.py new file mode 100644 index 000000000..80a6aadc2 --- /dev/null +++ b/services/agents-api/tests/official_environment_initial_failure.py @@ -0,0 +1,88 @@ +"""Verify public pre-Turn failure reads/SSE after private initial Session setup.""" + +import importlib.metadata +import json +from pathlib import Path +import sys +import threading + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + base, session_id = settings["base"], settings["session_id"] + headers = {"Authorization": "Bearer " + settings["token"], "OpenAI-Beta": "agents=v1"} + observations, failures = {}, [] + + def client(): + return OpenAI(api_key=settings["token"], base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=20)) + + def check(value, status): + assert value["id"] == session_id and value["object"] == "agent.session" and value["status"] == status + assert value["environment"]["id"] == settings["environment_id"] and value["usage"] is None + action = {"type": "environment_connection", "environment_id": settings["environment_id"]} + assert value["required_actions"] == ([action] if status == "requires_action" else []) + if status == "failed": + assert value["error"] == "The initial input timed out waiting for the environment connection." + else: + assert value["error"] is None + assert "private-input-marker" not in json.dumps(value) + + def observe(name): + try: + if name == "sdk": + with client() as api, api.beta.agents.sessions.events.stream(session_id) as stream: + (Path(settings["directory"]) / "sdk-ready").touch() + event = next(iter(stream)).to_dict() + else: + with httpx2.Client(trust_env=False, timeout=20) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", headers=headers) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + (Path(settings["directory"]) / "raw-ready").touch() + event = next(json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) + assert set(event) == {"type", "event_id", "session"} and event["type"] == "agent.session.failed" + check(event["session"], "failed") + observations[name] = event + except BaseException as error: + failures.append(error) + + with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=20, headers=headers) as raw: + check(api.beta.agents.sessions.retrieve(session_id).to_dict(), "requires_action") + check(raw.get("/agents/sessions/" + session_id).json(), "requires_action") + workers = [threading.Thread(target=observe, args=(name,), daemon=True) for name in ("sdk", "raw")] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=30) + assert not worker.is_alive(), "failure observer timed out" + if failures: + raise AssertionError("failure observer failed") from failures[0] + assert observations["sdk"] == observations["raw"] + current = api.beta.agents.sessions.retrieve(session_id).to_dict() + check(current, "failed") + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 and response.headers["cache-control"] == "no-store" and response.json() == current + assert observations["sdk"]["session"] == current + listed = list(api.beta.agents.sessions.list(agent_id=current["agent"]["id"])) + assert len(listed) == 1 and listed[0].to_dict() == current + assert list(api.beta.agents.sessions.turns.list(session_id)) == [] + assert list(api.beta.agents.sessions.items.list(session_id)) == [] + environment = api.beta.agents.environments.retrieve(settings["environment_id"]) + assert environment.status == "pending" + for suffix in ("", "/events", "/turns", "/items"): + assert raw.get("/agents/sessions/" + session_id + suffix, headers={"Authorization": "Bearer " + settings["foreign_token"]}).status_code == 404 + print(json.dumps({"status": "private_initial_failure_public_reads_and_live_events_verified", "session_id": session_id, + "event_id": observations["sdk"]["event_id"], "sdk_events": 1, "raw_events": 1, "turns": 0})) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_environment_initial_files.py b/services/agents-api/tests/official_environment_initial_files.py new file mode 100644 index 000000000..a5cbfca70 --- /dev/null +++ b/services/agents-api/tests/official_environment_initial_files.py @@ -0,0 +1,48 @@ +"""Real-deployment checks for confidential initial files and frozen metadata.""" +import base64 +import json +import secrets + + +def initial_files(client, foreign, http, agent, template_id=None): + inline = secrets.token_bytes(40) + source_body = bytes(range(256)) + source = client.files.create(file=('initial.bin', source_body), purpose='user_data') + files = [{'type': 'inline', 'path': '/workspace/initial-inline.bin', + 'data': base64.b64encode(inline).decode()}, + {'type': 'file_id', 'path': '/workspace/initial-source.bin', 'file_id': source.id}] + endpoint = str(client.base_url).rstrip('/') + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} + attempted = http.post(endpoint + '/agents/sessions', headers=foreign_headers, + json={'agent': agent, 'environment': {'type': 'openai_hosted', 'files': [files[1]]}}) + assert attempted.status_code == 404 and source.id not in attempted.text + if template_id: + response = client.beta.agents.environments.templates.with_raw_response.update(template_id, files=files) + body = response.http_response.json() + assert body['files'] == [{'type': 'inline', 'path': files[0]['path'], 'size_bytes': len(inline)}, + {'type': 'file_id', 'path': files[1]['path'], 'file_id': source.id}] + assert files[0]['data'] not in json.dumps(body) + listing = client.beta.agents.environments.templates.list().to_dict() + assert files[0]['data'] not in json.dumps(listing) + environment = {'type': 'openai_hosted', 'environment_template_id': template_id} + else: + environment = {'type': 'openai_hosted', 'network': {'access': 'disabled'}, 'files': files} + return environment, source.id, {files[0]['path']: inline, files[1]['path']: source_body} + + +def verify_initial_snapshot(client, http, session, expected, source_id): + metadata = [value.to_dict() for value in session.environment.files] + assert len(metadata) == 2 and len({value['id'] for value in metadata}) == 2 + assert {value['path']: value['size_bytes'] for value in metadata} == {p: len(b) for p, b in expected.items()} + assert metadata[0]['type'] == 'inline' and 'data' not in metadata[0] and 'file_id' not in metadata[0] + assert metadata[1]['type'] == 'file_id' and metadata[1]['file_id'] == source_id + endpoint = str(client.base_url).rstrip('/') + '/agents/environments/' + session.environment.id + response = http.get(endpoint, headers={'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'}) + assert response.status_code == 200 and response.json()['files'] == metadata + client.files.delete(source_id) + + +def assert_initial_bytes_script(expected): + return ''.join('assert Path(' + repr(path) + ').read_bytes() == bytes.fromhex(' + repr(body.hex()) + ')\n' + for path, body in expected.items()) diff --git a/services/agents-api/tests/official_environment_retrieve.py b/services/agents-api/tests/official_environment_retrieve.py new file mode 100644 index 000000000..557685e4b --- /dev/null +++ b/services/agents-api/tests/official_environment_retrieve.py @@ -0,0 +1,107 @@ +"""Public Environment retrieval with the pinned SDK and real PostgreSQL.""" + +import importlib.metadata +import json +from pathlib import Path +import sys +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import NotFoundError, OpenAI + + +def verify_environment(value, environment_id, status=None): + assert set(value) == {"id", "object", "type", "status", "files", "plugins", "skills"} + assert value["id"] == environment_id and value["object"] == "agent.environment" + assert value["type"] == "self_hosted" + assert value["status"] in {"pending", "connected", "disconnected", "expired", "failed"} + if status is not None: + assert value["status"] == status + assert value["files"] == [] and value["plugins"] == [] and value["skills"] == [] + return value + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] + assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + base, token = settings["base"], settings["token"] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as http: + def client(key): + return OpenAI(api_key=key, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + + api, peer, foreign = (client(settings[name]) for name in ("token", "peer_token", "foreign_token")) + if settings.get("phase") == "reopened": + result = {key: settings[key] for key in ("environment_id", "deleted_environment_id", "foreign_environment_id")} + else: + creation = {"agent": {"model": "test-model"}, "environment": { + "type": "self_hosted", "workspace_directory": "/private-workspace-" + str(uuid.uuid4())}} + session = api.beta.agents.sessions.create(**creation) + removed = api.beta.agents.sessions.create(**creation) + other = foreign.beta.agents.sessions.create(**creation) + result = {"environment_id": session.environment.id, "deleted_environment_id": removed.environment.id, + "foreign_environment_id": other.environment.id} + verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "pending") + api.beta.agents.sessions.delete(removed.id) + assert list(api.beta.agents.sessions.turns.list(session.id)) == [] + assert list(api.beta.agents.sessions.items.list(session.id)) == [] + + environment_id = result["environment_id"] + endpoint = base + "/v1/agents/environments/" + environment_id + expected = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + assert peer.beta.agents.environments.retrieve(environment_id).to_dict() == expected + raw = api.beta.agents.environments.with_raw_response.retrieve(environment_id.upper()) + assert raw.status_code == 200 and raw.http_response.json() == expected and raw.parse().to_dict() == expected + for key in (token, settings["peer_token"]): + response = http.get(endpoint, headers=headers | {"Authorization": "Bearer " + key}) + assert response.status_code == 200 and response.json() == expected + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + + def rejected(url, status, code, request_headers=headers, method="GET"): + response = http.request(method, url, headers=request_headers) + assert response.status_code == status + body = response.json() + assert set(body) == {"error"} and body["error"]["code"] == code + assert body["error"]["type"] == ("authentication_error" if status == 401 else "invalid_request_error") + for private in (token, settings["peer_token"], settings["foreign_token"], settings["executor_token"], environment_id): + assert private not in response.text + + for missing in (result["deleted_environment_id"], result["foreign_environment_id"], str(uuid.uuid4())): + rejected(base + "/v1/agents/environments/" + missing, 404, "not_found") + try: + api.beta.agents.environments.retrieve(missing) + except NotFoundError: + pass + else: + raise AssertionError("absent Environment was exposed through the SDK") + for malformed in ("invalid", str(uuid.UUID(int=0))): + rejected(base + "/v1/agents/environments/" + malformed, 400, "invalid_request") + rejected(endpoint, 404, "not_found", headers | {"Authorization": "Bearer " + settings["foreign_token"]}) + for authorization in (None, "Bearer invalid", "Bearer " + settings["executor_token"]): + request_headers = {"OpenAI-Beta": "agents=v1"} + if authorization is not None: + request_headers["Authorization"] = authorization + rejected(endpoint, 401, "invalid_api_key", request_headers) + for beta in (None, "agents=v2"): + request_headers = {"Authorization": "Bearer " + token} + if beta is not None: + request_headers["OpenAI-Beta"] = beta + rejected(endpoint, 400, "invalid_beta_header", request_headers) + rejected(endpoint + "?include=files", 400, "unsupported_parameter") + for method in ("POST", "PATCH", "DELETE"): + rejected(endpoint, 405, "unsupported_operation", method=method) + assert api.beta.agents.environments.retrieve(environment_id).to_dict() == expected + verify_environment(foreign.beta.agents.environments.retrieve(result["foreign_environment_id"]).to_dict(), result["foreign_environment_id"], "pending") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_environment_setup.py b/services/agents-api/tests/official_environment_setup.py new file mode 100644 index 000000000..ed3b225b0 --- /dev/null +++ b/services/agents-api/tests/official_environment_setup.py @@ -0,0 +1,181 @@ +"""Shared real-deployment assertions for confidential environment initialization.""" +import json +import secrets + +from openai import NotFoundError + + +def setup_configuration(proxy=None, *, system_packages=False): + marker = 'setup-private-' + secrets.token_hex(16) + "' $()" + env = {'SETUP_VALUE': marker} + if proxy: + env.update(HTTPS_PROXY=proxy, HTTP_PROXY=proxy, https_proxy=proxy, http_proxy=proxy) + first = """test -f /workspace/initial-inline.bin && mkdir -p /workspace/setup-sub && python3 - <<'SCRIPT' +import os +from pathlib import Path +import packaging +assert packaging.__version__ == '26.0' +assert os.environ['SETUP_VALUE'] +Path('/workspace/setup-sub/order').write_text('first') +SCRIPT +semver 1.2.3 > /workspace/setup-version +""" + second = "test \"$(cat order)\" = first && printf second > order && printf initialized > /workspace/setup-once" + packages = {'npm': ['semver@7.7.2'], 'python': ['packaging==26.0']} + if system_packages: + packages['system'] = ['jq', 'build-essential', 'libpq-dev'] + first = """printf '{"value":42}' | jq -e '.value == 42' && +printf '#include \\nint main(void){return PQlibVersion() > 0 ? 0 : 1;}\\n' > /workspace/system-library.c && +cc -I/usr/include/postgresql /workspace/system-library.c -lpq -o /workspace/system-library && +/workspace/system-library && +""" + first + return { + 'env': env, 'packages': packages, + 'setup_commands': [{'command': first}, {'command': second, 'cwd': '/workspace/setup-sub'}], + }, marker + + +def attach_setup(client, foreign, http, environment, configuration, template_id=None, network="disabled"): + endpoint = str(client.base_url).rstrip('/') + if template_id: + raw = client.beta.agents.environments.templates.with_raw_response.update( + template_id, network={'access': network}, **configuration) + resource = raw.http_response.json() + assert resource['packages'] == {'system': [], **configuration['packages']} + headers = {'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'} + rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, headers=headers) + assert rejected.status_code == 404 + metadata = [resource, client.beta.agents.environments.templates.list().to_dict()] + assert configuration['env']['SETUP_VALUE'] not in json.dumps(metadata) + assert all('env' not in value and 'setup_commands' not in value for value in [resource]) + return environment + return {**environment, 'network': {'access': network}, **configuration} + + +def verify_setup_metadata(client, session, configuration): + resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + assert session.environment.to_dict()['packages'] == {'system': [], **configuration['packages']} + for value in [session.to_dict(), resource]: + assert configuration['env']['SETUP_VALUE'] not in json.dumps(value) + environment = value.get('environment', value) + assert 'env' not in environment and 'setup_commands' not in environment + + +def native_setup_script(marker, network_target=None, *, system_packages=False): + script = f'''from pathlib import Path +import os, subprocess, socket +import packaging +assert packaging.__version__ == '26.0' +assert os.environ['SETUP_VALUE'] == {marker!r} +assert Path('/workspace/setup-sub/order').read_text() == 'second' +assert Path('/workspace/setup-once').read_text() == 'initialized' +for cwd in ['/workspace', '/workspace/setup-sub']: + assert subprocess.check_output(['semver', '1.2.3'], cwd=cwd).strip() == b'1.2.3' + subprocess.run(['python3', '-c', 'import packaging; assert packaging.__version__ == "26.0"'], cwd=cwd, check=True) +''' + if system_packages: + script += '''assert Path('/workspace').samefile('/environment/workspace') +for cwd in ['/environment/workspace', '/environment/workspace/setup-sub']: + assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}', cwd=cwd).strip() == b'42' +assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}').strip() == b'42' +subprocess.run(['/workspace/system-library'], check=True) +for path in ['/usr/bin/system-package-write', '/environment/packages/system/usr/bin/system-package-write']: + try: + Path(path).write_text('changed') + except OSError: + pass + else: + raise AssertionError('installed system root is writable') +''' + if network_target: + script += f'''try: + connection = socket.create_connection({network_target!r}, timeout=2) +except OSError: + pass +else: + connection.close() + raise AssertionError('runtime network policy was not applied after setup') +''' + return script + + +def verify_system_package_configuration(client, http): + templates = client.beta.agents.environments.templates + template = templates.create(packages={'system': ['jq'], 'npm': ['semver@7.7.2']}) + endpoint = str(client.base_url).rstrip('/') + '/agents/environments/templates/' + template.id + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + try: + expected = {'system': ['jq'], 'npm': ['semver@7.7.2'], 'python': []} + assert http.get(endpoint, headers=headers).json()['packages'] == expected + assert templates.update(template.id, name='System tools').to_dict()['packages'] == expected + assert templates.update(template.id, packages={'system': ['libpq-dev']}).to_dict()['packages'] == { + 'system': ['libpq-dev'], 'npm': [], 'python': []} + empty = {'system': [], 'npm': [], 'python': []} + for packages in [{'system': None}, {'system': []}, None]: + assert templates.update(template.id, packages=packages).to_dict()['packages'] == empty + for packages in [{'system': [None]}, {'system': ['']}, {'system': ['-unsafe-option']}]: + response = http.post(endpoint, headers=headers, json={'packages': packages}) + assert response.status_code == 400 + assert '-unsafe-option' not in response.text + assert templates.retrieve(template.id).to_dict()['packages'] == empty + finally: + templates.delete(template.id) + + +def verify_setup_failure(client, http, agent, until): + """Actual Provider initialization must fail before any native Turn starts.""" + sessions = client.beta.agents.sessions + for command in [{'command': 'echo confidential-setup-failure >&2; exit 7'}, + {'command': 'touch /workspace/unexpected', 'cwd': '/missing-setup-cwd'}]: + session = sessions.create(agent=agent, environment={ + 'type': 'openai_hosted', 'setup_commands': [command, + {'command': 'touch /workspace/unexpected-later-step'}]}) + try: + until(lambda: client.beta.agents.environments.retrieve(session.environment.id).status == 'failed', 180) + assert sessions.turns.list(session.id).data == [] + resource = sessions.retrieve(session.id).to_dict() + assert 'confidential-setup-failure' not in json.dumps(resource) + assert 'setup_commands' not in resource['environment'] + finally: + sessions.delete(session.id) + + +def verify_saved_agent_setup_identity(client, http): + sessions = client.beta.agents.sessions + agent = client.beta.agents.create(model='kimi-k3') + created = [] + secret = 'intent-canary-' + secrets.token_hex(12) + variants = [ + {'type': 'openai_hosted'}, + {'type': 'openai_hosted', 'env': {'VALUE': secret}}, + {'type': 'openai_hosted', 'env': {'VALUE': secret + '-changed'}}, + {'type': 'openai_hosted', 'setup_commands': [{'command': 'true'}]}, + {'type': 'openai_hosted', 'setup_commands': [{'command': 'false'}]}, + {'type': 'openai_hosted', 'env': {'VALUE': secret}, 'setup_commands': [{'command': 'true'}]}, + ] + try: + for index in [0, 1, 3]: + key = 'setup-intent-' + secrets.token_hex(12) + original = sessions.create(agent_id=agent.id, environment=variants[index], + extra_headers={'Idempotency-Key': key}) + created.append((original, key, index)) + client.beta.agents.delete(agent.id) + endpoint = str(client.base_url).rstrip('/') + '/agents/sessions' + for original, key, index in created: + assert sessions.create(agent_id=agent.id, environment=variants[index], + extra_headers={'Idempotency-Key': key}).id == original.id + for changed, environment in enumerate(variants): + if changed == index: + continue + response = http.post(endpoint, json={'agent_id': agent.id, 'environment': environment}, + headers={'Authorization': 'Bearer ' + client.api_key, + 'OpenAI-Beta': 'agents=v1', 'Idempotency-Key': key}) + assert response.status_code == 409, (index, changed, response.status_code) + assert secret not in response.text + finally: + for session, _, _ in created: + sessions.delete(session.id) + try: + client.beta.agents.delete(agent.id) + except NotFoundError: + pass diff --git a/services/agents-api/tests/official_environment_skills.py b/services/agents-api/tests/official_environment_skills.py new file mode 100644 index 000000000..aef19f626 --- /dev/null +++ b/services/agents-api/tests/official_environment_skills.py @@ -0,0 +1,66 @@ +"""Real native-model acceptance helpers for encrypted inline Skill initialization.""" +import base64 +import io +import json +import secrets +import zipfile + + +def inline_skill(): + marker = 'skill-private-' + secrets.token_hex(20) + manifest = """--- +name: proof-skill +description: Verify the initialized workspace and publish the Skill proof. +--- +Run `python3 /environment/initialization/capabilities/skills/proof-skill/scripts/check.py`. +Stop on any failed assertion. Report INITIAL_FILES_VERIFIED and SKILL_VERIFIED. +""" + script = f'''import os, runpy +from pathlib import Path +for name in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'MINIMAX_API_KEY']: + assert name not in os.environ, 'native credential reached a Skill helper' +for path in ['/environment/staging/initial-files-private-canary', '/home/runtime/.parsar/parsar-daemon/default/auth.json']: + assert not os.access(path, os.R_OK), 'private Runtime state reached a Skill helper' +manifest = Path('/environment/initialization/capabilities/skills/proof-skill/SKILL.md') +try: + manifest.write_text('tampered') +except OSError: + pass +else: + raise AssertionError('Skill content was writable') +runpy.run_path('/workspace/verify.py') +Path('/workspace/outputs/skill-proof.txt').write_text({marker!r}) +print('SKILL_VERIFIED') +''' + data = io.BytesIO() + with zipfile.ZipFile(data, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.writestr('proof-skill/SKILL.md', manifest) + archive.writestr('proof-skill/scripts/check.py', script) + return { + 'type': 'inline', 'name': 'proof-skill', + 'description': 'Verify the initialized workspace and publish the Skill proof.', + 'source': {'type': 'base64', 'media_type': 'application/zip', + 'data': base64.b64encode(data.getvalue()).decode()}, + }, marker.encode() + + +def attach_skills(client, foreign, http, environment, skill, template_id=None): + endpoint = str(client.base_url).rstrip('/') + if template_id: + response = client.beta.agents.environments.templates.with_raw_response.update(template_id, skills=[skill]) + metadata = {key: skill[key] for key in ['type', 'name', 'description']} + assert response.http_response.json()['skills'] == [metadata] + assert skill['source']['data'] not in json.dumps(response.http_response.json()) + rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, + headers={'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'}) + assert rejected.status_code == 404 + return environment + return {**environment, 'skills': [skill]} + + +def verify_skills_metadata(client, session, skill): + expected = [{key: skill[key] for key in ['type', 'name', 'description']}] + environment = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + assert session.environment.to_dict()['skills'] == expected + assert environment['skills'] == expected + assert skill['source']['data'] not in json.dumps([session.to_dict(), environment]) diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py new file mode 100644 index 000000000..35428d214 --- /dev/null +++ b/services/agents-api/tests/official_environment_templates.py @@ -0,0 +1,106 @@ +"""Pinned SDK and raw HTTP acceptance for the basic Environment Template profile. + +Run against the same independently deployed service used for actual native/model +acceptance. This module creates no fake Provider, Runtime or model endpoint. +""" +import uuid + + +def verify_environment_templates(client, foreign, http): + api = client.beta.agents.environments.templates + other = foreign.beta.agents.environments.templates + base = str(client.base_url).rstrip('/') + '/agents/environments/templates' + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} + owned = [] + retained = [] + try: + assert http.get(base, headers={'OpenAI-Beta': 'agents=v1'}).status_code == 401 + assert http.get(base, headers={'Authorization': 'Bearer ' + client.api_key}).status_code == 400 + assert api.list().data == [] + for values in ({}, {'packages': {}}, {'packages': {'npm': None}}, {'name': None, 'network': None, 'env': None, 'setup_commands': None}, + {'name': ' preserved ', 'network': {'access': 'disabled'}, 'files': [], + 'plugins': [], 'skills': [], 'packages': {'python': [], 'npm': None}}): + response = api.with_raw_response.create(**values) + body, template = response.http_response.json(), response.parse() + owned.append(template.id) + assert set(body) == {'id', 'object', 'created_at', 'updated_at', 'name', 'network', + 'packages', 'capability_directories', 'files', 'plugins', 'skills'} + assert body['object'] == 'agent.environment.template' + assert body['name'] == values.get('name') + assert body['network'] == {'access': (values.get('network') or {}).get('access', 'enabled'), + 'allowed_domains': []} + assert body['packages'] == {'python': [], 'npm': [], 'system': []} + for field in ['capability_directories', 'files', 'plugins', 'skills']: + assert body[field] == [] + assert body['created_at'] == body['updated_at'] + assert api.retrieve(template.id) == template + assert http.get(base + '/' + template.id, headers=headers).json() == body + before = api.retrieve(owned[-1]) + updated = api.update(owned[-1], name='changed') + assert updated.name == 'changed' and updated.network == before.network + assert updated.created_at == before.created_at + updated = api.update(owned[-1], name=None, network=None) + assert updated.name is None and updated.network.access == 'enabled' + assert api.update(owned[-1]).to_dict() == updated.to_dict() + assert [v.id for v in api.list(order='asc', limit=1)] == owned + assert [v.id for v in api.list(order='desc', limit=2)] == owned[::-1] + page = api.list(order='asc', limit=2) + assert page.has_more and page.first_id == owned[0] and page.last_id == owned[1] + assert [v.id for v in api.list(order='asc', after=page.last_id).data] == owned[2:] + foreign_template = other.create() + try: + for method, path, body in [('GET', '/' + owned[0], None), + ('POST', '/' + owned[0], {'name': 'forbidden'}), + ('DELETE', '/' + owned[0], None), + ('GET', '?after=' + owned[0], None)]: + assert http.request(method, base + path, headers=foreign_headers, json=body).status_code == 404 + assert [v.id for v in other.list()] == [foreign_template.id] + finally: + other.delete(foreign_template.id) + for query in ['limit=0', 'limit=101', 'limit=bad', 'order=wrong', 'limit=1&limit=2', 'unknown=1']: + assert http.get(base + '?' + query, headers=headers).status_code == 400 + canary = 'template-private-' + uuid.uuid4().hex + for body in [{'env': {'PATH': canary}}, {'setup_commands': [{'command': canary, 'cwd': 'relative'}]}, + {'files': [{'type': 'inline', 'path': '/workspace/a', 'data': canary}]}, + {'packages': {'system': ['-' + canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, + {'plugins': [{'type': 'inline', 'data': canary}]}, + {'capability_directories': ['/workspace']}, + {'network': {'access': 'restricted', 'allowed_domains': ['example.com']}}, + {'name': ''}, {'unknown': canary}]: + for path in ['', '/' + owned[0]]: + response = http.post(base + path, headers=headers, json=body) + assert response.status_code == 400 and canary not in response.text + assert [v.id for v in api.list(order='asc')] == owned + assert canary not in http.get(base, headers=headers).text + deleted = api.delete(owned.pop()) + assert deleted.deleted and deleted.object == 'agent.environment.template.deleted' + for method in ['GET', 'DELETE']: + assert http.request(method, base + '/' + deleted.id, headers=headers).status_code == 404 + enabled = api.create(name='real execution', network={'access': 'enabled'}) + retained.append(enabled.id) + disabled = api.create(name='real network isolation', network={'access': 'disabled'}) + retained.append(disabled.id) + return enabled.id, disabled.id + except BaseException: + for template_id in retained: + api.delete(template_id) + raise + finally: + for template_id in owned: + api.delete(template_id) + + +def verify_template_session_rejections(client, foreign, http, agent, enabled, disabled): + base = str(client.base_url).rstrip('/') + '/agents/sessions' + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + for reference, override, status, token in [ + (disabled, {'network': {'access': 'enabled'}}, 400, client.api_key), + (disabled, {'network': None}, 400, client.api_key), + (str(uuid.uuid4()), {}, 404, client.api_key), + (enabled, {}, 404, foreign.api_key), + ]: + response = http.post(base, headers={**headers, 'Authorization': 'Bearer ' + token}, + json={'agent': agent, 'environment': {'type': 'openai_hosted', + 'environment_template_id': reference, **override}}) + assert response.status_code == status, response.text diff --git a/services/agents-api/tests/official_execution.py b/services/agents-api/tests/official_execution.py new file mode 100644 index 000000000..037b87952 --- /dev/null +++ b/services/agents-api/tests/official_execution.py @@ -0,0 +1,175 @@ +"""Verify public execution against a native-daemon integration fixture.""" + +import importlib.metadata +import json +import sys +import time +from pathlib import Path + +import httpx2 +from openai import ConflictError, NotFoundError, OpenAI + + +def main(): + base, token, foreign_token, evidence = sys.argv[1:] + root = Path(__file__).resolve().parents[3] + pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) + source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json")) + assert source["vcs_info"]["commit_id"] == pin["commit"] + client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) + foreign = OpenAI(base_url=base + "/v1", api_key=foreign_token, max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) + sessions = client.beta.agents.sessions + + def message(*texts): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]} for text in texts]} + + def wait_turn(session, status, count=1): + deadline = time.monotonic() + 25 + while time.monotonic() < deadline: + turns = sessions.turns.list(session, limit=100, order="asc").data + if len(turns) == count and turns[-1].status == status: + return turns[-1] + time.sleep(0.05) + raise AssertionError([(turn.id, turn.status, turn.error) for turn in turns]) + + def create(): + return sessions.create(agent={"model": "gpt-5.5", "instructions": "Keep the conversation."}, + environment={"type": "none"}) + + def until_idle(stream): + events = [] + for event in stream: + events.append(event) + assert len(events) < 1000 + if event.type == "agent.session.turn.output_text.delta": + assert sessions.turns.retrieve(event.turn_id, session_id=event.session_id).status == "in_progress" + if event.type == "agent.session.idle": + assert event.session.status == "idle" + assert len({value.event_id for value in events}) == len(events) + return events + raise AssertionError("stream ended without an idle Session") + + try: + session = create() + assert session.agent.tools == [] + event = message("Search the web for this answer.", "Second message in the same event.") + with sessions.events.stream(session.id, timeout=20) as stream: + assert sessions.events.create(session.id, events=[event], idempotency_key="first") is None + sessions.events.create(session.id, events=[event], idempotency_key="first") + first_events = until_idle(stream) + first = wait_turn(session.id, "completed") + assert sessions.retrieve(session.id).status == "idle" + expected_usage = {"input_tokens": 10, "input_tokens_details": {"cached_tokens": 4}, + "output_tokens": 3, "output_tokens_details": {"reasoning_tokens": 2}, "total_tokens": 13} + assert first.usage is not None and first.usage.model_dump() == expected_usage, first.usage + assert sessions.retrieve(session.id).usage.model_dump() == expected_usage + items = sessions.items.list(session.id, limit=100, order="asc").data + users = [item for item in items if item.type == "message" and item.role == "user"] + answers = [item for item in items if item.type == "message" and item.role == "assistant"] + assert len(users) == 2 and len(answers) == 1 + assert answers[0].content[0].text == "NO-ENVIRONMENT-OK" + types = [value.type for value in first_events] + for kind in ("created", "in_progress", "completed", "item.added", "item.done", + "content_part.added", "content_part.done", "output_text.delta", "output_text.done"): + assert "agent.session.turn." + kind in types, types + terminal = next(value for value in first_events if value.type == "agent.session.turn.completed") + assert terminal.turn.usage.model_dump() == expected_usage + assert first_events[-1].session.usage.model_dump() == expected_usage + text_events = [value for value in first_events if value.type.startswith("agent.session.turn.output_text.")] + assert all(value.item_id == answers[0].id and value.output_index == 0 and value.content_index == 0 for value in text_events) + assert text_events[-1].text == answers[0].content[0].text + deltas = [value.delta for value in text_events if value.type.endswith(".delta")] + assert len(deltas) >= 2 and "".join(deltas) == answers[0].content[0].text, deltas + try: + sessions.events.create(session.id, events=[message("changed")], idempotency_key="first") + raise AssertionError("changed retry accepted") + except ConflictError: + pass + try: + foreign.beta.agents.sessions.events.create(session.id, events=[event]) + raise AssertionError("foreign tenant admitted") + except NotFoundError: + pass + with sessions.events.stream(session.id, timeout=20, extra_headers={"Last-Event-ID": first_events[-1].event_id}) as stream: + sessions.events.create(session.id, events=[message("Continue the same native conversation.")], idempotency_key="second") + second_events = until_idle(stream) + second = wait_turn(session.id, "completed", 2) + assert all(getattr(value, "turn_id", None) != first.id for value in second_events) + assert second.usage.model_dump() == expected_usage, second.usage + expected_total = {"input_tokens": 20, "input_tokens_details": {"cached_tokens": 8}, + "output_tokens": 6, "output_tokens_details": {"reasoning_tokens": 4}, "total_tokens": 26} + assert sessions.retrieve(session.id).usage.model_dump() == expected_total + sessions.events.create(session.id, events=[event], idempotency_key="first") + assert len(sessions.turns.list(session.id).data) == 2 + client.close() + client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) + sessions = client.beta.agents.sessions + assert sessions.turns.retrieve(second.id, session_id=session.id).status == "completed" + assert len(sessions.items.list(session.id, limit=100).data) == 5 + assert sessions.retrieve(session.id).usage.model_dump() == expected_total + assert sessions.turns.retrieve(first.id, session_id=session.id).usage.model_dump() == expected_usage + cancelled = create() + sessions.events.create(cancelled.id, events=[message("PUBLIC-CANCEL")]) + wait_turn(cancelled.id, "in_progress") + time.sleep(0.5) + retained = sessions.items.list(cancelled.id, limit=100).data + with sessions.events.stream(cancelled.id, timeout=20) as stream: + sessions.events.create(cancelled.id, events=[{"type": "agent.session.input.cancel"}], idempotency_key="cancel") + cancelled_events = until_idle(stream) + stopped = wait_turn(cancelled.id, "cancelled") + assert any(value.type == "agent.session.turn.cancelled" and value.turn.id == stopped.id for value in cancelled_events) + assert not any(value.type == "agent.session.turn.created" for value in cancelled_events) + assert {item.id for item in retained} <= {item.id for item in sessions.items.list(cancelled.id, limit=100).data} + assert sessions.retrieve(cancelled.id).status == "idle" + for verbosity in ("low", "medium", "high"): + agent = {"model": "gpt-5.5", "text": {"verbosity": verbosity, "format": {"type": "text"}}} + key = "text-" + verbosity + configured = sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}) + agent["text"]["format"] = None + assert sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}).id == configured.id + assert configured.agent.text.model_dump() == {"format": {"type": "text"}, "verbosity": verbosity} + for count in (1, 2): + sessions.events.create(configured.id, events=[message("TEXT-VERBOSITY:" + verbosity)]) + wait_turn(configured.id, "completed", count) + assert sessions.retrieve(configured.id).agent.text == configured.agent.text + agent["text"]["verbosity"] = "high" if verbosity != "high" else "low" + try: + sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}) + raise AssertionError("changed text configuration reused a retry key") + except ConflictError: + pass + default_agent = {"model": "custom-provider-model"} + default = sessions.create(agent=default_agent, environment={"type": "none"}, + extra_headers={"Idempotency-Key": "native-default"}) + for text in (None, {"verbosity": None}, {"verbosity": "medium"}): + configured = sessions.create(agent=dict(default_agent, text=text), environment={"type": "none"}, + extra_headers={"Idempotency-Key": "native-default"}) + assert configured.id == default.id and configured.agent.text.verbosity == "medium" + for count in (1, 2): + sessions.events.create(default.id, events=[message("DEFAULT-VERBOSITY")]) + wait_turn(default.id, "completed", count) + assert sessions.retrieve(default.id).agent.text.verbosity == "medium" + unsupported = sessions.create(agent={"model": "custom-provider-model", "text": {"verbosity": "high"}}, + environment={"type": "none"}) + sessions.events.create(unsupported.id, events=[message("UNSUPPORTED-VERBOSITY")]) + failed = wait_turn(unsupported.id, "failed") + assert failed.error is not None and failed.error.code == "internal_error", failed.error + assert sessions.retrieve(unsupported.id).status == "failed" + Path(evidence).write_text(json.dumps({"session": session.id, "turns": [first.id, second.id], + "cancelled_session": cancelled.id, "cancelled_turn": stopped.id, + "stream_types": types, "reconnected_events": len(second_events), + "cancelled_events": [value.type for value in cancelled_events], + "verbosity_new_and_resumed": ["low", "medium", "high"], + "native_default_session": default.id, + "unsupported_verbosity": failed.error.model_dump()})) + finally: + client.close() + foreign.close() + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_function_inputs.py b/services/agents-api/tests/official_function_inputs.py new file mode 100644 index 000000000..35f2deb00 --- /dev/null +++ b/services/agents-api/tests/official_function_inputs.py @@ -0,0 +1,49 @@ +"""Verify public result admission against storage fixtures, not native execution.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +import httpx2 +from openai import APIStatusError, OpenAI + +base, token, foreign, session, turn, other = sys.argv[1:] +pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) +source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") +assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + + +def result(call, **values): + return {"type": "agent.session.input.tool_result", "turn_id": turn, "call_id": call, **values} + + +def submit(api, events, key, expected=204, target=session): + try: + response = api.beta.agents.sessions.events.with_raw_response.create( + target, events=events, extra_headers={"Idempotency-Key": key}) + assert response.status_code == expected == 204 + assert response.content == b"" + except APIStatusError as error: + assert error.status_code == expected, (error.status_code, expected, error.message) + assert error.body["code"] in {"not_found", "turn_conflict", "idempotency_conflict", "invalid_request"} + + +with OpenAI(api_key=token, base_url=base+"/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) as api: + output = [{"type":"input_text","text":""}, {"type":"input_image","image_url":"data:image/png;base64,AA=="}, {"type":"input_text","text":"last"}] + message = {"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"follow up"}]}]} + batch = [result("a", success=False, error="failure", output=output), result("b", success=True, output=None, error=None), result("c", success=True), message, {"type":"agent.session.input.cancel"}] + submit(api, [message, result("rollback", success=True), result("missing", success=True)], "rollback", 404) + submit(api, [result("a", success=True)], "other-session", 404, other) + submit(api, [message, result("rollback", success=None)], "malformed", 400) + stranger = api.with_options(api_key=foreign) + submit(stranger, [result("a", success=True)], "foreign", 404) + submit(api, batch, "batch") + submit(api, batch, "batch") + print("saved", flush=True) + assert sys.stdin.readline() == "terminal\n" + submit(api, batch, "batch") + submit(api, list(reversed(batch)), "batch", 409) + submit(api, [result("late", success=True)], "late", 409) + submit(api, [result("b", success=True)], "changed-null", 409) diff --git a/services/agents-api/tests/official_function_state.py b/services/agents-api/tests/official_function_state.py new file mode 100644 index 000000000..1f8185a40 --- /dev/null +++ b/services/agents-api/tests/official_function_state.py @@ -0,0 +1,66 @@ +"""Verify function-action reads using real storage fixtures, without claiming execution coverage.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +import httpx2 +from openai import NotFoundError, OpenAI + +base, token, foreign, session_id, turn_id = sys.argv[1:] +pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) +source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") +assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + + +def client(key): + return OpenAI(api_key=key, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=15)) + + +def verify_actions(session, ids): + assert session.status == ("requires_action" if ids else "in_progress") + assert [action.call_id for action in session.required_actions] == ids + for action in session.required_actions: + assert action.to_dict() == {"type": "function_call", "call_id": action.call_id, + "name": "lookup", "turn_id": turn_id, + "arguments": {"ticket": 9007199254740993}}, action.to_dict() + + +with client(token) as api, client(foreign) as stranger: + sessions = api.beta.agents.sessions + verify_actions(sessions.retrieve(session_id), ["first"]) + verify_actions(sessions.list().data[0], ["first"]) + assert sessions.turns.retrieve(turn_id, session_id=session_id).status == "waiting" + assert sessions.turns.list(session_id).data[0].status == "waiting" + try: + stranger.beta.agents.sessions.retrieve(session_id) + raise AssertionError("foreign session was visible") + except NotFoundError: + pass + assert stranger.beta.agents.sessions.list().data == [] + try: + stranger.beta.agents.sessions.events.stream(session_id) + raise AssertionError("foreign event stream was visible") + except NotFoundError: + pass + states = [] + with sessions.events.stream(session_id) as stream: + print("connected", flush=True) + for event in stream: + if event.type.startswith("agent.session.turn."): + assert event.type != "agent.session.turn.waiting" + continue + wire = event.to_dict() + assert set(wire) == {"event_id", "session", "type"}, wire + states.append(event.type) + if event.type == "agent.session.idle": + assert event.session.status == "idle" and event.session.required_actions == [] + break + verify_actions(event.session, [["first", "second"], ["second"], []][len(states)-1]) + assert states == ["agent.session.requires_action", "agent.session.requires_action", + "agent.session.in_progress", "agent.session.idle"] + restored = sessions.retrieve(session_id) + assert restored.status == "idle" and restored.required_actions == [] + assert sessions.turns.retrieve(turn_id, session_id=session_id).status == "completed" diff --git a/services/agents-api/tests/official_function_stream.py b/services/agents-api/tests/official_function_stream.py new file mode 100644 index 000000000..d5a24b391 --- /dev/null +++ b/services/agents-api/tests/official_function_stream.py @@ -0,0 +1,78 @@ +"""Verify the pinned stream helper through the public API and native execution.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +import httpx2 +from openai import OpenAI + +base, token, evidence = sys.argv[1:] +pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) +source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") +assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] +assert importlib.metadata.version("openai") == pin["sdk_version"] +agent = {"model": "gpt-5.5", "tools": [{ + "type": "function", "name": "lookup_ticket", "description": "Read a synthetic ticket", + "parameters": {"type": "object", "properties": {"ticket": {"type": "string"}}, + "required": ["ticket"], "additionalProperties": False}, +}]} +expected = [{"output": '{"ticket":"42","status":"open"}'}, {"error": "Tool handler failed."}] +with OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=30)) as client: + sessions = client.beta.agents.sessions + session = sessions.create(agent=agent, environment={"type": "none"}) + turns, calls, handler_calls, observed = [], [], [], [] + for index in range(2): + def lookup_ticket(arguments): + assert arguments == {"ticket": "42"}, arguments + handler_calls.append(arguments) + if index == 1: + raise RuntimeError("private-handler-exception-must-not-be-exposed") + return {"ticket": arguments["ticket"], "status": "open"} + + with sessions.stream(session.id, input="Look up ticket 42", timeout=30, + tool_handlers={"lookup_ticket": lookup_ticket}, + idempotency_key="helper-" + str(index)) as stream: + events = [event.to_dict() for event in stream] + observed.append(events) + assert len(handler_calls) == index + 1, handler_calls + created = [event for event in events if event["type"] == "agent.session.turn.created"] + assert len(created) == 1, events + turn_id = created[0]["turn"]["id"] + turns.append(turn_id) + added = [event for event in events if event["type"] == "agent.session.turn.item.added"] + functions = [event for event in added if event["item"]["type"] == "function_call"] + assert len(functions) == 1 and functions[0]["turn_id"] == turn_id, events + call_id = functions[0]["item"]["call_id"] + calls.append(call_id) + results = [event for event in added if event["item"]["type"] == "function_call_output"] + assert len(results) == 1 and results[0]["turn_id"] == turn_id, events + assert results[0].get("output_index") is None + result = results[0]["item"] + assert result["call_id"] == call_id + assert {key: result[key] for key in ("output", "error") if key in result} == expected[index], result + assert not any(event["type"] == "agent.session.turn.item.done" and + event["item"]["type"] == "function_call_output" for event in events) + terminal = [event for event in events if event["type"] in ( + "agent.session.turn.completed", "agent.session.turn.failed", "agent.session.turn.cancelled")] + assert len(terminal) == 1 and terminal[0]["type"] == "agent.session.turn.completed", events + assert terminal[0]["turn"]["id"] == turn_id + assert events.index(functions[0]) < events.index(results[0]) < events.index(terminal[0]) < len(events) - 1 + assert events[-1]["type"] == "agent.session.idle" and events[-1]["session"]["required_actions"] == [] + assert not any(event["type"] == "agent.session.failed" for event in events) + assert sessions.turns.retrieve(turn_id, session_id=session.id).status == "completed" + current = sessions.retrieve(session.id) + assert current.status == "idle" and current.required_actions == [] + items = sessions.items.list(session.id, limit=100, order="asc").data + results = {item.call_id: item.to_dict() for item in items if item.type == "function_call_output"} + assert len(results) == 2 and len(sessions.turns.list(session.id).data) == 2 + for index, call_id in enumerate(calls): + assert {key: results[call_id][key] for key in ("output", "error") if key in results[call_id]} == expected[index] + answers = [item for item in items if item.type == "message" and item.role == "assistant"] + assert len(answers) == 2 and all(item.content[0].text == "FUNCTION-EXECUTION-OK" for item in answers) + proof = {"session": session.id, "turns": turns, "calls": calls, "handler_calls": handler_calls, + "events": observed, "results": results} + assert "private-handler-exception-must-not-be-exposed" not in json.dumps(proof) + Path(evidence).write_text(json.dumps(proof)) diff --git a/services/agents-api/tests/official_functions.py b/services/agents-api/tests/official_functions.py new file mode 100644 index 000000000..863264a2d --- /dev/null +++ b/services/agents-api/tests/official_functions.py @@ -0,0 +1,81 @@ +"""Verify public function configuration and execution against a real daemon/Codex.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +import httpx2 +from openai import ConflictError, OpenAI + +base, token, output_path, evidence = sys.argv[1:] +pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) +source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") +assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] +output = json.loads(Path(output_path).read_text()) +tool = {"type":"function","name":"lookup_ticket","description":"Read a synthetic ticket", + "parameters":{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":False}} +agent = {"model":"gpt-5.5","tools":[tool]} +with OpenAI(base_url=base+"/v1", api_key=token, max_retries=0, _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=30)) as client: + sessions = client.beta.agents.sessions + session = sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}) + expected = dict(tool, defer_loading=False) + assert session.agent.tools[0].to_dict() == expected, session.agent.tools + tool["defer_loading"] = False + assert sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}).id == session.id + tool["description"] = "changed" + try: + sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}) + raise AssertionError("changed tools reused a creation identity") + except ConflictError: + pass + turns, calls = [], [] + for index in range(3): + handled = False + with sessions.events.stream(session.id, timeout=30) as stream: + message = {"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Look up ticket 42"}]}]} + sessions.events.create(session.id, events=[message], idempotency_key="message-"+str(index)) + for event in stream: + if event.type in ("agent.session.turn.item.added", "agent.session.turn.item.done") and event.item.type == "function_call_output": + assert event.type == "agent.session.turn.item.added" and event.output_index is None + submitted = event.item.to_dict() + assert submitted["output"] == output + if index == 1: + assert submitted["error"] == "synthetic failure" + else: + assert "error" not in submitted + if event.type == "agent.session.requires_action" and not handled: + assert event.session.agent.tools[0].to_dict() == expected + action = event.session.required_actions[0] + assert action.name == "lookup_ticket" and action.arguments == {"ticket":"42"} + assert sessions.turns.retrieve(action.turn_id, session_id=session.id).status == "waiting" + turns.append(action.turn_id); calls.append(action.call_id); handled = True + if index == 2: + sessions.events.create(session.id, events=[{"type":"agent.session.input.cancel"}], idempotency_key="cancel") + else: + result = {"type":"agent.session.input.tool_result","turn_id":action.turn_id,"call_id":action.call_id,"success":index==0,"output":output} + if index == 1: + result["error"] = "synthetic failure" + for _ in range(2): + assert sessions.events.create(session.id, events=[result], idempotency_key="result-"+str(index)) is None + if event.type == "agent.session.idle": + assert handled and event.session.required_actions == [] + break + assert event.type != "agent.session.failed", event.to_dict() + assert sessions.turns.retrieve(turns[-1], session_id=session.id).status == ("cancelled" if index==2 else "completed") + assert len(sessions.turns.list(session.id).data) == index+1 + items = sessions.items.list(session.id, limit=100, order="asc").data + assert all(any(item.type=="function_call" and item.call_id==call for item in items) for call in calls) + results = {item.call_id: item.to_dict() for item in items if item.type=="function_call_output"} + assert len(results)==2 + for index, call in enumerate(calls[:2]): + assert results[call]["output"] == output + if index == 1: + assert results[call]["error"] == "synthetic failure" + else: + assert "error" not in results[call] + answers = [item for item in items if item.type=="message" and item.role=="assistant"] + assert len(answers)==2 and all(item.content[0].text=="FUNCTION-EXECUTION-OK" for item in answers) + assert sessions.retrieve(session.id).agent.tools[0].to_dict() == expected + Path(evidence).write_text(json.dumps({"session":session.id,"turns":turns,"calls":calls,"configured_tool":expected})) diff --git a/services/agents-api/tests/official_hosted_functions_native.py b/services/agents-api/tests/official_hosted_functions_native.py new file mode 100644 index 000000000..8c7becbc9 --- /dev/null +++ b/services/agents-api/tests/official_hosted_functions_native.py @@ -0,0 +1,109 @@ +"""Common real-harness acceptance for hosted functions and workspace execution.""" + +import importlib.metadata +import json +from pathlib import Path +import time +import uuid + + +def verify_hosted_functions(client, foreign, http, model, restart, evidence): + """restart(session_id, environment_id) restarts the operator-owned deployment.""" + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + sessions = client.beta.agents.sessions + endpoint = str(client.base_url).rstrip("/") + "/agents/sessions" + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + marker = "function-memory-" + uuid.uuid4().hex + private_error = "private-handler-" + uuid.uuid4().hex + calls, observed, checks = [], [], [] + session = sessions.create(agent={"model": model, "instructions": "Follow the requested tool calls exactly. Never repeat a failed call.", "tools": [{ + "type": "function", "name": "lookup", "description": "Retrieve the requested test value.", + "parameters": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"], "additionalProperties": False}, + }]}, environment={"type": "openai_hosted"}) + + def until(predicate, timeout=120): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(0.2) + raise AssertionError("Public workflow did not reach expected state") + + def items(): + response = http.get(endpoint + "/" + session.id + "/items", headers=headers, params={"limit": 100, "order": "asc"}) + assert response.status_code == 200 + raw = response.json()["data"] + assert raw == [item.to_dict() for item in sessions.items.list(session.id, limit=100, order="asc").data] + return raw + + def invoke(text, key, handler): + with sessions.stream(session.id, input=text, tool_handlers={"lookup": handler}, idempotency_key=key, timeout=240) as stream: + events = [event.to_dict() for event in stream] + observed.append(events) + terminals = [e for e in events if e["type"] in ("agent.session.turn.completed", "agent.session.turn.failed", "agent.session.turn.cancelled")] + assert len(terminals) == 1 and terminals[0]["type"] == "agent.session.turn.completed" + assert events[-1]["type"] == "agent.session.idle" + function = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call"] + result = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call_output"] + assert len(function) == len(result) == 1 + assert function[0]["item"]["call_id"] == result[0]["item"]["call_id"] + assert events.index(function[0]) < events.index(result[0]) < events.index(terminals[0]) < len(events) - 1 + assert sessions.retrieve(session.id).required_actions == [] + return terminals[0]["turn"]["id"], result[0]["item"] + + try: + def success(arguments): + assert arguments == {"key": "success"} + calls.append(arguments) + return marker + + turn, result = invoke("Call lookup once with key success. Remember its returned string in conversation. Then use the native shell to create /workspace/outputs/function.txt containing exactly that string, with no newline. Do not call any other function.", "function-success", success) + assert result["output"] == marker and "error" not in result + artifacts = list(sessions.artifacts.list(session.id, limit=100)) + output = [a for a in artifacts if a.turn_id == turn and a.path == "/workspace/outputs/function.txt"] + assert len(output) == 1 + with sessions.artifacts.with_streaming_response.content(output[0].id, session_id=session.id) as response: + assert response.read() == marker.encode() + listing = client.beta.agents.environments.files.list(session.environment.id, path="/workspace/outputs") + assert any(f.path == "/workspace/outputs/function.txt" for f in listing) + assert any(i["type"] == "function_call_output" and i.get("output") == marker for i in items()) + checks.append("same_turn_function_native_file_and_public_artifact") + + restart(session.id, session.environment.id) + + def failure(arguments): + assert arguments == {"key": "failure"} + calls.append(arguments) + raise RuntimeError(private_error) + + _, result = invoke("Call lookup exactly once with key failure. If it fails, do not retry. Then reply with the exact string returned by lookup in our first turn, using conversation history and no file tools.", "function-failure", failure) + assert result["error"] == "Tool handler failed." and "output" not in result + messages = [i for i in items() if i["type"] == "message" and i["role"] == "assistant"] + assert marker in "\n".join(p.get("text", "") for p in messages[-1]["content"]) + assert len(calls) == 2 + checks.append("cold_history_continuation_and_public_handler_error") + + sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "Call lookup once with key pending and wait for the result."}]}]}], idempotency_key="function-pending") + until(lambda: sessions.retrieve(session.id).required_actions) + pending = [i for i in items() if i["type"] == "function_call"][-1] + foreign_headers = {**headers, "Authorization": "Bearer " + foreign.api_key} + result_event = {"type": "agent.session.input.tool_result", "call_id": pending["call_id"], "turn_id": sessions.turns.list(session.id).data[0].id, "success": True, "output": "foreign-value"} + response = http.post(endpoint + "/" + session.id + "/events", headers=foreign_headers, json={"events": [result_event]}) + assert response.status_code == 404 + cancel = [{"type": "agent.session.input.cancel"}] + sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") + sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") + until(lambda: len(sessions.turns.list(session.id).data) == 3 and sessions.turns.list(session.id).data[0].status == "cancelled") + assert sessions.retrieve(session.id).required_actions == [] + assert not any(i["type"] == "function_call_output" and i["call_id"] == pending["call_id"] for i in items()) + checks.append("pending_function_tenant_isolation_and_cancel_retry") + proof = {"checks": checks, "session": session.id, "calls": calls, "events": observed, "items": items()} + assert private_error not in json.dumps(proof) + Path(evidence).write_text(json.dumps(proof, indent=2)) + return checks + finally: + sessions.delete(session.id) diff --git a/services/agents-api/tests/official_items.py b/services/agents-api/tests/official_items.py new file mode 100644 index 000000000..5b00358ff --- /dev/null +++ b/services/agents-api/tests/official_items.py @@ -0,0 +1,40 @@ +"""Validate canonical recovery Items through the pinned SDK and the real HTTP service.""" +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_items(a, b, invalid, session, empty_session, turns, expect_error): + items = a.beta.agents.sessions.items + recovered = list(items.list(session, limit=3, order="asc")) + assert len(recovered) == 32 and len({item.id for item in recovered}) == 32 + assert list(items.list(session, limit=5)) == list(reversed(recovered)) + assert list(items.list(session, after=recovered[-1].id, order="asc")) == [] + assert list(items.list(empty_session)) == [] + expect_error(NotFoundError, lambda: b.beta.agents.sessions.items.list(session)) + expect_error(NotFoundError, lambda: items.list(empty_session, after=recovered[0].id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.items.list(session)) + expect_error(BadRequestError, lambda: items.list(session, limit=101)) + assert "PRIVATE" not in repr(recovered) and "SECRET" not in repr(recovered) + for i, turn in enumerate(turns): + group = [item for item in recovered if item.turn_id == turn] + assert len(group) == 8 + assert group[0].type == "message" and group[0].role == "user" and group[0].status == "completed" + answer = next(item for item in group if item.type == "message" and item.role == "assistant") + assert answer.output_text == ("final answer" if i < 2 else "partial answer") + assert answer.status == ["completed", "completed", "incomplete", "in_progress"][i] + command = next(item for item in group if item.type == "command_execution") + assert command.status == "failed" and command.exit_code == 7 and command.output == "command failed" + assert command.duration_ms == 8 and command.cwd == "/workspace" + mcp = next(item for item in group if item.type == "mcp_call") + assert mcp.status == "completed" and mcp.server_label == "reference" and mcp.name == "lookup" + assert mcp.arguments["n"] == 9007199254740993 and mcp.output["structuredContent"]["n"] == 9007199254740993 + assert mcp.error is None + call = next(item for item in group if item.type == "function_call" and item.name == "reference::lookup") + output = next(item for item in group if item.type == "function_call_output") + assert output.call_id == call.call_id and output.id != call.id and group.index(call) < group.index(output) + assert output.output[0].type == "input_text" and output.output[0].text == "" + patch = next(item for item in group if item.type == "function_call" and item.name == "apply_patch") + assert patch.arguments["changes"][0]["diff"] == "+example" + search = next(item for item in group if item.type == "web_search_call") + assert search.status == "completed" and search.action.query == "reference" + print("Official Items client: native result shapes, partial/completed states, ordering, pagination and isolation passed.") + return recovered diff --git a/services/agents-api/tests/official_mcode_native.py b/services/agents-api/tests/official_mcode_native.py new file mode 100644 index 000000000..dcacd60ba --- /dev/null +++ b/services/agents-api/tests/official_mcode_native.py @@ -0,0 +1,95 @@ +"""Opt-in real-model MiniMax Code acceptance through the pinned public client.""" +import importlib.metadata +import json +import sys +import time +import uuid +from pathlib import Path + +import httpx2 +from openai import OpenAI + + +def main(): + base, token, foreign, model, stage, output = sys.argv[1:] + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + dist = importlib.metadata.distribution("openai") + assert dist.version == pin["sdk_version"] + assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] + http = httpx2.Client(trust_env=False, timeout=300) + client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, + _strict_response_validation=True, http_client=http) + sessions = client.beta.agents.sessions + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + record = {} if stage == "initial" else json.loads(Path(output).read_text()) + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + def submit(sid, text, key): + sessions.events.create(sid, events=[message(text)], idempotency_key=key) + + def execute(sid, text, steer=False, cancel=False): + types, submitted = [], False + with sessions.events.stream(sid, timeout=300) as stream: + submit(sid, text, str(uuid.uuid4())) + for event in stream: + types.append(event.type) + assert event.type != "agent.session.failed", event + if event.type == "agent.session.turn.output_text.delta" and not submitted: + submitted = True + if steer: + submit(sid, "Stop the list now and reply MCODE-STEERED.", "steer") + if cancel: + sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) + if event.type == "agent.session.idle": + break + end = "agent.session.turn.cancelled" if cancel else "agent.session.turn.completed" + assert end in types, types + assert types.index("agent.session.turn.created") < types.index(end) < len(types) - 1 + if steer or cancel: + assert submitted, "No native output to trigger the operation" + return types + + def answer(sid): + items = sessions.items.list(sid, order="asc", limit=100).data + answers = [i for i in items if i.type == "message" and i.role == "assistant"] + return "\n".join(c.text for c in answers[-1].content if c.type == "output_text") + + try: + if stage == "initial": + marker = "MCODE-MEMORY-" + uuid.uuid4().hex[:12] + session = sessions.create(agent={"model": model, "instructions": "Follow user instructions. Remember supplied markers. Do not use tools."}, environment={"type": "none"}) + record = {"session": session.id, "marker": marker, "model": model, "checks": []} + Path(output).write_text(json.dumps(record, indent=2)) + for agent_patch, environment in [({"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]}, {"type": "none"}), ({"text": {"verbosity": "high"}}, {"type": "none"}), ({}, {"type": "openai_hosted"})]: + r = http.post(base + "/v1/agents/sessions", headers=headers, json={"agent": {"model": model, **agent_patch}, "environment": environment}) + assert r.status_code == 400, r.status_code + record["initial_events"] = execute(session.id, "Remember " + marker + ". Write 120 numbered lines explaining addition, one sentence per line. Start immediately.", steer=True) + turns = sessions.turns.list(session.id, order="asc", limit=100).data + assert len(turns) == 1 and turns[0].status == "completed", [(t.id, t.status) for t in turns] + record["first_turn"] = turns[0].id + record["checks"] += ["real_native_execution", "active_steering_same_turn", "unsupported_operations_rejected"] + else: + sid = record["session"] + execute(sid, "Reply with the MCODE-MEMORY marker I gave you earlier, and nothing else.") + assert record["marker"] in answer(sid), "Cold native history was not continued" + foreign_headers = {**headers, "Authorization": "Bearer " + foreign} + for path in ["", "/items", "/turns"]: + assert http.get(base + "/v1/agents/sessions/" + sid + path, headers=foreign_headers).status_code == 404 + # The input must remain ordinary text instead of triggering ACP /model. + execute(sid, "/model") + assert sessions.turns.list(sid, order="asc", limit=100).data[-1].status == "completed" + record["cancel_events"] = execute(sid, "Print the integers from 1 to 10000, one per line. Start with 1 immediately; no explanation or planning.", cancel=True) + execute(sid, "Reply with the original MCODE-MEMORY marker only.") + assert record["marker"] in answer(sid) + record["checks"] += ["cold_daemon_history_continuation", "foreign_tenant_rejected", "slash_text_execution", "cancel_and_continue"] + record["passed"] = True + Path(output).write_text(json.dumps(record, indent=2)) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_mcp.py b/services/agents-api/tests/official_mcp.py new file mode 100644 index 000000000..3516043f5 --- /dev/null +++ b/services/agents-api/tests/official_mcp.py @@ -0,0 +1,67 @@ +"""HTTP MCP resource semantics; execution requires the separate real-provider run.""" + +from copy import deepcopy +from itertools import product + +from openai import BadRequestError, NotFoundError + + +def verify_mcp_configuration(client, other, expect_error): + agents, sessions = client.beta.agents, client.beta.agents.sessions + transport = {"type": "http", "server_url": "https://mcp.example.invalid/mcp"} + tool = {"type": "mcp", "server_label": "tickets", "transport": transport, + "connection_origin": "service"} + recovered, saved = [], [] + for allow, readiness in product( + ({}, {"allowed_tools": None}, {"allowed_tools": []}, + {"allowed_tools": ["lookup_ticket"]}), + ({}, {"required": False}, {"required": True}), + ): + declared = {**tool, **allow, **readiness} + response = agents.with_raw_response.create(model="requested-model", tools=[declared]) + resource, body = response.parse(), response.http_response.json() + canonical = {**declared, "allowed_tools": allow.get("allowed_tools"), + "credential_id": None, "request_metadata": {}, "required": readiness.get("required", False), + "transport": {**transport, "headers": {}}} + assert body["tools"] == [canonical] + spec = {"agent_id": resource.id, "environment": {"type": "none"}} + headers = {"Idempotency-Key": "mcp-snapshot-" + resource.id} + response = sessions.with_raw_response.create(**spec, extra_headers=headers) + session, body = response.parse(), response.http_response.json() + assert body["agent"]["tools"] == [{**canonical, "transport": transport}] + expect_error(NotFoundError, lambda: other.beta.agents.sessions.create(**spec)) + assert sessions.create(agent={"model": "requested-model", "tools": [declared]}, + environment={"type": "none"}).agent.tools == session.agent.tools + override = sessions.create(**spec, agent={"tools": []}) + assert override.agent.tools == [] + changed = agents.update(resource.id, tools=[]) + assert changed.tools == [] and sessions.retrieve(session.id) == session + assert sessions.create(**spec, extra_headers=headers) == session + recovered.extend([session, override]) + saved.append(changed) + + before = {item.id for item in sessions.list()} + saved_before = {item.id for item in agents.list()} + invalid = [{**tool, "connection_origin": value} for value in (None, "environment")] + invalid += [{**tool, "required": "true"}, {**tool, "required": None}, + {**tool, "request_metadata": {"x": "y"}}, + {**tool, "allowed_tools": [None]}] + for changes in ({"headers": {"Authorization": "synthetic-private"}}, + {"authorization": "synthetic-private"}, + {"server_url": "https://mcp.example.invalid/mcp?token=synthetic-private"}): + invalid.append({**tool, "transport": {**transport, **changes}}) + omitted = deepcopy(tool) + omitted.pop("connection_origin") + invalid.append(omitted) + for declaration in invalid: + for operation in ( + lambda: agents.create(model="requested-model", tools=[declaration]), + lambda: sessions.create(agent={"model": "requested-model", "tools": [declaration]}, + environment={"type": "none"}), + ): + error = expect_error(BadRequestError, operation) + assert "synthetic-private" not in str(error.body) + assert {item.id for item in sessions.list()} == before + assert {item.id for item in agents.list()} == saved_before + print("HTTP MCP: pinned saved/Session projections, null/empty allowlists, immutable snapshots and rejected writes passed; no native execution claimed.") + return recovered, saved diff --git a/services/agents-api/tests/official_mcp_credentials.py b/services/agents-api/tests/official_mcp_credentials.py new file mode 100644 index 000000000..8c663c145 --- /dev/null +++ b/services/agents-api/tests/official_mcp_credentials.py @@ -0,0 +1,167 @@ +"""Public MCP Vault admission; actual authenticated execution has a separate fixture.""" + +from copy import deepcopy +import json +import uuid + +import httpx2 +from openai import BadRequestError, ConflictError, NotFoundError + +from official_session_creation_stream import event_data + + +def verify_mcp_credentials(client, other, peer, canary, expect_error): + sessions, vaults = client.beta.agents.sessions, client.beta.agents.vaults + url = "https://mcp.example.invalid/credential-admission" + anonymous_url = "https://anonymous.example.invalid/mcp" + attached = [vaults.create(name="MCP attachment " + str(index)) for index in range(2)] + unattached = vaults.create(name="Unattached MCP credential") + foreign = other.beta.agents.vaults.create(name="Foreign MCP credential") + ids = [value.id for value in attached] + + def credential(api, vault, destination, name): + value = api.beta.agents.vaults.credentials.create( + vault.id, name=name, + auth={"type": "static_bearer", "mcp_server_url": destination, "token": canary}) + assert canary not in value.model_dump_json() + return value + + chosen = credential(client, attached[0], url, "First selected credential") + alternate = credential(client, attached[1], url + "/other", "Other destination") + outside = credential(client, unattached, url, "Not attached") + foreign_credential = credential(other, foreign, url, "Not owned") + tool = {"type": "mcp", "server_label": "private", "connection_origin": "service", + "transport": {"type": "http", "server_url": url}, "allowed_tools": ["remember"]} + anonymous = {**tool, "server_label": "anonymous", + "transport": {"type": "http", "server_url": anonymous_url}} + inline = {"agent": {"model": "requested-model", "tools": [tool, anonymous]}, + "environment": {"type": "none"}, "vault_ids": ids} + saved_sessions, saved_agents, retries = [], [], [] + + def verify_session(value, expected_ids, expected_credential): + body = value.to_dict() + assert body["vault_ids"] == expected_ids + assert body["agent"]["tools"][0]["credential_id"] == expected_credential + assert "headers" not in body["agent"]["tools"][0]["transport"] + assert canary not in json.dumps(body) and "mcp_credentials" not in body + assert value.status == "idle" + assert list(sessions.turns.list(value.id)) == [] + assert list(sessions.items.list(value.id)) == [] + assert sessions.retrieve(value.id) == value + assert peer.beta.agents.sessions.retrieve(value.id) == value + expect_error(NotFoundError, lambda: other.beta.agents.sessions.retrieve(value.id)) + saved_sessions.append(value) + + # Omission and null retain the declared public field while resolving the same + # unique private selection; explicit selection is preserved publicly. + for declaration in (tool, {**tool, "credential_id": None}, {**tool, "credential_id": chosen.id}): + request = deepcopy(inline) + request["agent"]["tools"][0] = declaration + key = {"Idempotency-Key": "mcp-vault-" + str(uuid.uuid4())} + response = sessions.with_raw_response.create(**request, extra_headers=key) + value = response.parse() + assert response.http_response.json() == value.to_dict() + verify_session(value, ids, declaration.get("credential_id")) + retries.append((request, key, value)) + + saved = client.beta.agents.create(model="requested-model", tools=[tool, anonymous]) + saved_spec = {"agent_id": saved.id, "environment": {"type": "none"}, "vault_ids": ids} + saved_key = {"Idempotency-Key": "mcp-vault-saved-" + saved.id} + value = sessions.create(**saved_spec, extra_headers=saved_key) + saved_session = value + verify_session(value, ids, None) + retries.append((saved_spec, saved_key, value)) + assert value.agent.id == saved.id + + # Stream only the first public creation event; the ordinary fixture's SDK + # response-validation hook eagerly reads JSON and is unsuitable for live SSE. + base = str(client.base_url).rstrip("/") + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + stream_key = {"Idempotency-Key": "mcp-vault-stream-" + str(uuid.uuid4())} + with raw.stream("POST", base + "/agents/sessions", headers={**headers, **stream_key}, + json={**inline, "stream": True}) as response: + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + event = event_data(response.iter_lines()) + assert event["type"] == "agent.session.created" + assert canary not in json.dumps(event) and "mcp_credentials" not in event["session"] + streamed = sessions.retrieve(event["session"]["id"]) + assert streamed.to_dict() == event["session"] + verify_session(streamed, ids, None) + retries.append((inline, stream_key, streamed)) + + # Vault defaults retain anonymous MCP and never implicitly search other + # project Vaults. A no-match attachment is also still anonymous. + for changes in ({}, {"vault_ids": None}, {"vault_ids": []}, {"vault_ids": [attached[1].id]}): + request = {key: item for key, item in inline.items() if key != "vault_ids"} + request.update(changes) + value = sessions.create(**request) + verify_session(value, changes.get("vault_ids") or [], None) + + # Same-project users can attach the same Vaults; role names do not add a + # product-specific approval or permission boundary to the independent API. + value = peer.beta.agents.sessions.create(**inline) + verify_session(value, ids, None) + + second = credential(client, attached[1], url, "Second matching credential") + explicit = deepcopy(inline) + explicit["agent"]["tools"][0]["credential_id"] = second.id + value = sessions.create(**explicit) + verify_session(value, ids, second.id) + for request, key, original in retries: + assert sessions.create(**request, extra_headers=key) == original + assert list(sessions.turns.list(original.id)) == [] + expect_error(BadRequestError, lambda: sessions.create(**inline)) + + changed = client.beta.agents.update(saved.id, tools=[]) + saved_agents.append(changed) + assert sessions.create(**saved_spec, extra_headers=saved_key) == saved_session + override = sessions.create(**saved_spec, agent={"tools": [{**tool, "credential_id": second.id}]}) + verify_session(override, ids, second.id) + + # Saved schemas can retain references without obtaining execution access. + referenced = client.beta.agents.create(model="requested-model", tools=[{**tool, "credential_id": outside.id}]) + saved_agents.append(referenced) + expect_error(NotFoundError, lambda: sessions.create(agent_id=referenced.id, + environment={"type": "none"}, vault_ids=ids)) + + before = {item.id for item in sessions.list()} + foreign_before = {item.id for item in other.beta.agents.sessions.list()} + rejected = [] + for reference in (chosen.id, outside.id, foreign_credential.id, alternate.id, str(uuid.uuid4())): + request = deepcopy(inline) + request["agent"]["tools"][0]["credential_id"] = reference + if reference == chosen.id: + request["vault_ids"] = [attached[1].id] + rejected.append((request, 404)) + rejected.extend([({**explicit, "vault_ids": [foreign.id]}, 404), + ({**explicit, "vault_ids": [str(uuid.uuid4())]}, 404), + (inline, 400)]) + for invalid in ("invalid", 3, [None], [3], {}): + rejected.append(({**explicit, "vault_ids": invalid}, 400)) + for request, status in rejected: + for initial in ({}, {"input": "Must not be admitted", "stream": True}): + response = raw.post(base + "/agents/sessions", headers=headers, json={**request, **initial}) + assert response.status_code == status + assert response.headers["content-type"].startswith("application/json") + assert canary not in response.text and foreign.id not in response.text + assert {item.id for item in sessions.list()} == before + assert {item.id for item in other.beta.agents.sessions.list()} == foreign_before + for field in ({"vault_ids": [attached[0].id]}, + {"agent": {"model": "requested-model", "tools": [{**tool, "credential_id": second.id}]}}): + request, key, _ = retries[0] + expect_error(ConflictError, lambda: sessions.create(**{**request, **field}, extra_headers=key)) + public = raw.get(base + "/agents/sessions", headers=headers).json() + assert canary not in json.dumps(public) and "mcp_credentials" not in json.dumps(public) + + print("Public MCP Vault admission: explicit/unique selection, owner/destination isolation, safe snapshots, creation streams and stable retries passed; native execution is checked separately.") + return saved_sessions, saved_agents, retries + + +def verify_mcp_credential_recovery(client, state): + sessions, agents, retries = state + assert [client.beta.agents.sessions.retrieve(value.id) for value in sessions] == sessions + assert [client.beta.agents.retrieve(value.id) for value in agents] == agents + for request, key, value in retries: + assert client.beta.agents.sessions.create(**request, extra_headers=key) == value diff --git a/services/agents-api/tests/official_self_hosted.py b/services/agents-api/tests/official_self_hosted.py new file mode 100644 index 000000000..ebe84b00e --- /dev/null +++ b/services/agents-api/tests/official_self_hosted.py @@ -0,0 +1,369 @@ +"""Public self-hosted execution against a built standalone service and real harness.""" + +import importlib.metadata +import json +import os +from pathlib import Path +import shlex +import sys +import threading +import time +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_retrieve import verify_environment +from official_self_hosted_creation import assert_creation_retry, create_initial_session + + +def main(): + settings = json.load(sys.stdin) + mode = settings.get("creation_mode", "empty_later") + assert mode in ("empty_later", "ordinary_initial", "streamed_initial") + initial_input = mode != "empty_later" + base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) + directory = Path(settings["evidence"]) + os.umask(0o077) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] + assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] + proof = {"scope": "public Session creation and input admission through a built standalone service; real remote execution", "creation_mode": mode, + "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, "environment_reads": {}} + observations = {"sdk": [], "raw": []} + ready = {name: threading.Event() for name in observations} + done = {name: threading.Event() for name in observations} + failures = [] + lock = threading.Lock() + session_id = None + expected_environment = None + + def write_private(name, value): + data = json.dumps(value, indent=2) + assert token not in data and foreign not in data, "caller credential in public evidence" + temporary = directory / (name + ".tmp") + temporary.write_text(data) + temporary.chmod(0o600) + temporary.replace(directory / (name + ".json")) + + def client(key=token): + return OpenAI(api_key=key, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=360)) + + def wait_for(predicate, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + if failures: + raise AssertionError("public observer or input failed") from failures[0] + value = predicate() + if value: + return value + time.sleep(0.025) + raise AssertionError(label + " timed out") + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + def prompt(phase): + text = "Run the exact command `./placement.sh " + phase + "` once with the native shell. " + text += "The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." + if phase == "first": + return text + " The fictional festival name to remember is " + settings["memory"] + "." + return text + " Recall the fictional festival name from the first Turn and read retained.txt." + + def check_session(value, status): + assert value["id"] == session_id and value["object"] == "agent.session" + assert value["environment"] == expected_environment + assert value["status"] == status and value["error"] is None + actions = [{"type": "environment_connection", "environment_id": expected_environment["id"]}] + assert value["required_actions"] == (actions if status == "requires_action" else []) + assert value["vault_ids"] == [] and isinstance(value["metadata"], dict) + assert isinstance(value["created_at"], int) and isinstance(value["last_active_at"], int) + + def observe(name): + completed = set() + + def accept(value): + with lock: + observations[name].append(value) + kind = value["type"] + assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") + if kind == "agent.session.requires_action": + check_session(value["session"], "requires_action") + if kind == "agent.session.turn.completed": + completed.add(value["turn"]["id"]) + return kind == "agent.session.idle" and len(completed) == 2 + + try: + if name == "sdk": + with client() as api: + with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: + ready[name].set() + for event in stream: + if accept(event.to_dict()): + return + else: + with httpx2.Client(trust_env=False, timeout=450) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + ready[name].set() + for line in response.iter_lines(): + if line.startswith("data: ") and accept(json.loads(line[6:])): + return + raise AssertionError("live stream ended before both Turns") + except BaseException as error: + with lock: + failures.append(error) + finally: + done[name].set() + + try: + with client() as api, httpx2.Client( + base_url=base + "/v1", trust_env=False, timeout=360, + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1", "Host": "untrusted.example"}, + ) as raw: + sessions = api.beta.agents.sessions + instructions = "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0." + agent = {"model": "MiniMax-M3", "instructions": instructions, "tools": []} + environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} + creation = {"agent": agent, "environment": environment} + + def assert_empty(created): + value = created.to_dict() + assert value["status"] == "idle" and value["required_actions"] == [] and value["usage"] is None + assert value["created_at"] == value["last_active_at"] + assert value["environment"] == {**environment, "id": value["environment"]["id"], + "capability_directories": [], "remote_url": settings["remote_url"]} + assert list(sessions.turns.list(created.id)) == [] and list(sessions.items.list(created.id)) == [] + return value + + with sessions.create(**creation, input=None, stream=True) as stream: + created = next(stream) + assert created.type == "agent.session.created" + assert set(created.to_dict()) == {"type", "event_id", "session"} + proof["streamed_empty_creation"] = assert_empty(created.session) + for capability in (None, []): + value = sessions.create(agent=agent, environment={**environment, "capability_directories": capability}, input=None) + assert_empty(value) + creation_key = str(uuid.uuid4()) + if initial_input: + creation["input"] = prompt("first") if mode == "ordinary_initial" else message(prompt("first"))["input"] + created = create_initial_session(sessions, creation, creation_key, mode, assert_empty, proof) + initial = created.to_dict() + else: + created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) + initial = assert_empty(created) + session_id = created.id + expected_environment = {**environment, "id": initial["environment"]["id"], + "capability_directories": [], "remote_url": settings["remote_url"]} + agent_id = initial["agent"]["id"] + check_session(initial, "requires_action" if initial_input else "idle") + assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id + + def snapshot(name, status): + value = sessions.retrieve(session_id).to_dict() + check_session(value, status) + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 and response.json() == value + assert [item.to_dict() for item in sessions.list(agent_id=agent_id, limit=1)] == [value] + proof["snapshots"][name] = value + return value + + def environment_snapshot(name, status=None): + environment_id = expected_environment["id"] + value = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, status) + response = raw.get("/agents/environments/" + environment_id) + assert response.status_code == 200 and response.headers["cache-control"] == "no-store" + wire = verify_environment(response.json(), environment_id, status) + proof["environment_reads"][name] = {"sdk": value, "raw": wire} + + snapshot("initial", "requires_action" if initial_input else "idle") + environment_snapshot("initial", "pending") + before = {value.id for value in sessions.list()} + for change in ({"input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.invalid/image.png"}]}]}, {"agent": {**agent, "tools": [ + {"type": "function", "name": "pending", "description": "Unsupported pending action", "parameters": {"type": "object"}}]}}, + {"environment": {"type": "self_hosted", "workspace_directory": "relative"}}): + reply = raw.post("/agents/sessions", json={**creation, **change}) + assert reply.status_code == 400 and reply.headers["content-type"].startswith("application/json") + assert {value.id for value in sessions.list()} == before + cancelled = raw.post("/agents/sessions/" + session_id + "/events", + json={"events": [{"type": "agent.session.input.cancel"}]}) + assert cancelled.status_code == (409 if initial_input else 204) + proof["pre_execution_cancel_status"] = cancelled.status_code + for events in ([message("Mixed input must not commit"), {"type": "agent.session.input.cancel"}], + [{"type": "agent.session.input.tool_result", "call_id": "unknown", "output": "unused"}]): + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": events}).status_code == 400 + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + for suffix in ("", "/events", "/turns", "/items"): + assert raw.get("/agents/sessions/" + session_id + suffix, + headers={"Authorization": "Bearer " + foreign}).status_code == 404 + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Foreign")]}, + headers={"Authorization": "Bearer " + foreign}).status_code == 404 + assert raw.get("/agents/sessions", headers={"Authorization": "Bearer " + foreign}).json()["data"] == [] + proof["unsupported_profile_rejected_before_writes"] = True + proof["tenant_isolation"] = True + + for name in observations: + threading.Thread(target=observe, args=(name,), daemon=True).start() + wait_for(lambda: all(value.is_set() for value in ready.values()), 25, "live subscriptions") + first_event, first_key = message(prompt("first")), str(uuid.uuid4()) + submitted = threading.Event() + submission = {} + + def submit_first(): + try: + with client() as submitting: + began = time.monotonic() + response = submitting.beta.agents.sessions.events.with_raw_response.create( + session_id, events=[first_event], idempotency_key=first_key) + assert response.status_code == 204 and response.parse() is None + submission["elapsed_seconds"] = time.monotonic() - began + submission["status"] = response.status_code + assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable Turn admission" + except BaseException as error: + with lock: + failures.append(error) + finally: + submitted.set() + + if not initial_input: + threading.Thread(target=submit_first, daemon=True).start() + wait_for(lambda: all(any(item["type"] == "agent.session.requires_action" for item in values) + for values in observations.values()), 25, "offline action") + pending = snapshot("waiting", "requires_action") + assert pending["usage"] is None + if mode == "streamed_initial": + assert proof["creation_events"][1]["session"] == pending + began = time.monotonic() + # Exceed the server's ordinary write timeout while no executor or daemon exists. + while time.monotonic() - began < 32: + if not initial_input: + assert not submitted.is_set(), "input returned before executor/native readiness" + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + time.sleep(0.25) + proof["offline_observation_seconds"] = time.monotonic() - began + with lock: + for values in observations.values(): + if initial_input: + assert values == [], "GET events replayed initial creation activity" + else: + assert len(values) == 1 and values[0]["type"] == "agent.session.requires_action" + assert values[0]["session"] == pending + write_private("waiting", {"session_id": session_id, "environment_id": expected_environment["id"], + "remote_url": expected_environment["remote_url"], "creation_mode": mode}) + wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection before daemon startup") + environment_snapshot("connected_before_execution", "connected") + write_private("initial-connection-read", {"environment_id": expected_environment["id"]}) + if initial_input: + proof["first_input"] = {"source": mode, "creation_response_elapsed_seconds": proof["creation_response_elapsed_seconds"]} + else: + wait_for(submitted.is_set, 150, "first input admission") + assert submission["elapsed_seconds"] >= 32 + proof["first_input"] = submission + + def completed(count): + turns = list(sessions.turns.list(session_id, order="asc")) + assert len(turns) <= count + assert not any(turn.status in ("failed", "cancelled") for turn in turns) + return turns if len(turns) == count and all(turn.status == "completed" for turn in turns) else None + + first_turn = wait_for(lambda: completed(1), 150, "first real Turn")[0] + snapshot("after_first", "idle") + environment_snapshot("after_first") + if initial_input: + assert_creation_retry(sessions, raw, creation, creation_key, session_id) + else: + sessions.events.create(session_id, events=[first_event], idempotency_key=first_key) + assert len(list(sessions.turns.list(session_id))) == 1 + write_private("first-completed", {"turn_id": first_turn.id}) + wait_for(lambda: (directory / "resume-ready.json").exists(), 45, "retained native binding and executor reconnection") + environment_snapshot("before_resumed", "connected") + second_event, second_key = message(prompt("resumed")), str(uuid.uuid4()) + reply = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [second_event]}, + headers={"Idempotency-Key": second_key}) + assert reply.status_code == 204 and reply.content == b"" + turns = wait_for(lambda: completed(2), 150, "second real Turn") + wait_for(lambda: all(value.is_set() for value in done.values()), 30, "both completion streams") + final = snapshot("final", "idle") + environment_snapshot("after_remote_file_and_history") + items = list(sessions.items.list(session_id, limit=100, order="asc")) + proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] + for turn, phase in zip(turns, ("first", "resumed")): + assert sessions.turns.retrieve(turn.id, session_id=session_id) == turn + group = [item.to_dict() for item in items if item.turn_id == turn.id] + commands = [item for item in group if item["type"] == "command_execution" + and "remote-stdout:" + phase in item.get("output", "") + and "remote-stderr:" + phase in item.get("output", "")] + assert len(commands) == 1 + command = commands[0] + assert command["exit_code"] == 7 and command["cwd"] == settings["workspace_directory"] + argv = shlex.split(command["command"]) + assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./placement.sh " + phase] + answer = "\n".join(part.get("text", "") for item in group + if item["type"] == "message" and item.get("role") == "assistant" for part in item["content"]) + assert settings["memory"] in answer and settings["instruction"] in answer + assert "WRONG_LOCAL_INSTRUCTIONS" not in answer + assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] + assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] + retries = [(second_event, second_key)] if initial_input else [(first_event, first_key), (second_event, second_key)] + for event, key in retries: + sessions.events.create(session_id, events=[event], idempotency_key=key) + response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed retry")]}, + headers={"Idempotency-Key": key}) + assert response.status_code == 409 + if initial_input: + assert_creation_retry(sessions, raw, creation, creation_key, session_id) + time.sleep(1) + assert list(sessions.turns.list(session_id, order="asc")) == turns + assert list(sessions.items.list(session_id, limit=100, order="asc")) == items + proof["retries_preserved_turns_and_items"] = True + + for values in observations.values(): + ids = [value["event_id"] for value in values] + assert len(ids) == len(set(ids)) + kinds = [value["type"] for value in values] + connected = kinds.index("agent.session.environment.connected") + cleared, admitted = kinds.index("agent.session.idle"), kinds.index("agent.session.turn.created") + assert connected < cleared < admitted + if initial_input: + assert "agent.session.created" not in kinds and "agent.session.requires_action" not in kinds + else: + request = kinds.index("agent.session.requires_action") + assert request < connected and kinds.count("agent.session.requires_action") == 1 + assert values[request]["session"] == pending + assert set(values[cleared]) == {"type", "event_id", "session"} + check_session(values[cleared]["session"], "idle") + assert values[cleared]["session"]["usage"] is None + turn_ids = [turn.id for turn in turns] + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == turn_ids + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == turn_ids + for event in values: + if event["type"].startswith("agent.session.environment."): + assert set(event) == {"type", "event_id", "session_id", "environment"} + assert event["environment"]["id"] == expected_environment["id"] and event["environment"]["error"] is None + assert [value["event_id"] for value in observations["sdk"]] == [value["event_id"] for value in observations["raw"]] + with client() as recovered: + resource = recovered.beta.agents.sessions + assert resource.retrieve(session_id).to_dict() == final + assert list(resource.turns.list(session_id, order="asc")) == turns + assert list(resource.items.list(session_id, limit=100, order="asc")) == items + verify_environment(recovered.beta.agents.environments.retrieve(expected_environment["id"]).to_dict(), expected_environment["id"]) + proof["query_recovery"] = True + proof["live_event_scope"] = "common GET subscription interval; creation events are recorded separately" + proof["status"] = "public_creation_waiting_admission_and_real_remote_continuation_verified" + print("Built service (" + mode + "): public self-hosted creation/wait, safe Environment GET, fixed SDK/raw SSE, two real remote Turns and retry recovery passed.", flush=True) + finally: + with lock: + proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) + write_private("public-environment-proof", proof) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/agents-api/tests/official_self_hosted_cancel.py new file mode 100644 index 000000000..03d220f80 --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_cancel.py @@ -0,0 +1,134 @@ +"""Public cancellation admission with an offline Worker and controlled active Turns.""" + +from concurrent.futures import ThreadPoolExecutor +import importlib.metadata +import json +from pathlib import Path +import sys +import threading +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import APIStatusError, OpenAI + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + base, token = settings["base"], settings["token"] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + cancel = {"type": "agent.session.input.cancel"} + batch = [cancel, cancel] + message = {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Must not be admitted."}]}]} + + def client(): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) + + with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: + sessions = api.beta.agents.sessions + endpoint = base + "/v1/agents/sessions/" + + def sdk_submit(session_id, key, events=batch, expected=204): + with client() as caller: + try: + response = caller.beta.agents.sessions.events.with_raw_response.create( + session_id, events=events, idempotency_key=key) + assert response.status_code == expected == 204 and response.content == b"" + assert response.parse() is None + except APIStatusError as error: + assert error.status_code == expected and expected != 204 + + def raw_submit(session_id, key, events=batch, expected=204, key_token=token): + response = raw.post(endpoint + session_id + "/events", json={"events": events}, + headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) + assert response.status_code == expected, (response.status_code, expected) + if expected == 204: + assert response.content == b"" and response.headers["cache-control"] == "no-store" + + def concurrent(session_id, key): + barrier = threading.Barrier(4) + + def submit(index): + barrier.wait(timeout=10) + (sdk_submit if index % 2 else raw_submit)(session_id, key) + + with ThreadPoolExecutor(max_workers=4) as workers: + list(workers.map(submit, range(4))) + + def current(session_id): + value = sessions.retrieve(session_id).to_dict() + response = raw.get(endpoint + session_id) + assert response.status_code == 200 and response.json() == value + assert value["environment"]["type"] == "self_hosted" + return value + + phase = settings["phase"] + if phase == "create": + request = {"agent": {"model": "test-model", "instructions": "Controlled cancellation admission."}, + "environment": {"type": "self_hosted", "workspace_directory": "/private-cancel-workspace"}} + main_session = sessions.create(**request) + initial = sessions.create(**request, input="Keep this pending initial input.") + later = sessions.create(**request) + deleted = sessions.create(**request) + sessions.delete(deleted.id) + result = {"id": main_session.id, "environment_id": main_session.environment.id, + "initial_id": initial.id, "later_id": later.id, "deleted_id": deleted.id, + "idle_key": str(uuid.uuid4()), "active_key": str(uuid.uuid4())} + before = current(main_session.id) + concurrent(main_session.id, result["idle_key"]) + sdk_submit(main_session.id, result["idle_key"]) + raw_submit(main_session.id, result["idle_key"]) + assert current(main_session.id) == before and before["status"] == "idle" + assert list(sessions.turns.list(main_session.id)) == list(sessions.items.list(main_session.id)) == [] + else: + result = settings["accepted"] + session_id, turn_id = result["id"], settings["turn_id"] + before = current(session_id) + assert before["status"] == "in_progress" and before["environment"]["id"] == result["environment_id"] + turn = sessions.turns.retrieve(turn_id, session_id=session_id).to_dict() + items = [item.to_dict() for item in sessions.items.list(session_id)] + assert turn["status"] == "in_progress" + if phase == "active": + concurrent(session_id, result["active_key"]) + assert any("Retained partial output." in json.dumps(item) for item in items) + else: + keys = [result["idle_key"]] if phase == "idle_replay" else [result["idle_key"], result["active_key"]] + for key in keys: + sdk_submit(session_id, key) + raw_submit(session_id, key) + assert current(session_id) == before + assert sessions.turns.retrieve(turn_id, session_id=session_id).to_dict() == turn + assert raw.get(endpoint + session_id + "/turns/" + turn_id).json() == turn + assert [item.to_dict() for item in sessions.items.list(session_id)] == items + sdk_submit(session_id, result["idle_key"], [cancel], 409) + raw_submit(session_id, result["idle_key"], [cancel], 409) + for pending_id in (result["initial_id"], result["later_id"]): + pending = current(pending_id) + assert pending["status"] == "requires_action" + sdk_submit(pending_id, "rejected-pending-cancel", expected=409) + raw_submit(pending_id, "rejected-pending-cancel", expected=409) + assert current(pending_id) == pending + assert list(sessions.turns.list(pending_id)) == list(sessions.items.list(pending_id)) == [] + for events in ([message, cancel], [cancel, message]): + sdk_submit(session_id, str(uuid.uuid4()), events, 400) + raw_submit(session_id, str(uuid.uuid4()), events, 400) + missing_function = [{"type": "agent.session.input.tool_result", "turn_id": turn_id, + "call_id": "unknown-function", "success": True, "output": "not admitted"}] + sdk_submit(session_id, str(uuid.uuid4()), missing_function, 404) + raw_submit(session_id, str(uuid.uuid4()), missing_function, 404) + raw_submit(session_id, "foreign-cancel", expected=404, key_token=settings["foreign_token"]) + for missing in (result["deleted_id"], str(uuid.uuid4())): + sdk_submit(missing, "missing-cancel", expected=404) + raw_submit(missing, "missing-cancel", expected=404) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_cancel_native.py b/services/agents-api/tests/official_self_hosted_cancel_native.py new file mode 100644 index 000000000..2c4a8267a --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_cancel_native.py @@ -0,0 +1,256 @@ +"""Real public self-hosted cancellation and cold continuation against a built service.""" + +import importlib.metadata +import json +import os +from pathlib import Path +import shlex +import sys +import threading +import time +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_retrieve import verify_environment + + +def main(): + settings = json.load(sys.stdin) + base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) + directory = Path(settings["evidence"]) + os.umask(0o077) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] + proof = {"scope": "built service; public self_hosted long-command cancellation and real cold continuation", + "case": "public_cancellation", "sdk_version": distribution.version, "sdk_commit": pin["commit"], + "snapshots": {}, "limits": ["204 is durable admission, not process exit", + "OS observations are recorded separately by the native fixture; no general quiescence guarantee", + "Cancellation preserves observed output and usage; complete native final usage remains unverified"]} + observations = {"sdk": [], "raw": []} + ready = {name: threading.Event() for name in observations} + done = {name: threading.Event() for name in observations} + failures, lock = [], threading.Lock() + session_id = None + + def write_private(name, value): + data = json.dumps(value, indent=2) + assert token not in data and foreign not in data, "caller credential in public evidence" + temporary = directory / (name + ".tmp") + temporary.write_text(data) + temporary.chmod(0o600) + temporary.replace(directory / (name + ".json")) + + def client(): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=360)) + + def wait_for(predicate, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + if failures: + raise AssertionError("public observer or input failed") from failures[0] + value = predicate() + if value: + return value + time.sleep(0.025) + raise AssertionError(label + " timed out") + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + def observe(name): + terminal = {} + + def accept(value): + with lock: + observations[name].append(value) + kind = value["type"] + assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed") + if kind in ("agent.session.turn.cancelled", "agent.session.turn.completed"): + terminal[value["turn"]["id"]] = kind + return kind == "agent.session.idle" and len(terminal) == 2 + + try: + if name == "sdk": + with client() as api: + with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: + ready[name].set() + for event in stream: + if accept(event.to_dict()): + return + else: + with httpx2.Client(trust_env=False, timeout=450) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + ready[name].set() + for line in response.iter_lines(): + if line.startswith("data: ") and accept(json.loads(line[6:])): + return + raise AssertionError("live stream ended before cancelled and resumed Turns") + except BaseException as error: + with lock: + failures.append(error) + finally: + done[name].set() + + try: + with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: + sessions = api.beta.agents.sessions + environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} + instructions = "Use the native shell for exact requested commands. Do not add wrappers, separators or recovery. Wait or poll a running command; never finish the Turn while it is still running." + creation = {"agent": {"model": settings.get("model") or "MiniMax-M3", "instructions": instructions, "tools": []}, "environment": environment} + creation_key = str(uuid.uuid4()) + created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) + session_id, environment_id = created.id, created.environment.id + expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} + assert created.environment.to_dict() == expected_environment + assert created.status == "idle" and created.required_actions == [] and created.usage is None + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + + def snapshot(name, status): + value = sessions.retrieve(session_id).to_dict() + assert value["id"] == session_id and value["environment"] == expected_environment and value["status"] == status + assert value["error"] is None + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 and response.json() == value + proof["snapshots"][name] = value + return value + + for name in observations: + threading.Thread(target=observe, args=(name,), daemon=True).start() + wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") + first_command = "./placement.sh first" + first_event = message("First run the exact command `" + first_command + "` once with the native shell. Its exit 7 is intentional; preserve stdout/stderr and do not retry. Then run the exact command `./long.sh cancel` once as a separate native shell call. Keep waiting or polling, and do not finish while that long command is running. Remember the fictional festival name " + settings["memory"] + ".") + first_key, cancel_key = str(uuid.uuid4()), str(uuid.uuid4()) + cancel_events = [{"type": "agent.session.input.cancel"}] + submitted = threading.Event() + + def submit_first(): + try: + with client() as submitting: + response = submitting.beta.agents.sessions.events.with_raw_response.create(session_id, events=[first_event], idempotency_key=first_key) + assert response.status_code == 204 and response.parse() is None + assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable admission" + except BaseException as error: + with lock: + failures.append(error) + finally: + submitted.set() + + threading.Thread(target=submit_first, daemon=True).start() + wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "pending public input") + pending = snapshot("pending", "requires_action") + assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] + assert not submitted.is_set() and list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + write_private("waiting", {"session_id": session_id, "environment_id": environment_id, + "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) + wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") + write_private("initial-connection-read", {"environment_id": environment_id}) + wait_for(submitted.is_set, 150, "first input admission") + first_turn = list(sessions.turns.list(session_id))[0] + + def command_items(turn_id, phase): + return [item.to_dict() for item in sessions.items.list(session_id, limit=100, order="asc") + if item.turn_id == turn_id and item.type == "command_execution" + and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] + + partial = wait_for(lambda: command_items(first_turn.id, "first"), 120, "public partial command output") + assert len(partial) == 1 + assert partial[0]["cwd"] == settings["workspace_directory"] and partial[0]["exit_code"] == 7 + argv = shlex.split(partial[0]["command"]) + assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", first_command] + wait_for(lambda: (directory / "cancel-ready.json").exists(), 30, "independent long-command activity") + def running_command(): + return [item.to_dict() for item in sessions.items.list(session_id, limit=100, order="asc") + if item.turn_id == first_turn.id and item.type == "command_execution" + and shlex.split(item.command)[1:] == ["-lc", "./long.sh cancel"]] + running = wait_for(running_command, 15, "public long-command identity") + assert len(running) == 1 and running[0]["status"] == "in_progress" + assert running[0]["cwd"] == settings["workspace_directory"] + proof["running_command_before_cancel"] = running[0] + assert sessions.turns.retrieve(first_turn.id, session_id=session_id).status == "in_progress" + proof["partial_before_cancel"] = partial + proof["partial_output_scope"] = "Completed first command within the still-active Turn; running-command output deltas are not asserted" + write_private("cancel-requested", {"turn_id": first_turn.id, "request_started_unix": str(time.time())}) + began = time.monotonic() + response = sessions.events.with_raw_response.create(session_id, events=cancel_events, idempotency_key=cancel_key) + assert response.status_code == 204 and response.parse() is None + proof["cancel_response"] = {"status": 204, "elapsed_seconds": time.monotonic() - began} + first_turn = wait_for(lambda: (turn if (turn := sessions.turns.retrieve(first_turn.id, session_id=session_id)).status == "cancelled" else None), 45, "cancelled public Turn") + cancelled_output = command_items(first_turn.id, "first") + assert len(cancelled_output) == 1 and cancelled_output[0]["id"] == partial[0]["id"] + assert partial[0]["output"] in cancelled_output[0]["output"] + proof["retained_cancelled_output"] = cancelled_output + snapshot("cancelled", "idle") + write_private("first-cancelled", {"turn_id": first_turn.id}) + wait_for(lambda: (directory / "resume-ready.json").exists(), 90, "native exit measurement and retained binding") + second_event = message("Run the exact command `./resume.sh` once with the native shell. It waits for the test gate, then executes the resumed verification with intentional exit 7; preserve that exit and do not retry. Keep waiting while it runs. Read retained.txt and recall the fictional festival name from the first Turn. Include the remembered name and command stdout/stderr in the final answer.") + second_key = str(uuid.uuid4()) + response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [second_event]}, headers={"Idempotency-Key": second_key}) + assert response.status_code == 204 and response.content == b"" + wait_for(lambda: (directory / "resumed-active.json").exists(), 150, "actual resumed command activity") + turns = list(sessions.turns.list(session_id, order="asc")) + assert len(turns) == 2 and turns[0].status == "cancelled" and turns[1].status == "in_progress" + second_id = turns[1].id + sessions.events.create(session_id, events=cancel_events, idempotency_key=cancel_key) + response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": cancel_events}, headers={"Idempotency-Key": cancel_key}) + assert response.status_code == 204 and response.content == b"" + time.sleep(1) + assert sessions.turns.retrieve(second_id, session_id=session_id).status == "in_progress" + write_private("old-cancel-retried", {"turn_id": second_id}) + second_turn = wait_for(lambda: (turn if (turn := sessions.turns.retrieve(second_id, session_id=session_id)).status == "completed" else None), 150, "uncancelled resumed Turn") + wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") + final = snapshot("final", "idle") + turns = [first_turn, second_turn] + items = list(sessions.items.list(session_id, limit=100, order="asc")) + resumed = command_items(second_id, "resumed") + assert len(resumed) == 1 and resumed[0]["exit_code"] == 7 and resumed[0]["cwd"] == settings["workspace_directory"] + argv = shlex.split(resumed[0]["command"]) + assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./resume.sh"] + answer = "\n".join(part.get("text", "") for item in items if item.turn_id == second_id and item.type == "message" + and item.role == "assistant" for part in item.to_dict()["content"]) + assert settings["memory"] in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer + proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] + assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] + assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] + for event, key in ((first_event, first_key), (second_event, second_key)): + sessions.events.create(session_id, events=[event], idempotency_key=key) + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed cancel key")]}, headers={"Idempotency-Key": cancel_key}).status_code == 409 + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": cancel_events}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 + time.sleep(1) + assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, limit=100, order="asc")) == items + for values in observations.values(): + ids = [value["event_id"] for value in values] + assert len(ids) == len(set(ids)) + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.cancelled"] == [first_turn.id] + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == [second_id] + assert observations["sdk"] == observations["raw"], "SDK/raw common live event payloads differ" + with client() as recovered: + resource = recovered.beta.agents.sessions + assert resource.retrieve(session_id).to_dict() == final + assert list(resource.turns.list(session_id, order="asc")) == turns + assert list(resource.items.list(session_id, limit=100, order="asc")) == items + proof["query_recovery"] = proof["old_cancel_did_not_retarget"] = proof["retries_preserved_turns_and_items"] = True + proof["status"] = "public_cancelled_turn_output_and_cold_continuation_verified" + print("Built service: real public self-hosted cancellation, retained output/history, cold continuation and old-cancel retry passed.", flush=True) + finally: + with lock: + proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) + write_private("public-cancellation-proof", proof) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_creation.py b/services/agents-api/tests/official_self_hosted_creation.py new file mode 100644 index 000000000..2d132261a --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_creation.py @@ -0,0 +1,40 @@ +"""Initial creation checks shared by the public self-hosted native fixture.""" + +import time + + +def create_initial_session(sessions, request, key, mode, assert_empty, proof): + began = time.monotonic() + arguments = {**request, "extra_headers": {"Idempotency-Key": key}, "timeout": 10} + if mode == "ordinary_initial": + created = sessions.create(**arguments) + proof["creation_response"] = created.to_dict() + elapsed = time.monotonic() - began + else: + assert mode == "streamed_initial" + with sessions.create(**arguments, stream=True) as stream: + first = next(stream) + elapsed = time.monotonic() - began + assert first.type == "agent.session.created" + assert set(first.to_dict()) == {"type", "event_id", "session"} + assert_empty(first.session) + action = next(stream) + assert action.type == "agent.session.requires_action" + assert set(action.to_dict()) == {"type", "event_id", "session"} + assert action.event_id != first.event_id + assert action.session.id == first.session.id + assert action.session.environment == first.session.environment + proof["creation_events"] = [first.to_dict(), action.to_dict()] + created = action.session + proof["creation_stream_closed_before_connection"] = True + assert elapsed < 10, "initial creation waited for an offline executor" + assert created.status == "requires_action" and created.error is None and created.usage is None + proof["creation_response_elapsed_seconds"] = elapsed + return created + + +def assert_creation_retry(sessions, raw, request, key, session_id): + assert sessions.create(**request, extra_headers={"Idempotency-Key": key}).id == session_id + response = raw.post("/agents/sessions", json={**request, "input": "Changed initial retry"}, + headers={"Idempotency-Key": key}) + assert response.status_code == 409 diff --git a/services/agents-api/tests/official_self_hosted_functions.py b/services/agents-api/tests/official_self_hosted_functions.py new file mode 100644 index 000000000..d21b1a2f8 --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_functions.py @@ -0,0 +1,170 @@ +"""Public function admission and reads using controlled calls, observations and receipts.""" + +from concurrent.futures import ThreadPoolExecutor +import importlib.metadata +import json +from pathlib import Path +import sys +import threading +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import APIStatusError, OpenAI + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + base, token = settings["base"], settings["token"] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + + def client(): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) + + with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: + sessions = api.beta.agents.sessions + endpoint = base + "/v1/agents/sessions" + + def submit(session_id, events, key, expected=204, sdk=False, key_token=token): + if sdk: + with client() as caller: + try: + response = caller.beta.agents.sessions.events.with_raw_response.create( + session_id, events=events, idempotency_key=key) + assert response.status_code == expected == 204 and response.content == b"" + assert response.parse() is None + except APIStatusError as error: + assert error.status_code == expected and expected != 204 + else: + response = raw.post(endpoint + "/" + session_id + "/events", json={"events": events}, + headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) + assert response.status_code == expected, (response.status_code, expected) + if expected == 204: + assert response.content == b"" + + def current(session_id): + value = sessions.retrieve(session_id).to_dict() + response = raw.get(endpoint + "/" + session_id) + assert response.status_code == 200 and response.json() == value + assert value["environment"]["type"] == "self_hosted" + return value + + phase = settings["phase"] + if phase == "create": + tool = {"type": "function", "name": "lookup_ticket", "description": "Look up a ticket.", + "parameters": {"type": "object", "properties": {"ticket": {"type": "string"}}, + "required": ["ticket"], "additionalProperties": False}} + expected = {**tool, "defer_loading": False} + agent = {"model": "test-model", "tools": [tool]} + environment = {"type": "self_hosted", "workspace_directory": "/private-function-workspace"} + key = str(uuid.uuid4()) + session = sessions.create(agent=agent, environment=environment, extra_headers={"Idempotency-Key": key}) + assert session.agent.tools[0].to_dict() == expected + assert sessions.create(agent={**agent, "tools": [expected]}, environment=environment, + extra_headers={"Idempotency-Key": key}).id == session.id + saved = api.beta.agents.create(**agent) + response = raw.post(endpoint, json={"agent_id": saved.id, "environment": environment}) + assert response.status_code == 200 + saved_session = response.json() + assert saved_session["agent"]["id"] == saved.id and saved_session["agent"]["tools"] == [expected] + api.beta.agents.update(saved.id, tools=[{**tool, "description": "Changed later."}]) + assert current(saved_session["id"]) == saved_session + initial = sessions.create(agent=agent, environment=environment, input="Retain the initial function prompt.") + later = sessions.create(agent=agent, environment=environment) + for tools in ([{**tool, "defer_loading": True}], [{**tool, "parameters": None}], [tool, tool]): + response = raw.post(endpoint, json={"agent": {**agent, "tools": tools}, "environment": environment}) + assert response.status_code == 400 + unsupported = api.beta.agents.create(**{**agent, "tools": [{**tool, "defer_loading": True}]}) + response = raw.post(endpoint, json={"agent_id": unsupported.id, "environment": environment}) + assert response.status_code == 400 + result = {"id": session.id, "saved_id": saved_session["id"], "initial_id": initial.id, "later_id": later.id, + "environment_id": session.environment.id, "tools": [expected], "result_key": str(uuid.uuid4())} + unknown_result = {"type": "agent.session.input.tool_result", "turn_id": str(uuid.uuid4()), + "call_id": "unknown", "success": True} + for sdk in (True, False): + submit(session.id, [unknown_result], "idle-result", 404, sdk) + assert current(session.id)["status"] == "idle" + for session_id in (session.id, saved_session["id"], initial.id, later.id): + assert list(sessions.turns.list(session_id)) == list(sessions.items.list(session_id)) == [] + else: + result = settings["accepted"] + session_id, turn = result["id"], settings["turn_id"] + calls = settings["calls"] + outputs = [{"success": False, "error": "controlled tool failure", "output": [ + {"type": "input_text", "text": "before"}, {"type": "input_text", "text": ""}, + {"type": "input_image", "image_url": "data:image/png;base64,AA=="}, + {"type": "input_text", "text": "after"}]}, {"success": True, "output": None, "error": None}, {"success": True}] + batch = [{"type": "agent.session.input.tool_result", "turn_id": turn, "call_id": call, **output} + for call, output in zip(calls, outputs)] + before = current(session_id) + assert before["agent"]["tools"] == result["tools"] + if phase in ("reject", "submit"): + assert before["status"] == "requires_action" + actions = before["required_actions"] + assert {action["call_id"] for action in actions} == set(calls) + assert all(action["type"] == "function_call" and action["turn_id"] == turn and + action["name"] == "lookup_ticket" and action["arguments"] == {"ticket": "42"} for action in actions) + if phase == "reject": + missing = {**batch[1], "call_id": "missing"} + foreign_turn = {**batch[1], "turn_id": settings["other_turn"], "call_id": settings["other_call"]} + cancel = {"type": "agent.session.input.cancel"} + message = {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Must roll back."}]}]} + for events, status in (([batch[0], missing], 404), ([batch[0], foreign_turn], 404), + ([batch[0], {**batch[0], "success": True}], 409), + ([batch[0], cancel], 400), ([message, batch[0]], 400)): + for sdk in (True, False): + submit(session_id, events, "failed-batch", status, sdk) + submit(result["saved_id"], batch, "other-session", 404) + submit(session_id, batch, "foreign", 404, key_token=settings["foreign_token"]) + for pending_id in (result["initial_id"], result["later_id"]): + waiting = current(pending_id) + for sdk in (True, False): + submit(pending_id, batch, "pending-result", 409, sdk) + assert current(pending_id) == waiting and waiting["status"] == "requires_action" + assert current(session_id) == before + elif phase == "submit": + barrier = threading.Barrier(4) + + def submit_once(index): + barrier.wait(timeout=10) + submit(session_id, batch, result["result_key"], sdk=bool(index % 2)) + + with ThreadPoolExecutor(max_workers=4) as workers: + list(workers.map(submit_once, range(4))) + assert current(session_id) == before + assert not any(item.type == "function_call_output" for item in sessions.items.list(session_id)) + result = {**result, "batch": batch} + else: + assert sessions.turns.retrieve(turn, session_id=session_id).status == "completed" + assert before["status"] == ("idle" if phase == "terminal" else "requires_action") + for sdk in (True, False): + submit(session_id, batch, result["result_key"], sdk=sdk) + submit(session_id, list(reversed(batch)), result["result_key"], 409, sdk) + submit(session_id, [{**batch[1], "output": "changed"}], "changed-result", 409, sdk) + assert current(session_id) == before + if phase == "later": + assert len(before["required_actions"]) == 1 + assert before["required_actions"][0]["turn_id"] == settings["next_turn"] + assert before["required_actions"][0]["call_id"] == settings["next_call"] + listed = [item.to_dict() for item in sessions.items.list(session_id, order="asc")] + response = raw.get(endpoint + "/" + session_id + "/items", params={"order": "asc"}) + assert response.status_code == 200 and response.json()["data"] == listed + projected = [item for item in listed if item["type"] == "function_call_output"] + assert [item["call_id"] for item in projected] == calls + for item, output in zip(projected, outputs): + for field in ("output", "error"): + assert (field in item) == (field in output) and item.get(field) == output.get(field) + environment = api.beta.agents.environments.retrieve(result["environment_id"]).to_dict() + assert environment["type"] == "self_hosted" and environment["files"] == environment["plugins"] == environment["skills"] == [] + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_functions_native.py b/services/agents-api/tests/official_self_hosted_functions_native.py new file mode 100644 index 000000000..6e7051b47 --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_functions_native.py @@ -0,0 +1,318 @@ +"""Real public self-hosted functions through the pinned SDK's automatic handlers.""" + +import importlib.metadata +import json +import os +from pathlib import Path +import shlex +import sys +import threading +import time +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_retrieve import verify_environment + + +def main(): + settings = json.load(sys.stdin) + base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) + directory = Path(settings["evidence"]) + os.umask(0o077) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] + memory = "festival-" + uuid.uuid4().hex + private_exception = "private-handler-exception-" + uuid.uuid4().hex + proof = {"case": "public_functions", "scope": "built service, real remote MiniMax, official automatic function handlers", + "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, + "limits": ["One function success and one SDK-mapped error; no image or complete tool-set claim", + "Cold continuation is tested; recovery of an interrupted native call is not"]} + observations = {"sdk": [], "raw": []} + ready = {name: threading.Event() for name in observations} + done = {name: threading.Event() for name in observations} + failures, submissions, input_responses, handler_calls, helper_events = [], [], [], [], [[], []] + lock = threading.Lock() + session_id = None + + def write_private(name, value): + data = json.dumps(value, indent=2) + assert token not in data and foreign not in data and private_exception not in data + temporary = directory / (name + ".tmp") + temporary.write_text(data) + temporary.chmod(0o600) + temporary.replace(directory / (name + ".json")) + + def capture_result(response): + request = response.request + if request.method == "POST" and request.url.path.endswith("/events"): + events = json.loads(request.content).get("events", []) + if events and all(event["type"] == "agent.session.input.tool_result" for event in events): + assert len(events) == 1 + with lock: + submissions.append({"status": response.status_code, "key": request.headers["Idempotency-Key"], "event": events[0]}) + elif events and all(event["type"] == "agent.session.input.message" for event in events): + with lock: + input_responses.append({"status": response.status_code, "key": request.headers["Idempotency-Key"]}) + + def client(capture=False): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=360, + event_hooks={"response": [capture_result]} if capture else None)) + + def wait_for(predicate, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + if failures: + raise AssertionError("public function observer or handler failed") from failures[0] + value = predicate() + if value: + return value + time.sleep(0.025) + raise AssertionError(label + " timed out") + + def observe(name): + completed = set() + + def accept(value): + with lock: + observations[name].append(value) + kind = value["type"] + assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") + if kind == "agent.session.turn.completed": + completed.add(value["turn"]["id"]) + return kind == "agent.session.idle" and len(completed) == 2 + + try: + if name == "sdk": + with client() as api: + with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: + ready[name].set() + for event in stream: + if accept(event.to_dict()): + return + else: + with httpx2.Client(trust_env=False, timeout=450) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + ready[name].set() + for line in response.iter_lines(): + if line.startswith("data: ") and accept(json.loads(line[6:])): + return + raise AssertionError("live stream ended before two function Turns") + except BaseException as error: + with lock: + failures.append(error) + finally: + done[name].set() + + try: + with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: + sessions = api.beta.agents.sessions + tool = {"type": "function", "name": "lookup_festival", "description": "Call once per requested phase to obtain the festival record.", + "parameters": {"type": "object", "properties": {"phase": {"type": "string", "enum": ["first", "resumed"]}}, + "required": ["phase"], "additionalProperties": False}} + environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} + instructions = "Use lookup_festival exactly once when requested, with the requested phase. Use the native shell for exact requested commands. The command argument must match the supplied text: no wrapper, appended echo, separators or error recovery. Exit 7 is intentional and must remain the native exit status. A function-tool failure is intentional: report it without retries or alternate tools." + creation = {"agent": {"model": "MiniMax-M3", "instructions": instructions, "tools": [tool]}, "environment": environment} + creation_key = str(uuid.uuid4()) + created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) + session_id, environment_id = created.id, created.environment.id + expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} + assert created.environment.to_dict() == expected_environment and created.agent.tools[0].to_dict() == {**tool, "defer_loading": False} + assert created.status == "idle" and created.required_actions == [] and created.usage is None + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + + def snapshot(name, status): + value = sessions.retrieve(session_id).to_dict() + assert value["environment"] == expected_environment and value["status"] == status and value["error"] is None + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 and response.json() == value + proof["snapshots"][name] = value + return value + + def accepted_result(index): + with lock: + successful = [entry for entry in submissions if entry["status"] == 204] + assert len(successful) == index + 1 + assert successful[index]["key"] and successful[index]["event"]["success"] == (index == 0) + return successful[index] + + def replay_result(submission): + response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [submission["event"]]}, + headers={"Idempotency-Key": submission["key"]}) + assert response.status_code == 204 and response.content == b"" + + def run_turn(index): + phase = ("first", "resumed")[index] + prompt = ("Call lookup_festival once with phase first. Remember its festival value without writing it to files. Then run the exact native shell command `./placement.sh first` once. Its exit 7 is intentional; preserve stdout/stderr and do not retry. Include the festival value and command output in your final answer." + if index == 0 else "Recall the festival value returned by lookup_festival in the first Turn. Call lookup_festival once with phase resumed; its failure is intentional, do not retry it. Read retained.txt in a separate native tool call. Then run the exact native shell command `./placement.sh resumed` once in a new tool call with intentional exit 7. Do not combine the file read with this command. Include the remembered festival, retained file contents, exact tool error and command stdout/stderr in your final answer.") + assert memory not in prompt + with client(capture=True) as submitting: + resource = submitting.beta.agents.sessions + + def lookup_festival(arguments): + try: + assert arguments == {"phase": phase} + handler_calls.append({"phase": phase, "arguments": arguments}) + assert len(handler_calls) == index + 1, "function handler executed more than once" + call = [event["item"] for event in helper_events[index] if event["type"] == "agent.session.turn.item.added" + and event["item"]["type"] == "function_call"][-1] + + def pending_action(): + current = resource.retrieve(session_id) + return current.to_dict() if current.status == "requires_action" and any( + action.type == "function_call" and action.call_id == call["call_id"] and action.turn_id == call["turn_id"] + for action in current.required_actions) else None + + pending = wait_for(pending_action, 15, "persisted function action") + proof["snapshots"][phase + "_function_pending"] = pending + if index == 1: + before = [item.to_dict() for item in resource.items.list(session_id, order="asc", limit=100)] + replay_result(accepted_result(0)) + assert pending_action() == pending, "old result changed the current pending action" + assert [item.to_dict() for item in resource.items.list(session_id, order="asc", limit=100)] == before + assert len(list(resource.turns.list(session_id))) == 2 + proof["old_result_did_not_retarget"] = True + except BaseException as error: + with lock: + failures.append(error) + raise + if index == 1: + raise RuntimeError(private_exception) + return {"festival": memory} + + with resource.stream(session_id, input=prompt, tool_handlers={"lookup_festival": lookup_festival}, + idempotency_key=input_keys[index], timeout=360) as stream: + for event in stream: + helper_events[index].append(event.to_dict()) + + for name in observations: + threading.Thread(target=observe, args=(name,), daemon=True).start() + wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") + input_keys = [str(uuid.uuid4()), str(uuid.uuid4())] + first_done = threading.Event() + + def first_turn(): + try: + run_turn(0) + except BaseException as error: + with lock: + failures.append(error) + finally: + first_done.set() + + threading.Thread(target=first_turn, daemon=True).start() + wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "offline input reservation") + pending = snapshot("offline", "requires_action") + assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] + assert not first_done.is_set() and not handler_calls and not input_responses + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + write_private("waiting", {"session_id": session_id, "environment_id": environment_id, + "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) + wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") + write_private("initial-connection-read", {"environment_id": environment_id}) + wait_for(first_done.is_set, 180, "first real function Turn") + first_result = accepted_result(0) + first_id = first_result["event"]["turn_id"] + assert sessions.turns.retrieve(first_id, session_id=session_id).status == "completed" + snapshot("first_completed", "idle") + first_items = list(sessions.items.list(session_id, order="asc", limit=100)) + replay_result(first_result) + assert list(sessions.items.list(session_id, order="asc", limit=100)) == first_items + write_private("first-completed", {"turn_id": first_id, "call_id": first_result["event"]["call_id"]}) + wait_for(lambda: (directory / "resume-ready.json").exists(), 40, "retained native binding") + run_turn(1) + wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") + second_result = accepted_result(1) + proof["accepted_results"] = [first_result, second_result] + assert input_responses == [{"status": 204, "key": key} for key in input_keys] + assert first_result["key"] != second_result["key"] and not ({first_result["key"], second_result["key"]} & set(input_keys)) + assert first_result["event"]["output"] == json.dumps({"festival": memory}, separators=(",", ":")) + assert "error" not in first_result["event"] and second_result["event"]["error"] == "Tool handler failed." + assert "output" not in second_result["event"] + final = snapshot("final", "idle") + assert final["required_actions"] == [] and proof["old_result_did_not_retarget"] + # Native release precedes Turn completion; executor reconnection is asynchronous. + connected = wait_for(lambda: value if (value := api.beta.agents.environments.retrieve(environment_id, timeout=5)).status == "connected" else None, + 30, "executor reconnection after completion") + proof["final_environment"] = verify_environment(connected.to_dict(), environment_id, "connected") + turns = list(sessions.turns.list(session_id, order="asc")) + items = list(sessions.items.list(session_id, order="asc", limit=100)) + assert len(turns) == 2 and all(turn.status == "completed" for turn in turns) + for index, phase in enumerate(("first", "resumed")): + turn_id = turns[index].id + events = helper_events[index] + added = [event for event in events if event["type"] == "agent.session.turn.item.added"] + calls = [event for event in added if event["item"]["type"] == "function_call"] + results = [event for event in added if event["item"]["type"] == "function_call_output"] + completed = [event for event in events if event["type"] == "agent.session.turn.completed"] + assert len(calls) == len(results) == len(completed) == 1 and completed[0]["turn"]["id"] == turn_id + assert calls[0]["item"]["call_id"] == results[0]["item"]["call_id"] == proof["accepted_results"][index]["event"]["call_id"] + assert events.index(calls[0]) < events.index(results[0]) < events.index(completed[0]) < len(events) - 1 + assert events[-1]["type"] == "agent.session.idle" and results[0].get("output_index") is None + assert not any(event["type"] == "agent.session.turn.item.done" and event["item"]["type"] == "function_call_output" for event in events) + output = {field: results[0]["item"][field] for field in ("output", "error") if field in results[0]["item"]} + expected = {field: proof["accepted_results"][index]["event"][field] for field in ("output", "error") if field in proof["accepted_results"][index]["event"]} + assert output == expected + commands = [item for item in items if item.turn_id == turn_id and item.type == "command_execution" + and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] + assert len(commands) == 1 and commands[0].exit_code == 7 and commands[0].cwd == settings["workspace_directory"] + argv = shlex.split(commands[0].command) + assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./placement.sh " + phase] + answer = "\n".join(part.get("text", "") for item in items if item.turn_id == turn_id and item.type == "message" + and item.role == "assistant" for part in item.to_dict()["content"]) + assert memory in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer + if index == 1: + assert "remote-file-content" in answer and "Tool handler failed." in answer + proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] + proof["calls"] = [entry["event"]["call_id"] for entry in proof["accepted_results"]] + assert len(set(proof["calls"])) == 2 and len(handler_calls) == 2 + assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] + assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] + for submission in proof["accepted_results"]: + replay_result(submission) + changed = {**first_result["event"], "output": "changed"} + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [changed]}, headers={"Idempotency-Key": first_result["key"]}).status_code == 409 + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [first_result["event"]]}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 + time.sleep(1) + assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, order="asc", limit=100)) == items + for values in observations.values(): + ids = [value["event_id"] for value in values] + assert len(ids) == len(set(ids)) + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] + assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == [turn.id for turn in turns] + raw_ids = [value["event_id"] for value in observations["raw"]] + start = next(index for index, value in enumerate(observations["sdk"]) if value["event_id"] in raw_ids) + common = observations["sdk"][start:] + assert common == observations["raw"][raw_ids.index(common[0]["event_id"]):], "SDK/raw common live suffix differs" + with client() as recovered: + resource = recovered.beta.agents.sessions + assert resource.retrieve(session_id).to_dict() == final + assert list(resource.turns.list(session_id, order="asc")) == turns + assert list(resource.items.list(session_id, order="asc", limit=100)) == items + proof["common_live_events"] = len(common) + proof["query_recovery"] = proof["retries_preserved_turns_and_items"] = True + proof["status"] = "public_functions_and_cold_continuation_verified" + print("Built service: real self-hosted functions, SDK success/error mapping, cold history, remote commands and original-result retry passed.", flush=True) + finally: + with lock: + proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) + proof["result_requests"] = list(submissions) + proof["input_responses"] = list(input_responses) + proof["helper_events"], proof["handler_calls"] = helper_events, handler_calls + write_private("public-functions-proof", proof) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_initial.py b/services/agents-api/tests/official_self_hosted_initial.py new file mode 100644 index 000000000..a49bb61dc --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_initial.py @@ -0,0 +1,194 @@ +"""Public initial creation with an offline Worker and controlled deadline advancement.""" + +from concurrent.futures import ThreadPoolExecutor +import importlib.metadata +import json +from pathlib import Path +import sys +import threading +import time +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + base, token = settings["base"], settings["token"] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + + def client(key=token): + return OpenAI(api_key=key, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) + + def check(value, status, environment_id=None): + assert value["object"] == "agent.session" and value["status"] == status and value["usage"] is None + environment = value["environment"] + assert environment["id"] == (environment_id or environment["id"]) + assert environment["type"] == "self_hosted" and environment["capability_directories"] == [] + assert environment["remote_url"] == settings["remote_url"] + action = {"type": "environment_connection", "environment_id": environment["id"]} + assert value["required_actions"] == ([action] if status == "requires_action" else []) + assert value["error"] == ("The initial input timed out waiting for the environment connection." if status == "failed" else None) + + with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: + sessions = api.beta.agents.sessions + endpoint = base + "/v1/agents/sessions" + + def post(request, key, url=endpoint, key_token=token): + return raw.post(url, json=request, headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) + + def current(case, status="requires_action"): + value = sessions.retrieve(case["id"]).to_dict() + check(value, status, case["environment_id"]) + response = raw.get(endpoint + "/" + case["id"]) + assert response.status_code == 200 and response.json() == value + assert list(sessions.turns.list(case["id"])) == [] and list(sessions.items.list(case["id"])) == [] + return value + + def retry(case, status="requires_action"): + value = current(case, status) + assert sessions.create(**case["request"], extra_headers={"Idempotency-Key": case["key"]}).to_dict() == value + response = post(case["request"], case["key"]) + assert response.status_code == 200 and response.json() == value + return value + + phase = settings.get("phase", "create") + if phase == "create": + environment = {"type": "self_hosted", "workspace_directory": "/private-initial-workspace"} + inline = {"agent": {"model": "test-model", "instructions": "Retain the creation snapshot."}, "environment": environment} + ordered = [{"role": "user", "content": [{"type": "input_text", "text": "first private input"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "second private input"}]}] + saved = api.beta.agents.create(model="test-model", instructions="Saved initial instructions.") + cases = [] + for mode in ("concurrent", "saved", "sdk_stream", "raw_disconnect"): + request = {**inline, "input": ordered if mode in ("saved", "sdk_stream") else "private initial string"} + if mode == "saved": + request = {"agent_id": saved.id, "environment": environment, "input": ordered} + key, began = str(uuid.uuid4()), time.monotonic() + if mode == "concurrent": + def create_once(index): + if index == 0: + with client() as creator: + return creator.beta.agents.sessions.create(**request, extra_headers={"Idempotency-Key": key}).to_dict() + reply = post(request, key) + assert reply.status_code == 200 + return reply.json() + + with ThreadPoolExecutor(max_workers=4) as workers: + replies = list(workers.map(create_once, range(4))) + value = replies[0] + assert all(reply == value for reply in replies) + elif mode == "sdk_stream": + with sessions.create(**request, stream=True, extra_headers={"Idempotency-Key": key}) as stream: + events = iter(stream) + created, waiting = next(events).to_dict(), next(events).to_dict() + assert created["type"] == "agent.session.created" and waiting["type"] == "agent.session.requires_action" + assert set(created) == set(waiting) == {"type", "event_id", "session"} + assert created["event_id"] != waiting["event_id"] + check(created["session"], "idle") + value = waiting["session"] + assert created["session"]["id"] == value["id"] and created["session"]["environment"] == value["environment"] + assert created["session"]["created_at"] == created["session"]["last_active_at"] + elif mode == "raw_disconnect": + with raw.stream("POST", endpoint, json={**request, "stream": True}, headers={"Idempotency-Key": key}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + created = next(json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) + assert created["type"] == "agent.session.created" + check(created["session"], "idle") + value = sessions.retrieve(created["session"]["id"]).to_dict() + else: + value = sessions.create(**request, extra_headers={"Idempotency-Key": key}).to_dict() + assert time.monotonic() - began < 8, "creation waited for offline execution" + check(value, "requires_action") + case = {"id": value["id"], "environment_id": value["environment"]["id"], "key": key, "request": request, + "snapshot": value, "texts": [message["content"][0]["text"] for message in ordered] if isinstance(request["input"], list) else [request["input"]]} + cases.append(case) + retry(case) + assert post({**request, "input": "changed"}, key).status_code == 409 + assert post(request, key, key_token=settings["peer_token"]).status_code == 409 + assert raw.get(endpoint + "/" + value["id"], headers={"Authorization": "Bearer " + settings["peer_token"]}).status_code == 200 + api.beta.agents.update(saved.id, instructions="Changed after acceptance.") + assert retry(cases[1]) == cases[1]["snapshot"] + api.beta.agents.delete(saved.id) + assert retry(cases[1]) == cases[1]["snapshot"] + for case in cases: + for suffix in ("", "/events", "/turns", "/items"): + assert raw.get(endpoint + "/" + case["id"] + suffix, headers={"Authorization": "Bearer " + settings["foreign_token"]}).status_code == 404 + foreign = post(cases[0]["request"], cases[0]["key"], key_token=settings["foreign_token"]) + assert foreign.status_code == 200 and foreign.json()["id"] != cases[0]["id"] + assert raw.get(endpoint + "/" + foreign.json()["id"]).status_code == 404 + result = {"cases": cases} + else: + result = settings["accepted"] + cases = result["cases"] + if phase == "reopen": + for case in cases: + assert retry(case) == case["snapshot"] + elif phase == "expire": + case = cases[0] + observations, failures = {}, [] + ready = {name: threading.Event() for name in ("sdk", "raw", "creation_retry")} + + def observe(name): + try: + if name == "raw": + with httpx2.Client(trust_env=False, timeout=15) as http: + with http.stream("GET", endpoint + "/" + case["id"] + "/events", headers=headers) as response: + assert response.status_code == 200 + ready[name].set() + event = next(json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) + else: + with client() as observer: + events = observer.beta.agents.sessions + stream = (events.create(**case["request"], stream=True, extra_headers={"Idempotency-Key": case["key"]}) + if name == "creation_retry" else events.events.stream(case["id"])) + with stream: + ready[name].set() + event = next(iter(stream)).to_dict() + assert event["type"] == "agent.session.failed" and set(event) == {"type", "event_id", "session"} + check(event["session"], "failed", case["environment_id"]) + observations[name] = event + except BaseException as error: + failures.append(error) + + workers = [threading.Thread(target=observe, args=(name,), daemon=True) for name in ready] + for worker in workers: + worker.start() + assert all(event.wait(10) for event in ready.values()), "live subscriptions did not open" + # The Go control advances only this committed deadline; the real Worker performs expiry. + assert raw.post(settings["expiry_control"]).status_code == 204 + for worker in workers: + worker.join(timeout=15) + assert not worker.is_alive(), "public initial failure observer timed out" + if failures: + raise AssertionError("public initial failure observer failed") from failures[0] + assert observations["sdk"] == observations["raw"] == observations["creation_retry"] + assert retry(case, "failed") == observations["sdk"]["session"] + assert api.beta.agents.environments.retrieve(case["environment_id"]).status == "pending" + for retained in cases[1:]: + assert retry(retained) == retained["snapshot"] + result["controlled_deadline_expiry"] = True + elif phase == "unavailable": + before = {session.id for session in sessions.list()} + for target in (endpoint, settings["disabled_base"] + "/v1/agents/sessions"): + for streaming in (False, True): + response = post({**cases[0]["request"], "stream": streaming}, str(uuid.uuid4()), url=target) + assert response.status_code == 503 and response.json()["error"]["code"] == "execution_unavailable" + response = post(cases[1]["request"], cases[1]["key"], url=target) + assert response.status_code == 200 and response.json() == cases[1]["snapshot"] + assert {session.id for session in sessions.list()} == before + else: + raise AssertionError("unknown test phase") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_steering.py b/services/agents-api/tests/official_self_hosted_steering.py new file mode 100644 index 000000000..12361f4f5 --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_steering.py @@ -0,0 +1,144 @@ +"""Public text admission with controlled active Turns and offline preparation.""" + +from concurrent.futures import ThreadPoolExecutor +import importlib.metadata +import json +from pathlib import Path +import sys +import threading +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import APIStatusError, OpenAI + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + base, token = settings["base"], settings["token"] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + + def client(): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: + sessions = api.beta.agents.sessions + endpoint = base + "/v1/agents/sessions/" + + def submit(session_id, events, key, expected=204, sdk=False, key_token=token): + if sdk: + with client() as caller: + try: + response = caller.beta.agents.sessions.events.with_raw_response.create( + session_id, events=events, idempotency_key=key) + assert response.status_code == expected == 204 and response.content == b"" + assert response.parse() is None + except APIStatusError as error: + assert error.status_code == expected and expected != 204 + else: + response = raw.post(endpoint + session_id + "/events", json={"events": events}, + headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) + assert response.status_code == expected, (response.status_code, expected) + if expected == 204: + assert response.content == b"" + + def current(session_id): + value = sessions.retrieve(session_id).to_dict() + response = raw.get(endpoint + session_id) + assert response.status_code == 200 and response.json() == value + assert value["environment"]["type"] == "self_hosted" + return value + + def waiting(session_id, events, key): + try: + raw.post(endpoint + session_id + "/events", json={"events": events}, + headers={"Idempotency-Key": key}, timeout=0.8) + raise AssertionError("offline input returned before preparation") + except httpx2.ReadTimeout: + pass + + phase = settings["phase"] + if phase == "create": + request = {"agent": {"model": "test-model", "instructions": "Controlled active text."}, + "environment": {"type": "self_hosted", "workspace_directory": "/private-steering-workspace"}} + main_session = sessions.create(**request) + initial = sessions.create(**request, input="Keep initial pending input.") + later, deleted = sessions.create(**request), sessions.create(**request) + sessions.delete(deleted.id) + result = {"id": main_session.id, "environment_id": main_session.environment.id, + "initial_id": initial.id, "later_id": later.id, "deleted_id": deleted.id, + "batch": [message("first active text"), message("second active text")], + "pending_event": message("Keep later pending input.")} + result.update({key: str(uuid.uuid4()) for key in ("batch_key", "pending_key", "idle_key", "rollback_key")}) + else: + result = settings["accepted"] + session_id, batch, key = result["id"], result["batch"], result["batch_key"] + before = current(session_id) + assert before["environment"]["id"] == result["environment_id"] + if phase == "active": + barrier = threading.Barrier(4) + + def submit_once(index): + barrier.wait(timeout=10) + submit(session_id, batch, key, sdk=bool(index % 2)) + + with ThreadPoolExecutor(max_workers=4) as workers: + list(workers.map(submit_once, range(4))) + assert before["status"] == current(session_id)["status"] == "in_progress" + turns = list(sessions.turns.list(session_id)) + assert len(turns) == 1 and turns[0].id == settings["turn_id"] + elif phase == "reject": + cancel = {"type": "agent.session.input.cancel"} + for events, code in ((list(reversed(batch)), 409), ([message("changed")], 409), + ([batch[0], {"type": "agent.session.input.message", "input": []}], 400), + ([batch[0], cancel], 400)): + for sdk in (True, False): + submit(session_id, events, key, code, sdk) + submit(session_id, batch, "foreign", 404, key_token=settings["foreign_token"]) + for sdk in (True, False): + submit(result["deleted_id"], batch, key, 404, sdk) + for pending_id in (result["initial_id"], result["later_id"]): + submit(pending_id, batch, key, 409, sdk) + pending = current(result["later_id"]) + waiting(result["later_id"], [result["pending_event"]], result["pending_key"]) + assert current(result["later_id"]) == pending + assert current(session_id) == before + elif phase == "rollback": + for sdk in (True, False): + submit(session_id, batch, result["rollback_key"], 500, sdk) + assert current(session_id) == before + elif phase == "idle": + assert before["status"] == "idle" + waiting(session_id, [message("New idle input.")], result["idle_key"]) + assert current(session_id)["status"] == "requires_action" + for sdk in (True, False): + submit(session_id, batch, key, sdk=sdk) + assert len(list(sessions.turns.list(session_id))) == 2 + else: + assert before["status"] == ("idle" if phase == "terminal" else "in_progress") + for sdk in (True, False): + submit(session_id, batch, key, sdk=sdk) + assert current(session_id) == before + listed = [item.to_dict() for item in sessions.items.list(session_id, order="asc")] + response = raw.get(endpoint + session_id + "/items", params={"order": "asc"}) + assert response.status_code == 200 and response.json()["data"] == listed + texts = ["".join(part["text"] for part in item["content"]) for item in listed if item["type"] == "message"] + expected = ["Controlled original work.", "first active text", "second active text"] + if phase in ("later", "idle"): + expected.append("Controlled original work.") + assert texts == expected + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_self_hosted_steering_native.py b/services/agents-api/tests/official_self_hosted_steering_native.py new file mode 100644 index 000000000..53afa9046 --- /dev/null +++ b/services/agents-api/tests/official_self_hosted_steering_native.py @@ -0,0 +1,275 @@ +"""Real public self-hosted active text, native application and cold continuation.""" + +import importlib.metadata +import json +import os +from pathlib import Path +import shlex +import sys +import threading +import time +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_environment_retrieve import verify_environment + + +def main(): + settings = json.load(sys.stdin) + base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) + directory = Path(settings["evidence"]) + os.umask(0o077) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] + proof = {"case": "public_steering", "scope": "built service, real remote MiniMax, public active input and cold continuation", + "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, + "limits": ["204 acknowledges durable admission; Go separately verifies actual native Accepted and cursor", + "Written only releases the bounded fixture gate; no crash recovery or OS-quiescence claim"]} + observations = {"sdk": [], "raw": []} + ready = {name: threading.Event() for name in observations} + done = {name: threading.Event() for name in observations} + failures, lock = [], threading.Lock() + session_id = None + + def write_private(name, value): + data = json.dumps(value, indent=2) + assert token not in data and foreign not in data + temporary = directory / (name + ".tmp") + temporary.write_text(data) + temporary.chmod(0o600) + temporary.replace(directory / (name + ".json")) + + def client(): + return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, + http_client=httpx2.Client(trust_env=False, timeout=360)) + + def wait_for(predicate, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + if failures: + raise AssertionError("public steering observer or input failed") from failures[0] + value = predicate() + if value: + return value + time.sleep(0.025) + raise AssertionError(label + " timed out") + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + def observe(name): + completed = set() + + def accept(value): + with lock: + observations[name].append(value) + kind = value["type"] + assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") + if kind == "agent.session.turn.completed": + completed.add(value["turn"]["id"]) + return kind == "agent.session.idle" and len(completed) == 2 + + try: + if name == "sdk": + with client() as api: + with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: + ready[name].set() + for event in stream: + if accept(event.to_dict()): + return + else: + with httpx2.Client(trust_env=False, timeout=450) as raw: + with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + ready[name].set() + for line in response.iter_lines(): + if line.startswith("data: ") and accept(json.loads(line[6:])): + return + raise AssertionError("live stream ended before both real Turns") + except BaseException as error: + with lock: + failures.append(error) + finally: + done[name].set() + + try: + with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, + headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: + sessions = api.beta.agents.sessions + environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} + instructions = "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0. Keep waiting or polling a running command until it finishes. Integrate additional user text without restarting or cancelling that command." + creation = {"agent": {"model": "MiniMax-M3", "instructions": instructions, "tools": []}, "environment": environment} + creation_key = str(uuid.uuid4()) + created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) + session_id, environment_id = created.id, created.environment.id + expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} + assert created.environment.to_dict() == expected_environment + assert created.status == "idle" and created.required_actions == [] and created.usage is None + assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + + def snapshot(name, status): + value = sessions.retrieve(session_id).to_dict() + assert value["environment"] == expected_environment and value["status"] == status and value["error"] is None + response = raw.get("/agents/sessions/" + session_id) + assert response.status_code == 200 and response.json() == value + proof["snapshots"][name] = value + return value + + def post_raw(event, key): + response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [event]}, + headers={"Idempotency-Key": key}) + assert response.status_code == 204 and response.content == b"" + + def running_command(turn_id, phase): + values = [item.to_dict() for item in sessions.items.list(session_id, order="asc", limit=100) + if item.turn_id == turn_id and item.type == "command_execution" and item.status == "in_progress" + and shlex.split(item.command)[1:] == ["-lc", "./gate.sh " + phase]] + if values: + assert len(values) == 1 and values[0]["cwd"] == settings["workspace_directory"] + return values[0] + return None + + for name in observations: + threading.Thread(target=observe, args=(name,), daemon=True).start() + wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") + exact = "The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry." + first_text = "Run the exact native shell command `./gate.sh first` once. It waits for the fixture before producing stdout/stderr and exit 7. Keep waiting or polling until it finishes; do not finish the Turn, release the gate yourself, cancel or restart it. Additional user text may arrive while it runs; include it and the command output in your final answer. " + exact + first_event, first_key = message(first_text), str(uuid.uuid4()) + submitted = threading.Event() + + def submit_first(): + try: + with client() as submitting: + response = submitting.beta.agents.sessions.events.with_raw_response.create(session_id, events=[first_event], idempotency_key=first_key) + assert response.status_code == 204 and response.parse() is None + assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable admission" + except BaseException as error: + with lock: + failures.append(error) + finally: + submitted.set() + + threading.Thread(target=submit_first, daemon=True).start() + wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "offline message reservation") + pending = snapshot("offline", "requires_action") + assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] + assert not submitted.is_set() and list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] + write_private("waiting", {"session_id": session_id, "environment_id": environment_id, + "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) + wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") + verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") + write_private("initial-connection-read", {"environment_id": environment_id}) + wait_for(submitted.is_set, 150, "initial input admission") + first_id = list(sessions.turns.list(session_id))[0].id + wait_for(lambda: (directory / "steer-ready.json").exists(), 150, "independent active remote command") + proof["first_active_command"] = wait_for(lambda: running_command(first_id, "first"), 15, "public active command") + assert sessions.turns.retrieve(first_id, session_id=session_id).status == "in_progress" + value = "festival-" + uuid.uuid4().hex + active_text = "The fictional festival name is " + value + ". Remember it without writing it to a file. Keep waiting for the current command without cancelling, restarting or changing it. Include this exact festival name and the command stdout/stderr in your final answer after the command finishes." + assert value not in json.dumps(creation) and value not in first_text + active_event, active_key = message(active_text), str(uuid.uuid4()) + response = sessions.events.with_raw_response.create(session_id, events=[active_event], idempotency_key=active_key) + assert response.status_code == 204 and response.parse() is None + post_raw(active_event, active_key) + assert [turn.id for turn in sessions.turns.list(session_id)] == [first_id] + assert sessions.turns.retrieve(first_id, session_id=session_id).status == "in_progress" + + def steered_items(): + return [item.to_dict() for item in sessions.items.list(session_id, order="asc", limit=100) + if item.type == "message" and item.role == "user" and value in json.dumps(item.to_dict()["content"])] + + active_items = steered_items() + assert len(active_items) == 1 and active_items[0]["turn_id"] == first_id + proof["active_submission"] = {"event": active_event, "key": active_key, "status": 204, "item": active_items[0]} + write_private("steer-submitted", {"turn_id": first_id, "value": value}) + first_turn = wait_for(lambda: turn if (turn := sessions.turns.retrieve(first_id, session_id=session_id)).status == "completed" else None, + 150, "steered first Turn completion") + first_items = list(sessions.items.list(session_id, order="asc", limit=100)) + post_raw(active_event, active_key) + assert list(sessions.items.list(session_id, order="asc", limit=100)) == first_items + snapshot("first_completed", "idle") + write_private("first-completed", {"turn_id": first_id}) + wait_for(lambda: (directory / "resume-ready.json").exists(), 40, "retained native binding") + second_text = "Recall the fictional festival name supplied by the additional user message during the first Turn. Read retained.txt in a separate native tool call. Then run the exact native shell command `./gate.sh resumed` once in a new tool call. Do not combine the file read with this command. The command waits for the fixture; keep waiting or polling until it finishes and do not release its gate yourself. Include the remembered festival, retained file contents and command stdout/stderr in the final answer. " + exact + assert value not in second_text + second_event, second_key = message(second_text), str(uuid.uuid4()) + post_raw(second_event, second_key) + wait_for(lambda: (directory / "resumed-active.json").exists(), 150, "independent cold command activity") + turns = list(sessions.turns.list(session_id, order="asc")) + assert len(turns) == 2 and turns[0].status == "completed" and turns[1].status == "in_progress" + second_id = turns[1].id + proof["second_active_command"] = wait_for(lambda: running_command(second_id, "resumed"), 15, "public cold command") + before = list(sessions.items.list(session_id, order="asc", limit=100)) + assert sessions.events.create(session_id, events=[active_event], idempotency_key=active_key) is None + post_raw(active_event, active_key) + assert list(sessions.items.list(session_id, order="asc", limit=100)) == before + assert steered_items() == active_items and sessions.turns.retrieve(second_id, session_id=session_id).status == "in_progress" + write_private("old-steer-retried", {"turn_id": second_id}) + second_turn = wait_for(lambda: turn if (turn := sessions.turns.retrieve(second_id, session_id=session_id)).status == "completed" else None, + 150, "cold Turn completion") + wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") + final = snapshot("final", "idle") + assert final["required_actions"] == [] + connected = wait_for(lambda: resource if (resource := api.beta.agents.environments.retrieve(environment_id, timeout=5)).status == "connected" else None, + 30, "executor reconnection after completion") + proof["final_environment"] = verify_environment(connected.to_dict(), environment_id, "connected") + turns, items = [first_turn, second_turn], list(sessions.items.list(session_id, order="asc", limit=100)) + for turn, phase in zip(turns, ("first", "resumed")): + commands = [item for item in items if item.turn_id == turn.id and item.type == "command_execution" + and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] + assert len(commands) == 1 and commands[0].exit_code == 7 and commands[0].cwd == settings["workspace_directory"] + argv = shlex.split(commands[0].command) + assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./gate.sh " + phase] + answer = "\n".join(part.get("text", "") for item in items if item.turn_id == turn.id and item.type == "message" + and item.role == "assistant" for part in item.to_dict()["content"]) + assert value in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer + if phase == "resumed": + assert "remote-file-content" in answer + assert steered_items() == active_items + proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] + assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] + assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] + for event, key in ((first_event, first_key), (active_event, active_key), (second_event, second_key)): + post_raw(event, key) + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed active message")]}, headers={"Idempotency-Key": active_key}).status_code == 409 + assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [active_event]}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 + time.sleep(1) + assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, order="asc", limit=100)) == items + for events in observations.values(): + ids = [event["event_id"] for event in events] + assert len(ids) == len(set(ids)) + assert [event["turn"]["id"] for event in events if event["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] + assert [event["turn"]["id"] for event in events if event["type"] == "agent.session.turn.completed"] == [turn.id for turn in turns] + additions = [event for event in events if event["type"] == "agent.session.turn.item.added" and event["item"]["id"] == active_items[0]["id"]] + assert len(additions) == 1 and additions[0]["turn_id"] == first_id + raw_ids = [event["event_id"] for event in observations["raw"]] + start = next(index for index, event in enumerate(observations["sdk"]) if event["event_id"] in raw_ids) + common = observations["sdk"][start:] + assert common == observations["raw"][raw_ids.index(common[0]["event_id"]):], "SDK/raw common live suffix differs" + with client() as recovered: + resource = recovered.beta.agents.sessions + assert resource.retrieve(session_id).to_dict() == final + assert list(resource.turns.list(session_id, order="asc")) == turns + assert list(resource.items.list(session_id, order="asc", limit=100)) == items + proof["common_live_events"] = len(common) + proof["query_recovery"] = proof["old_input_did_not_retarget"] = True + proof["status"] = "public_active_input_and_cold_continuation_verified" + print("Built service: real self-hosted active input, exact retries, same Turn application, remote commands and cold history passed.", flush=True) + finally: + with lock: + proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) + write_private("public-steering-proof", proof) + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_session_agent_filter.py b/services/agents-api/tests/official_session_agent_filter.py new file mode 100644 index 000000000..3678e9fdb --- /dev/null +++ b/services/agents-api/tests/official_session_agent_filter.py @@ -0,0 +1,73 @@ +"""Pinned SDK and raw HTTP Session filtering against real PostgreSQL.""" + +import importlib.metadata +import json +from pathlib import Path +import sys + +import httpx2 +from openai import OpenAI + + +def main(): + base, token, foreign, restarted = sys.argv[1:] + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + assert distribution.version == pin["sdk_version"] + assert json.loads(distribution.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] + with httpx2.Client(trust_env=False, timeout=10) as http: + client = OpenAI(api_key=token, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + other = OpenAI(api_key=foreign, base_url=base + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + agents, sessions = client.beta.agents, client.beta.agents.sessions + root = agents.create(model="first-model", instructions="Original") + peer = agents.create(model="peer-model") + selected, all_ids = [], [] + for index in range(7): + agent = root if index % 2 == 0 else peer + session = sessions.create(agent_id=agent.id, environment={"type": "none"}) + all_ids.append(session.id) + if agent.id == root.id: + selected.append(session) + inline = sessions.create(agent={"model": "inline-model"}, environment={"type": "none"}) + all_ids.append(inline.id) + foreign_agent = other.beta.agents.create(model="foreign-model") + foreign_session = other.beta.agents.sessions.create(agent_id=foreign_agent.id, environment={"type": "none"}) + assert [s.id for s in sessions.list(agent_id=root.id, limit=2, order="asc")] == [s.id for s in selected] + assert [s.id for s in sessions.list(agent_id=root.id, limit=2)] == [s.id for s in reversed(selected)] + assert [s.id for s in sessions.list(agent_id=inline.agent.id)] == [inline.id] + assert [s.id for s in sessions.list(order="asc", limit=2)] == all_ids + assert list(sessions.list(agent_id=foreign_agent.id)) == [] + assert list(other.beta.agents.sessions.list(agent_id=root.id)) == [] + assert [s.id for s in other.beta.agents.sessions.list(agent_id=foreign_agent.id)] == [foreign_session.id] + agents.update(root.id, model="changed-model", instructions="Changed") + assert list(sessions.list(agent_id=root.id, order="asc")) == selected + agents.delete(root.id) + assert list(sessions.list(agent_id=root.id, order="asc")) == selected + recovered = OpenAI(api_key=token, base_url=restarted + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + assert list(recovered.beta.agents.sessions.list(agent_id=root.id, order="asc", limit=2)) == selected + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + endpoint = base + "/v1/agents/sessions" + first = http.get(endpoint, headers=headers, params={"agent_id": root.id, "order": "asc", "limit": 2}) + assert first.status_code == 200 and first.json()["has_more"] is True + assert [s["id"] for s in first.json()["data"]] == [s.id for s in selected[:2]] + tail = http.get(endpoint, headers=headers, params={"agent_id": root.id, "order": "asc", "limit": 2, "after": selected[1].id}) + assert tail.status_code == 200 and tail.json()["has_more"] is False + assert [s["id"] for s in tail.json()["data"]] == [s.id for s in selected[2:]] + for value in ("", "unknown", root.id + " ", "' OR true --"): + reply = http.get(endpoint, headers=headers, params={"agent_id": value}) + assert reply.status_code == 200 and reply.json() == {"data": [], "has_more": False} + for query in ([("agent_id", root.id), ("agent_id", peer.id)], {"agent_id": root.id, "tenant_id": "other"}): + assert http.get(endpoint, headers=headers, params=query).status_code == 400 + assert http.get(endpoint, headers=headers, params={"agent_id": root.id, "after": foreign_session.id}).status_code == 404 + assert http.get(endpoint, headers={"Authorization": "Bearer " + token}, params={"agent_id": root.id}).status_code == 400 + assert http.get(endpoint, headers={"OpenAI-Beta": "agents=v1"}, params={"agent_id": root.id}).status_code == 401 + for path in ("/v1/agents", "/v1/agents/sessions/" + inline.id + "/items", "/v1/agents/sessions/" + inline.id + "/turns"): + assert http.get(base + path, headers=headers, params={"agent_id": root.id}).status_code == 400 + print("Session Agent filter: SDK/raw HTTP, saved/inline IDs, both pagination orders, tenant isolation, source update/deletion, reconnect and unfiltered behavior passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_session_artifacts.py b/services/agents-api/tests/official_session_artifacts.py new file mode 100644 index 000000000..b86f25af0 --- /dev/null +++ b/services/agents-api/tests/official_session_artifacts.py @@ -0,0 +1,75 @@ +"""Pinned SDK and raw HTTP checks for known immutable Session output versions.""" + +from openai import NotFoundError + + +def verify_session_artifacts(client, foreign, http, session_id, environment_id, expected): + resource = client.beta.agents.sessions.artifacts + endpoint = str(client.base_url).rstrip("/") + "/agents/sessions/" + session_id + "/artifacts" + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + wanted = {(turn, path): data for turn, files in expected.items() for path, data in files.items()} + all_items = list(resource.list(session_id, limit=100, order="asc")) + assert len(all_items) == len(wanted), "Missing or duplicate published output" + assert {(item.turn_id, item.path) for item in all_items} == set(wanted), "Wrong output versions" + fields = {"id", "created_at", "environment_id", "object", "path", "session_id", "size_bytes", "turn_id"} + for item in all_items: + metadata = item.to_dict() + assert set(metadata) == fields, "Wrong public metadata or private storage fields leaked" + assert item.object == "agent.session.artifact" and item.session_id == session_id + assert item.environment_id == environment_id and type(item.created_at) is int and item.created_at > 0 + data = wanted[(item.turn_id, item.path)] + assert item.size_bytes == len(data), "Wrong immutable size" + assert resource.retrieve(item.id, session_id=session_id).to_dict() == metadata + with resource.with_streaming_response.content(item.id, session_id=session_id) as response: + assert response.read() == data, "SDK content differs from completed output" + response = http.get(endpoint + "/" + item.id, headers=headers) + assert response.status_code == 200 and response.json() == metadata + response = http.get(endpoint + "/" + item.id + "/content", headers=headers) + assert response.status_code == 200 and response.content == data + assert response.headers["content-type"].startswith("application/octet-stream") + + ascending_ids = [item.id for item in all_items] + for order in ("asc", "desc"): + wanted_ids = ascending_ids if order == "asc" else list(reversed(ascending_ids)) + sdk = resource.list(session_id, environment_id=environment_id, limit=1, order=order) + assert [item.id for item in sdk] == wanted_ids, "SDK cursor continuation changed order" + params = {"environment_id": environment_id, "limit": 2, "order": order} + seen = [] + for _ in range(len(wanted) + 1): + response = http.get(endpoint, headers=headers, params=params) + assert response.status_code == 200 + page = response.json() + assert isinstance(page["data"], list) and type(page["has_more"]) is bool + assert len(page["data"]) <= 2 + seen.extend(item["id"] for item in page["data"]) + if not page["has_more"]: + break + assert page["data"], "Empty page claims continuation" + params["after"] = page["data"][-1]["id"] + else: + raise AssertionError("Artifact cursor did not terminate") + assert seen == wanted_ids, "Raw HTTP pagination changed order or completeness" + assert [item.id for item in resource.list(session_id)] == list(reversed(ascending_ids)) + assert [item.id for item in resource.list(session_id, after=None, environment_id=None, limit=None)] == list(reversed(ascending_ids)) + + assert http.get(endpoint, headers={"Authorization": headers["Authorization"]}).status_code == 400 + assert http.get(endpoint, headers={"OpenAI-Beta": "agents=v1"}).status_code == 401 + for query in ("limit=0", "limit=101", "order=wrong", "environment_id=a&environment_id=b"): + assert http.get(endpoint + "?" + query, headers=headers).status_code == 400 + assert http.post(endpoint, headers=headers, json={}).status_code == 405 + other = foreign.beta.agents.sessions.artifacts + probes = [lambda: other.list(session_id)] + for item in all_items: + probes.extend(( + lambda item=item: other.retrieve(item.id, session_id=session_id), + lambda item=item: other.content(item.id, session_id=session_id), + lambda item=item: other.delete(item.id, session_id=session_id), + )) + for probe in probes: + try: + probe() + except NotFoundError: + pass + else: + raise AssertionError("Foreign tenant accessed published artifacts") + return all_items diff --git a/services/agents-api/tests/official_session_creation_stream.py b/services/agents-api/tests/official_session_creation_stream.py new file mode 100644 index 000000000..86a65aab3 --- /dev/null +++ b/services/agents-api/tests/official_session_creation_stream.py @@ -0,0 +1,91 @@ +"""Fixed SDK and raw HTTP checks for creation streams on a paused worker.""" +import json +import uuid + + +def event_data(lines): + for line in lines: + if line.startswith("data: "): + return json.loads(line[6:]) + raise AssertionError("stream ended before an event") + + +def verify_creation_streams(client, raw, base, headers, foreign, unsupported): + sessions = client.beta.agents.sessions + spec = {"agent": {"model": "test-model"}, "environment": {"type": "none"}} + saved = client.beta.agents.create(model="test-model", instructions="Saved stream configuration.") + forms = [None, "First", [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}, + {"role": "user", "content": [{"type": "input_text", "text": "Second"}]}]] + for index, initial in enumerate(forms): + config = spec if index != 2 else {"agent_id": saved.id, "environment": {"type": "none"}} + request = {**config, "input": initial} + key = {"Idempotency-Key": str(uuid.uuid4())} + with sessions.create(**request, stream=True, extra_headers=key) as stream: + first = next(stream) + assert first.type == "agent.session.created" + assert set(first.to_dict()) == {"type", "event_id", "session"} + session = first.session + assert session.status == "idle" and session.last_active_at == session.created_at + if index == 2: + assert session.agent.id == saved.id and session.agent.instructions == saved.instructions + if initial is None: + sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}]}]) + count = 2 if index == 2 else 1 + events = [next(stream) for _ in range(2 + count)] + assert [event.type for event in events] == ["agent.session.turn.created", "agent.session.in_progress"] + ["agent.session.turn.item.added"] * count + assert len({event.event_id for event in [first, *events]}) == 3 + count + assert events[0].turn.id == list(sessions.turns.list(session.id))[0].id + assert events[1].session.status == "in_progress" + items = list(sessions.items.list(session.id, order="asc")) + assert [event.item.to_dict() for event in events[2:]] == [item.to_dict() for item in items] + assert [item.content[0].text for item in items] == (["First", "Second"] if count == 2 else ["First"]) + sessions.events.create(session.id, events=[{"type": "agent.session.input.cancel"}]) + assert [next(stream).type, next(stream).type] == ["agent.session.turn.cancelled", "agent.session.idle"] + # An idle creation stream stays available for a later Turn. + sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "Next"}]}]}]) + assert next(stream).type == "agent.session.turn.created" + current = sessions.retrieve(session.id) + assert current.status == "in_progress", "disconnect cancelled admitted work" + assert sessions.create(**request, stream=False, extra_headers=key).id == session.id + assert len(list(sessions.turns.list(session.id))) == 2 + sessions.events.create(session.id, events=[{"type": "agent.session.input.cancel"}]) + # A creation retry observes from the upsert cursor, with no old created/Turn/Item replay. + with raw.stream("POST", base + "/v1/agents/sessions", headers={**headers, **key, "Last-Event-ID": first.event_id}, + json={**request, "stream": True}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + lines = response.iter_lines() + assert next(lines) == ": connected" + previous_turns = {turn.id for turn in sessions.turns.list(session.id)} + sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "After retry"}]}]}]) + event = event_data(lines) + assert event["type"] == "agent.session.turn.created" and event["event_id"] != events[0].event_id + assert event["turn"]["id"] not in previous_turns + assert len(list(sessions.turns.list(session.id))) == 3 + sessions.events.create(session.id, events=[{"type": "agent.session.input.cancel"}]) + response = raw.get(base + "/v1/agents/sessions/" + session.id, headers={**headers, "Authorization": "Bearer " + foreign}) + assert response.status_code == 404 + + # Raw first-frame shape, early disconnect and same-key JSON recovery. + key = {"Idempotency-Key": str(uuid.uuid4())} + request = {**spec, "input": "Disconnect after creation"} + with raw.stream("POST", base + "/v1/agents/sessions", headers={**headers, **key}, json={**request, "stream": True}) as response: + event = event_data(response.iter_lines()) + assert set(event) == {"type", "event_id", "session"} and event["type"] == "agent.session.created" + recovered = sessions.create(**request, extra_headers=key) + assert recovered.id == event["session"]["id"] and recovered.status == "in_progress" + assert len(list(sessions.turns.list(recovered.id))) == 1 + sessions.events.create(recovered.id, events=[{"type": "agent.session.input.cancel"}]) + + before = {session.id for session in sessions.list()} + for changed_headers, fields, status in [({"Authorization": "Bearer invalid"}, {}, 401), + ({"OpenAI-Beta": ""}, {}, 400), + ({}, {"input": []}, 400), + ({}, {"stream": None}, 400), + ({**key}, {"input": "Changed"}, 409)]: + response = raw.post(base + "/v1/agents/sessions", headers={**headers, **changed_headers}, + json={**request, "stream": True, **fields}) + assert response.status_code == status and response.headers["content-type"].startswith("application/json") + response = raw.post(unsupported + "/v1/agents/sessions", headers=headers, json={**request, "stream": True}) + assert response.status_code == 400 and response.headers["content-type"].startswith("application/json") + assert {session.id for session in sessions.list()} == before + print("Creation streams: fixed SDK/raw HTTP, initial and idle creation, snapshots/order, saved Agents, safe retries, later Turns, disconnect recovery and pre-stream errors passed.") diff --git a/services/agents-api/tests/official_session_creators.py b/services/agents-api/tests/official_session_creators.py new file mode 100644 index 000000000..0e52a9f6d --- /dev/null +++ b/services/agents-api/tests/official_session_creators.py @@ -0,0 +1,128 @@ +"""Local Session creator retry policy with no-input, synthetic configurations. + +Cross-subject conflicts are local policy, not verified hosted API semantics. +These checks exercise storage and HTTP behavior without model execution. +""" + +import uuid + +import httpx2 +from openai import BadRequestError, ConflictError, NotFoundError + +from official_session_creation_stream import event_data +from official_session_metadata import without_metadata + + +def assert_no_creator_fields(payload): + private = {"creator", "creator_kind", "creator_id", "subject_kind", "subject_id"} + assert private.isdisjoint(payload), payload + + +def verify_session_creators(client, owner, other, rotated, peer, same_id, spec, expect_error): + sessions = owner.beta.agents.sessions + endpoint = str(owner.base_url).rstrip("/") + "/agents/sessions" + auth = {"Authorization": "Bearer " + owner.api_key, "OpenAI-Beta": "agents=v1"} + forged = {"X-User-ID": "test-peer", "X-Subject-Kind": "user", "X-Subject-ID": "test-peer", + "X-Creator-Kind": "user", "X-Creator-ID": "test-peer"} + metadata = {"creator_kind": "user", "creator_id": "test-peer"} + recovered = [] + with client(rotated) as replacement, client(peer) as collaborator, client(same_id) as typed_peer, \ + httpx2.Client(trust_env=False, timeout=10) as raw: + + def check_retries(request, key, current): + for caller in (owner, replacement): + assert caller.beta.agents.sessions.create(**request, extra_headers=key) == current + for caller in (collaborator, typed_peer): + expect_error(ConflictError, lambda: caller.beta.agents.sessions.create(**request, extra_headers=key)) + headers = auth | key | forged | {"Authorization": "Bearer " + caller.api_key} + with raw.stream("POST", endpoint, headers=headers, json=request | {"stream": True}) as response: + assert response.status_code == 409 + assert response.headers["content-type"].split(";")[0] == "application/json" + response.read() + assert response.json()["error"]["code"] == "idempotency_conflict" + assert_no_creator_fields(response.json()["error"]) + response = raw.post(endpoint, headers=auth | key, json=request) + assert response.status_code == 200 and response.json() == current.to_dict() + assert_no_creator_fields(response.json()) + + # Inline requests reach the authoritative creation upsert. Untrusted + # metadata and forwarded identity headers cannot choose the creator. + request = spec | {"metadata": metadata} + key = {"Idempotency-Key": str(uuid.uuid4())} + first = sessions.create(**request, extra_headers=key | forged) + assert first.status == "idle" and first.metadata == metadata + check_retries(request, key, first) + foreign = other.beta.agents.sessions.create(**request, extra_headers=key) + assert foreign.id != first.id + expect_error(NotFoundError, lambda: other.beta.agents.sessions.retrieve(first.id)) + expect_error(NotFoundError, lambda: collaborator.beta.agents.sessions.retrieve(foreign.id)) + + # Project peers retain reads and metadata writes without taking ownership. + for caller in (collaborator, typed_peer): + assert caller.beta.agents.sessions.retrieve(first.id) == first + assert first.id in {item.id for item in caller.beta.agents.sessions.list()} + assert list(caller.beta.agents.sessions.turns.list(first.id)) == [] + assert list(caller.beta.agents.sessions.items.list(first.id)) == [] + current = collaborator.beta.agents.sessions.update(first.id, metadata={"creator_id": "test-peer"}) + assert without_metadata(current) == without_metadata(first) + assert_no_creator_fields(current.to_dict()) + check_retries(request, key, current) + recovered.append((request, key, current)) + response = raw.get(endpoint + "/" + current.id, headers=auth | forged) + assert response.status_code == 200 and response.json() == current.to_dict() + assert_no_creator_fields(response.json()) + response = raw.get(endpoint, headers=auth, params={"agent_id": current.agent.id}) + assert response.status_code == 200 + assert [item["id"] for item in response.json()["data"]] == [current.id] + assert_no_creator_fields(response.json()["data"][0]) + for fields in ({"creator_kind": "user", "creator_id": "test-peer"}, + {"creator": {"kind": "user", "id": "test-peer"}}): + expect_error(BadRequestError, lambda: sessions.create(**spec, extra_body=fields)) + expect_error(BadRequestError, lambda: sessions.update(current.id, extra_body=fields)) + check_retries(request, key, current) + + # Saved references recover before source resolution, even after a peer + # changes or deletes the source Agent. + source = owner.beta.agents.create(model="creator-fixture-model", instructions="Frozen source.") + request = {"agent_id": source.id, "environment": {"type": "none"}, "metadata": metadata} + key = {"Idempotency-Key": str(uuid.uuid4())} + saved = sessions.create(**request, extra_headers=key | forged) + check_retries(request, key, saved) + collaborator.beta.agents.update(source.id, model="updated-fixture-model", instructions="Changed source.") + check_retries(request, key, saved) + assert saved.agent.model == "creator-fixture-model" and saved.agent.instructions == "Frozen source." + collaborator.beta.agents.delete(source.id) + expect_error(NotFoundError, lambda: sessions.create(**request)) + saved = typed_peer.beta.agents.sessions.update(saved.id, metadata={"shared": "updated"}) + check_retries(request, key, saved) + recovered.append((request, key, saved)) + + # Streaming creation also records the authenticated typed subject. Two + # users with different IDs cannot share a retry; peers may still delete. + key = {"Idempotency-Key": str(uuid.uuid4())} + headers = auth | key | forged | {"Authorization": "Bearer " + typed_peer.api_key} + with raw.stream("POST", endpoint, headers=headers, json=spec | {"stream": True}) as response: + assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" + created = event_data(response.iter_lines()) + assert created["type"] == "agent.session.created" + assert_no_creator_fields(created["session"]) + streamed = typed_peer.beta.agents.sessions.create(**spec, extra_headers=key) + assert streamed.id == created["session"]["id"] + for caller in (owner, collaborator): + expect_error(ConflictError, lambda: caller.beta.agents.sessions.create(**spec, extra_headers=key)) + deleted = collaborator.beta.agents.sessions.delete(streamed.id) + assert deleted.deleted is True and deleted.id == streamed.id + expect_error(NotFoundError, lambda: typed_peer.beta.agents.sessions.retrieve(streamed.id)) + + print("Session creator local policy: typed subjects, credential rotation, project sharing, immutable retries, source update/deletion, private wire fields and pre-SSE JSON conflicts passed; no model execution or hosted semantics verified.") + return recovered + + +def verify_creator_recovery(client, rotated, peer, same_id, retries, expect_error): + with client(rotated) as creator, client(peer) as collaborator, client(same_id) as typed_peer: + for request, key, current in retries: + assert creator.beta.agents.sessions.create(**request, extra_headers=key) == current + for caller in (collaborator, typed_peer): + assert caller.beta.agents.sessions.retrieve(current.id) == current + expect_error(ConflictError, lambda: caller.beta.agents.sessions.create(**request, extra_headers=key)) + print("Session creator local policy: same-subject credential and cross-subject retry behavior survived service restart.") diff --git a/services/agents-api/tests/official_session_delete.py b/services/agents-api/tests/official_session_delete.py new file mode 100644 index 000000000..650b863d5 --- /dev/null +++ b/services/agents-api/tests/official_session_delete.py @@ -0,0 +1,90 @@ +"""Public Session removal against the pinned SDK and actual PostgreSQL.""" + +import sys +import uuid + +import httpx2 +from openai import APIStatusError, OpenAI + + +def rejected(status, action): + try: + action() + except APIStatusError as exc: + assert exc.status_code == status, exc + return + raise AssertionError(f"expected local status {status}") + + +def main(): + base, token, foreign, restarted = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=10) as http: + def client(url, key): + return OpenAI(api_key=key, base_url=url + "/v1", http_client=http, + max_retries=0, _strict_response_validation=True) + api = client(base, token) + sessions = api.beta.agents.sessions + other = client(base, foreign).beta.agents.sessions + recovered = client(restarted, token).beta.agents.sessions + agent = api.beta.agents.create(model="test-model") + peer = sessions.create(agent_id=agent.id, environment={"type": "none"}) + foreign_session = other.create(agent={"model": "test-model"}, environment={"type": "none"}) + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + for saved in (False, True): + for streaming in (False, True): + spec = {"environment": {"type": "none"}, "input": "Queued controlled input"} + spec.update({"agent_id": agent.id} if saved else {"agent": {"model": "test-model"}}) + key = {"Idempotency-Key": str(uuid.uuid4())} + result = sessions.create(**spec, stream=streaming, extra_headers=key) + if streaming: + with result: + first = next(iter(result)) + assert first.type == "agent.session.created" + session = first.session + else: + session = result + turn = list(sessions.turns.list(session.id))[0] + assert list(sessions.items.list(session.id)) + endpoint = base + "/v1/agents/sessions/" + session.id + rejected(404, lambda: other.delete(session.id)) + assert http.delete(endpoint, headers={"Authorization": "Bearer " + token}).status_code == 400 + assert http.delete(endpoint, headers={"OpenAI-Beta": "agents=v1"}).status_code == 401 + assert http.delete(endpoint + "?cascade=true", headers=headers).status_code == 400 + for body in ("null", "{}"): + assert http.request("DELETE", endpoint, headers=headers, content=body).status_code == 400 + assert sessions.retrieve(session.id).id == session.id + # Existing live streams close on public removal without a fabricated event. + with http.stream("GET", endpoint + "/events", headers=headers) as stream: + assert stream.status_code == 200 + raw = sessions.with_raw_response.delete(session.id.upper()) + expected = {"id": session.id, "object": "agent.session.deleted", "deleted": True} + assert raw.status_code == 200 and raw.http_response.json() == expected + assert raw.parse().to_dict() == expected + assert not any(line.startswith(("event:", "data:")) for line in stream.iter_lines()) + for reader in (sessions, recovered): + rejected(404, lambda: reader.retrieve(session.id)) + rejected(404, lambda: reader.update(session.id, metadata={"no": "resurrection"})) + rejected(404, lambda: list(reader.items.list(session.id))) + rejected(404, lambda: list(reader.turns.list(session.id))) + rejected(404, lambda: reader.turns.retrieve(turn.id, session_id=session.id)) + rejected(404, lambda: reader.delete(session.id)) + for stream in (False, True): + rejected(409, lambda: reader.create(**spec, stream=stream, extra_headers=key)) + assert session.id not in {s.id for s in reader.list()} + assert session.id not in {s.id for s in reader.list(agent_id=session.agent.id)} + assert http.get(endpoint + "/events", headers=headers).status_code == 404 + assert http.post(endpoint + "/events", headers=headers, json={"events": [{"type": "agent.session.input.cancel"}]}).status_code == 404 + fresh = sessions.create(**spec) + assert fresh.id != session.id + sessions.delete(fresh.id) + assert sessions.retrieve(peer.id) == peer + assert api.beta.agents.retrieve(agent.id) == agent + assert other.retrieve(foreign_session.id) == foreign_session + rejected(404, lambda: sessions.delete(foreign_session.id)) + for missing in (str(uuid.uuid4()), "invalid", str(uuid.UUID(int=0))): + rejected(404, lambda: sessions.delete(missing)) + print("Session deletion: fixed SDK/raw HTTP, public history/stream removal, tenant isolation, durable retry rejection and independent resources passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_session_initial_input.py b/services/agents-api/tests/official_session_initial_input.py new file mode 100644 index 000000000..cd247aa67 --- /dev/null +++ b/services/agents-api/tests/official_session_initial_input.py @@ -0,0 +1,66 @@ +"""Pinned client and raw HTTP acceptance for atomic initial text admission.""" +import json +import sys +import uuid + +sys.dont_write_bytecode = True + +import httpx2 +from openai import OpenAI +from official_session_creation_stream import verify_creation_streams + + +def main(): + base, token, foreign, unsupported = sys.argv[1:] + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + spec = {"agent": {"model": "test-model", "instructions": "Keep the snapshot."}, "environment": {"type": "none"}} + with OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) as client, httpx2.Client(trust_env=False) as raw: + sessions = client.beta.agents.sessions + saved = client.beta.agents.create(model="test-model", instructions="Saved instructions.") + idle_key = {"Idempotency-Key": str(uuid.uuid4())} + idle = sessions.create(**spec, extra_headers=idle_key) + assert sessions.create(**spec, input=None, extra_headers=idle_key) == idle + assert list(sessions.turns.list(idle.id)) == [] + forms = ["First", [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Second"}]}]] + for i, initial in enumerate(forms): + configuration = spec if i == 0 else {"agent_id": saved.id, "environment": {"type": "none"}} + key = {"Idempotency-Key": str(uuid.uuid4())} + session = sessions.create(**configuration, input=initial, stream=False, extra_headers=key) + assert session.status == "in_progress" + assert session.agent.instructions == ("Keep the snapshot." if i == 0 else saved.instructions) + turns = list(sessions.turns.list(session.id)) + assert len(turns) == 1 + items = list(sessions.items.list(session.id, order="asc")) + assert [item.content[0].text for item in items] == (["First"] if i == 0 else ["First", "Second"]) + reply = raw.post(base + "/v1/agents/sessions", headers={**headers, **key}, + json={**configuration, "input": initial}) + assert reply.status_code == 200 and reply.json()["id"] == session.id + assert [turn.id for turn in sessions.turns.list(session.id)] == [turns[0].id] + denied = raw.get(base + "/v1/agents/sessions/" + session.id, + headers={**headers, "Authorization": "Bearer " + foreign}) + assert denied.status_code == 404 + changed = raw.post(base + "/v1/agents/sessions", headers={**headers, **key}, + json={**configuration, "input": "Changed"}) + assert changed.status_code == 409 + sessions.events.create(session.id, events=[{"type": "agent.session.input.cancel"}]) + assert sessions.create(**configuration, input=initial, extra_headers=key).status == "idle" + assert len(list(sessions.turns.list(session.id))) == 1 + + verify_creation_streams(client, raw, base, headers, foreign, unsupported) + + before = {session.id for session in sessions.list()} + for fields in [{"input": 0}, {"input": {}}, {"input": []}, {"input": " "}, + {"input": [{"role": "assistant", "content": [{"type": "input_text", "text": "x"}]}]}, + {"input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/x.png"}]}]}]: + reply = raw.post(base + "/v1/agents/sessions", headers=headers, json={**spec, **fields}) + assert reply.status_code == 400, (fields, reply.status_code) + reply = raw.post(unsupported + "/v1/agents/sessions", headers=headers, json={**spec, "input": "x"}) + assert reply.status_code == 400 + assert {session.id for session in sessions.list()} == before + print("Initial text: pinned SDK/raw HTTP, saved and inline snapshots, null/omission, ordered Items, retries/cancellation, tenant isolation and no writes on rejection passed.") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/tests/official_session_metadata.py b/services/agents-api/tests/official_session_metadata.py new file mode 100644 index 000000000..3cdc7bac1 --- /dev/null +++ b/services/agents-api/tests/official_session_metadata.py @@ -0,0 +1,92 @@ +"Verify pinned Session metadata replacement through the actual HTTP service." + +import json +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, ConflictError, NotFoundError + + +def without_metadata(session): + return {key: value for key, value in session.to_dict().items() if key != "metadata"} + + +def verify_session_metadata(client, other, invalid, spec, expect_error): + sessions = client.beta.agents.sessions + original = {"old": "remove", "keep": "replace"} + retry = {"Idempotency-Key": str(uuid.uuid4())} + first = sessions.create(**spec, metadata=original, extra_headers=retry) + fixed = without_metadata(first) + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + url = str(client.base_url).rstrip("/") + "/agents/sessions/" + first.id + + def assert_metadata(session, expected): + assert session.metadata == expected + assert without_metadata(session) == fixed + assert sessions.retrieve(first.id) == session + assert next(item for item in sessions.list(limit=1) if item.id == first.id) == session + return session + + assert_metadata(sessions.update(first.id), original) + with httpx2.Client(trust_env=False, timeout=10) as raw: + for metadata in [{"keep": "new", "empty": ""}, None, {}, + {"🧪" * 64: "界" * 512, **{str(i): "🧪" * 512 for i in range(15)}}]: + expected = metadata or {} + assert_metadata(sessions.update(first.id, metadata=metadata), expected) + # Reset between HTTP and SDK updates so both must actually replace values. + sessions.update(first.id, metadata=original) + response = raw.post(url, headers=headers, json={"metadata": metadata}) + assert response.status_code == 200 and response.headers["cache-control"] == "no-store" + assert response.json()["metadata"] == expected + current = assert_metadata(sessions.retrieve(first.id), expected) + assert response.json() == current.to_dict() + assert sessions.update(first.id) == current + assert raw.post(url, headers=headers, json={}).json() == current.to_dict() + # Updating metadata must not rewrite or reapply the original creation request. + assert sessions.create(**spec, metadata=original, extra_headers=retry) == current + expect_error(ConflictError, lambda: sessions.create(**spec, metadata=metadata, extra_headers=retry)) + + invalid_fields = [ + {"metadata": []}, {"metadata": "value"}, {"metadata": False}, + {"metadata": {"key": None}}, {"metadata": {"key": 1}}, + {"metadata": {"key": []}}, {"metadata": {"key": {}}}, + {"metadata": {str(i): "value" for i in range(17)}}, + {"metadata": {"界" * 65: "value"}}, {"metadata": {"key": "🧪" * 513}}, + {"agent": spec["agent"]}, {"environment": spec["environment"]}, + {"tenant_id": str(uuid.uuid4())}, {"Metadata": {}}, + ] + for fields in invalid_fields: + response = raw.post(url, headers=headers, json=fields) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_request" + expect_error(BadRequestError, lambda: sessions.update(first.id, extra_body=fields)) + assert sessions.retrieve(first.id) == current + for body in ["", "null", "[]", "1", "{}{}", '{"metadata":']: + response = raw.post(url, headers=headers, content=body) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_request" + oversized = json.dumps({"metadata": {"key": "x" * (1024 * 1024)}}) + response = raw.post(url, headers=headers, content=oversized) + assert response.status_code == 413 and response.json()["error"]["code"] == "request_too_large" + assert raw.patch(url, headers=headers, json={"metadata": {}}).status_code == 405 + for path in [url + "?tenant_id=other", url + "?unsupported=1"]: + assert raw.post(path, headers=headers, json={}).status_code == 400 + for target in [other, invalid]: + expected_error = AuthenticationError if target is invalid else NotFoundError + for fields in [{}, {"metadata": None}, {"metadata": {"tenant_id": "untrusted"}}]: + expect_error(expected_error, lambda: target.beta.agents.sessions.update(first.id, **fields)) + expect_error(NotFoundError, lambda: sessions.update(str(uuid.uuid4()), metadata={})) + expect_error(NotFoundError, lambda: sessions.update(str(uuid.uuid4()))) + expect_error(BadRequestError, lambda: sessions.update("invalid-id", metadata={})) + expect_error(BadRequestError, lambda: sessions.update(first.id, extra_headers={"OpenAI-Beta": ""})) + assert sessions.retrieve(first.id) == current + print("Session metadata: pinned SDK and raw HTTP replacement, clearing, omission, limits, authentication, tenant isolation, unchanged configuration and creation retry identity passed.") + return current + + +def verify_active_session_metadata(client, session_id): + sessions = client.beta.agents.sessions + before = sessions.retrieve(session_id) + assert before.status == "in_progress" + updated = sessions.update(session_id, metadata={"label": "active execution"}) + assert updated.metadata == {"label": "active execution"} + assert without_metadata(updated) == without_metadata(before) + return updated diff --git a/services/agents-api/tests/official_session_requests.py b/services/agents-api/tests/official_session_requests.py new file mode 100644 index 000000000..b9c9c4aad --- /dev/null +++ b/services/agents-api/tests/official_session_requests.py @@ -0,0 +1,50 @@ +"""Check Session-create field presence against the pinned SDK request types.""" + +import uuid + +import httpx2 +from openai import BadRequestError + + +def verify_session_create_requests(client, spec): + # SessionCreateParamsBase permits null metadata, not null string values; + # agent_id is str and stream is a boolean literal union without None. + sessions = client.beta.agents.sessions + before = {session.id for session in sessions.list()} + invalid = [ + {"stream": None}, {"stream": "false"}, {"stream": 0}, + {"agent_id": None}, {"agent_id": 0}, + {"metadata": {"label": None}}, + {"metadata": {"empty": "", "label": None}}, + {"metadata": {"label": 0}}, {"metadata": []}, + ] + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + for fields in invalid: + response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", + headers=headers, json={**spec, **fields}) + assert response.status_code == 400, (fields, response.status_code) + assert response.json()["error"]["code"] == "invalid_request" + try: + sessions.create(**spec, extra_body=fields) + except BadRequestError as error: + assert error.body["code"] == "invalid_request" + else: + raise AssertionError(f"Official client accepted invalid fields: {fields}") + assert {session.id for session in sessions.list()} == before + + key = {"Idempotency-Key": str(uuid.uuid4())} + first = sessions.create(**spec, extra_headers=key) + assert first.metadata == {} + for fields in [{}, {"input": None}, {"stream": False}, {"metadata": None}, {"metadata": {}}, + {"stream": False, "metadata": None}]: + assert sessions.create(**spec, extra_body=fields, extra_headers=key) == first + response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", + headers={**headers, **key}, json={**spec, **fields}) + assert response.status_code == 200 + assert response.json()["id"] == first.id and response.json()["metadata"] == {} + metadata = {"empty": "", "label": "中文🧪"} + preserved = sessions.create(**spec, metadata=metadata) + assert sessions.retrieve(preserved.id).metadata == metadata + print("Session create requests: raw HTTP and pinned SDK null rejection, no writes on rejection, default equivalence, idempotency and exact metadata passed.") + return [first, preserved] diff --git a/services/agents-api/tests/official_source_file_list.py b/services/agents-api/tests/official_source_file_list.py new file mode 100644 index 000000000..7155493b8 --- /dev/null +++ b/services/agents-api/tests/official_source_file_list.py @@ -0,0 +1,97 @@ +"""Project-scoped Files discovery through the pinned SDK and raw HTTP.""" + +from urllib.parse import parse_qs, urlsplit + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_page(response, expected, has_more): + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + body = response.json() + assert body == { + "object": "list", + "data": [value.to_dict() for value in expected], + "has_more": has_more, + "first_id": expected[0].id if expected else None, + "last_id": expected[-1].id if expected else None, + } + + +def verify_source_file_list(client, other, invalid, peer, expect_error): + files = client.files + expected = [ + files.create(file=(f"source-{index}.txt", f"value-{index}".encode()), purpose="user_data") + for index in range(105) + ] + foreign = other.files.create(file=("foreign.txt", b"foreign"), purpose="user_data") + descending = list(reversed(expected)) + + def sdk_page(values, has_more, query, **request): + response = files.with_raw_response.list(**request) + assert parse_qs(urlsplit(str(response.http_response.request.url)).query, keep_blank_values=True) == query + verify_page(response.http_response, values, has_more) + assert response.parse().data == values + + sdk_page(descending, False, {}) + sdk_page(expected[7:14], True, {"after": [expected[6].id], "limit": ["7"], "order": ["asc"], "purpose": ["user_data"]}, + after=expected[6].id, limit=7, order="asc", purpose="user_data") + pages = list(files.list(limit=100, order="asc").iter_pages()) + assert [len(page.data) for page in pages] == [100, 5] + assert [value for page in pages for value in page.data] == expected + assert pages[0].has_next_page() and not pages[-1].has_next_page() + assert list(files.list(limit=17, order="desc")) == descending + assert files.list(purpose="batch").data == [] + assert list(peer.files.list(order="asc")) == expected + assert list(other.files.list()) == [foreign] + + endpoint = str(client.base_url).rstrip("/") + "/files" + headers = {"Authorization": f"Bearer {client.api_key}"} + with httpx2.Client(trust_env=False, timeout=20) as raw: + verify_page(raw.get(endpoint, headers=headers), descending, False) + first = raw.get(endpoint, headers=headers, params={"order": "asc", "limit": "100"}) + verify_page(first, expected[:100], True) + verify_page(raw.get(endpoint, headers=headers, params={"order": "asc", "limit": "100", "after": first.json()["last_id"]}), expected[100:], False) + verify_page(raw.get(endpoint, headers=headers, params={"purpose": "batch"}), [], False) + response = raw.get(endpoint, headers=headers, params={"after": foreign.id}) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert foreign.id not in response.text + for query in ("limit=0", "limit=10001", "limit=null", "order=invalid", "after=a&after=b", "purpose=a&purpose=b", "unknown=x"): + response = raw.get(endpoint + "?" + query, headers=headers) + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + response = raw.get(endpoint, headers=headers, params={"after": "not-a-file"}) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" + assert response.json()["error"]["type"] == "invalid_request_error" + assert raw.get(endpoint).status_code == 401 + for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): + assert raw.get(endpoint, headers=headers | scope).status_code == 401 + expect_error(AuthenticationError, lambda scope=scope: files.list(extra_headers=scope)) + + deleted_cursor = files.create(file=("deleted-cursor.txt", b"gone"), purpose="user_data") + files.delete(deleted_cursor.id) + expect_error(NotFoundError, lambda: files.list(after=deleted_cursor.id)) + expect_error(NotFoundError, lambda: files.list(after=foreign.id)) + expect_error(NotFoundError, lambda: other.files.list(after=expected[0].id)) + expect_error(AuthenticationError, lambda: invalid.files.list()) + expect_error(BadRequestError, lambda: files.list(limit=0)) + expect_error(BadRequestError, lambda: files.list(limit=10001)) + print("Source Files list: fixed SDK and raw HTTP pagination, filters, exact envelopes, restart-ready storage and project isolation passed.") + return expected, foreign + + +def verify_source_file_list_recovery(client, other, peer, saved): + expected, foreign = saved + assert list(client.files.list(order="asc")) == expected + assert list(peer.files.list(limit=19, order="desc")) == list(reversed(expected)) + assert list(other.files.list()) == [foreign] + for value in expected: + receipt = client.files.delete(value.id) + assert receipt.id == value.id and receipt.object == "file" and receipt.deleted is True + receipt = other.files.delete(foreign.id) + assert receipt.id == foreign.id and receipt.deleted is True + assert client.files.list().data == [] and other.files.list().data == [] + print("Source Files list: discovery survived API restart and project-owned cleanup returned empty envelopes.") diff --git a/services/agents-api/tests/official_source_files.py b/services/agents-api/tests/official_source_files.py new file mode 100644 index 000000000..3c6084a92 --- /dev/null +++ b/services/agents-api/tests/official_source_files.py @@ -0,0 +1,80 @@ +"""Referenced source-file acceptance against our actual API, not an OpenAI proxy.""" + +import hashlib +from pathlib import Path +import tempfile + + +def verify_source_files(client, foreign, http, environment, directory, cases): + base = str(client.base_url).rstrip("/") + headers = {"Authorization": "Bearer " + client.api_key} + other = {"Authorization": "Bearer " + foreign.api_key} + copies, receipts = {}, [] + for index, (name, content) in enumerate(cases.items()): + if index % 2: + response = http.post(base + "/files", headers=headers, + data={"purpose": "user_data"}, files={"file": (name, content)}) + assert response.status_code == 200, "Raw source upload failed" + source = response.json() + else: + source = client.files.create(file=(name, content), purpose="user_data").to_dict() + source_id = source["id"] + endpoint = base + "/files/" + source_id + assert set(source) == {"id", "object", "bytes", "created_at", "filename", "purpose", "status", "expires_at", "status_details"} + assert source["object"] == "file" and source["bytes"] == len(content) and source["filename"] == name + assert source["purpose"] == "user_data" and source["status"] == "processed" + assert source["expires_at"] is None and source["status_details"] is None + assert client.files.retrieve(source_id).to_dict() == source + assert client.files.content(source_id).read() == content + for method, suffix in (("GET", ""), ("GET", "/content"), ("DELETE", "")): + response = http.request(method, endpoint + suffix, headers=other) + assert response.status_code == 404 and "error" in response.json(), "Foreign source access succeeded" + assert source_id not in response.text and name not in response.text + path = directory + "/source-" + name + receipt = client.beta.agents.environments.files.create(environment, type="file_id", file_id=source_id, path=path).to_dict() + assert receipt == {"environment_id": environment, "object": "agent.environment.file", "path": path, "size_bytes": len(content)} + assert client.files.delete(source_id).to_dict() == {"id": source_id, "object": "file", "deleted": True} + for suffix in ("", "/content"): + assert http.get(endpoint + suffix, headers=headers).status_code == 404 + response = http.post(base + "/agents/environments/" + environment + "/files", + headers={**headers, "OpenAI-Beta": "agents=v1"}, + json={"type": "file_id", "file_id": source_id, "path": path}) + assert response.status_code == 404, "Deleted source copied again" + copies[path] = len(content) + receipts.append({"path": path, "size": len(content), "sha256": hashlib.sha256(content).hexdigest()}) + + foreign_source = foreign.files.create(file=("foreign.bin", b"foreign-private"), purpose="user_data") + try: + response = http.post(base + "/agents/environments/" + environment + "/files", + headers={**headers, "OpenAI-Beta": "agents=v1"}, + json={"type": "file_id", "file_id": foreign_source.id, "path": directory + "/foreign.bin"}) + assert response.status_code == 404, "Foreign source crossed project boundary" + finally: + foreign.files.delete(foreign_source.id) + + scratch = Path.home() / ".parsar" / "tmp" + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryFile(dir=scratch) as body: + chunk = bytes(range(256)) * 1024 + digest = hashlib.sha256() + for _ in range(2048): + body.write(chunk) + digest.update(chunk) + body.seek(0) + large = client.files.create(file=("512MiB.bin", body), purpose="user_data") + try: + assert large.bytes == 512 << 20 + actual, size = hashlib.sha256(), 0 + with client.files.with_streaming_response.content(large.id) as response: + for chunk in response.iter_bytes(chunk_size=256 << 10): + size += len(chunk) + actual.update(chunk) + assert size == large.bytes and actual.digest() == digest.digest(), "Large source stream differs" + response = http.post(base + "/agents/environments/" + environment + "/files", + headers={**headers, "OpenAI-Beta": "agents=v1"}, + json={"type": "file_id", "file_id": large.id, "path": directory + "/too-large.bin"}) + assert response.status_code == 413, "Destination size bound bypassed" + finally: + client.files.delete(large.id) + return copies, receipts, {"source_limit_bytes": 512 << 20, "large_source_sha256": digest.hexdigest(), + "deleted_sources_unavailable": True, "foreign_source_rejected": True} diff --git a/services/agents-api/tests/official_vault_delete.py b/services/agents-api/tests/official_vault_delete.py new file mode 100644 index 000000000..e19744572 --- /dev/null +++ b/services/agents-api/tests/official_vault_delete.py @@ -0,0 +1,104 @@ +"""Pinned Vault deletion, child removal, scoped retries and keyless recovery.""" + +import uuid + +import httpx2 +from openai import AuthenticationError, NotFoundError + + +def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): + vaults = client.beta.agents.vaults + values = [vaults.create(name=name) for name in ["Cascade target", "Empty HTTP target", "Keyless target", "Retained Vault"]] + foreign = other.beta.agents.vaults.create(name="Foreign Vault deletion") + auth = {"type": "static_bearer", "mcp_server_url": "https://example.invalid/vault-delete", "token": canary} + children = [vaults.credentials.create(values[0].id, name=str(i), auth=auth) for i in range(3)] + keyless = vaults.credentials.create(values[2].id, name="Keyless child", auth=auth) + retained = vaults.credentials.create(values[3].id, name="Retained child", auth=auth) + foreign_child = other.beta.agents.vaults.credentials.create(foreign.id, name="Foreign child", auth=auth) + spec = {"agent": {"model": "requested-model"}, "environment": {"type": "none"}, "vault_ids": [values[0].id, values[3].id]} + headers = {"Idempotency-Key": "vault-delete-" + str(uuid.uuid4())} + session = client.beta.agents.sessions.create(**spec, extra_headers=headers) + target = values[0] + expect_error(NotFoundError, lambda: other.beta.agents.vaults.delete(target.id)) + expect_error(NotFoundError, lambda: vaults.delete(foreign.id)) + expect_error(NotFoundError, lambda: vaults.delete(str(uuid.uuid4()))) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.delete(target.id)) + auth_headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + endpoint = str(client.base_url).rstrip("/") + "/vaults/" + with httpx2.Client(trust_env=False, timeout=10) as raw: + assert raw.delete(endpoint + target.id).status_code == 401 + response = raw.delete(endpoint + target.id, headers={"Authorization": auth_headers["Authorization"]}) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + for kwargs in [{"params": {"include": "credentials"}}, {"content": b"{}"}]: + assert raw.request("DELETE", endpoint + target.id, headers=auth_headers, **kwargs).status_code == 400 + assert vaults.retrieve(target.id) == target + assert list(vaults.credentials.list(target.id, order="asc")) == children + response = peer.beta.agents.vaults.with_raw_response.delete(target.id) + expected = {"id": target.id, "deleted": True, "object": "vault.deleted"} + assert response.status_code == 200 and response.parse().to_dict() == expected + assert response.http_response.json() == expected and canary not in response.http_response.text + assert response.headers["cache-control"] == "no-store" + response = raw.delete(endpoint + values[1].id, headers=auth_headers) + assert response.status_code == 200 and response.json() == {**expected, "id": values[1].id} + for value in values[:2]: + for method in ["GET", "DELETE"]: + response = raw.request(method, endpoint + value.id, headers=auth_headers) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + response = raw.get(endpoint + value.id + "/credentials", headers=auth_headers) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + response = raw.post(endpoint + value.id + "/credentials", headers=auth_headers, json={"name": "late", "auth": auth}) + assert response.status_code == 404 and canary not in response.text + expect_error(NotFoundError, lambda: vaults.credentials.list(value.id)) + expect_error(NotFoundError, lambda: vaults.credentials.create(value.id, name="late", auth=auth)) + for child in children: + for operation in [vaults.credentials.retrieve, vaults.credentials.delete]: + expect_error(NotFoundError, lambda: operation(child.id, vault_id=target.id)) + expect_error(NotFoundError, lambda: vaults.credentials.update(child.id, vault_id=target.id, + auth={"type": "static_bearer", "token": canary})) + response = raw.get(endpoint + target.id + "/credentials/" + child.id, headers=auth_headers) + assert response.status_code == 404 + for status in [None, ["active"], ["active", "archived"]]: + args = {} if status is None else {"status": status} + ids = {v.id for v in values} + assert [v for v in vaults.list(order="asc", limit=2, **args) if v.id in ids] == values[2:] + expect_error(NotFoundError, lambda: client.beta.agents.sessions.create(**spec)) + response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", headers=auth_headers, json=spec) + assert response.status_code == 404 + assert client.beta.agents.sessions.create(**spec, extra_headers=headers) == session + assert client.beta.agents.sessions.retrieve(session.id) == session + assert vaults.credentials.retrieve(retained.id, vault_id=retained.vault_id) == retained + assert other.beta.agents.vaults.retrieve(foreign.id) == foreign + assert other.beta.agents.vaults.credentials.retrieve(foreign_child.id, vault_id=foreign.id) == foreign_child + print("Vault deletion: SDK/raw confirmation, cascade visibility, scope and retained Session identity passed.") + return values, keyless, retained, foreign, foreign_child, spec, headers, session + + +def verify_vault_deletion_recovery(client, other, saved, expect_error): + values, keyless, retained, foreign, foreign_child, spec, headers, session = saved + vaults = client.beta.agents.vaults + for target in values[:2]: + expect_error(NotFoundError, lambda: vaults.retrieve(target.id)) + expect_error(NotFoundError, lambda: vaults.delete(target.id)) + expect_error(NotFoundError, lambda: vaults.credentials.list(target.id)) + for value in values[2:]: + assert vaults.retrieve(value.id) == value + for child in [keyless, retained]: + assert vaults.credentials.retrieve(child.id, vault_id=child.vault_id) == child + assert other.beta.agents.vaults.retrieve(foreign.id) == foreign + assert other.beta.agents.vaults.credentials.retrieve(foreign_child.id, vault_id=foreign.id) == foreign_child + assert client.beta.agents.sessions.retrieve(session.id) == session + assert client.beta.agents.sessions.create(**spec, extra_headers=headers) == session + expect_error(NotFoundError, lambda: client.beta.agents.sessions.create(**spec)) + print("Vault deletion: absent parents/children, unaffected resources and Session retry survived API restart.") + + +def verify_keyless_vault_deletion(client, saved, expect_error): + values, child, retained, *_ = saved + vaults, target = client.beta.agents.vaults, values[2] + assert vaults.delete(target.id).to_dict() == {"id": target.id, "deleted": True, "object": "vault.deleted"} + expect_error(NotFoundError, lambda: vaults.retrieve(target.id)) + expect_error(NotFoundError, lambda: vaults.credentials.retrieve(child.id, vault_id=target.id)) + expect_error(NotFoundError, lambda: vaults.credentials.list(target.id)) + assert vaults.retrieve(values[3].id) == values[3] + assert vaults.credentials.retrieve(retained.id, vault_id=retained.vault_id) == retained + print("Vault deletion: keyless cascade removed stored children while another Vault remained usable.") diff --git a/services/agents-api/tests/official_vault_list.py b/services/agents-api/tests/official_vault_list.py new file mode 100644 index 000000000..4009bd108 --- /dev/null +++ b/services/agents-api/tests/official_vault_list.py @@ -0,0 +1,143 @@ +"""Vault discovery through the fixed SDK and HTTP, with synthetic archived rows.""" + +import json +import os +from pathlib import Path +import subprocess +from urllib.parse import parse_qs, urlsplit + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + +from official_vaults import verify_vault + + +def verify_page(response, expected, has_more): + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + body = response.json() + assert type(body["has_more"]) is bool + assert body == {"object": "list", "data": [value.to_dict() for value in expected], + "has_more": has_more, "first_id": expected[0].id if expected else None, + "last_id": expected[-1].id if expected else None} + for value in body["data"]: + verify_vault(value, value["name"], value["metadata"]) + + +def seed_archived_fixture(root, directory, tenant, values): + # Only this private SQL fixture assigns status; no public archive operation exists. + path = Path(directory) / "vault-list.json" + path.write_text(json.dumps({"tenant": tenant, "vaults": [value.id for value in values]})) + subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + env=dict(os.environ, AGENTS_API_VAULT_LIST_FIXTURE=str(path)), + check=True, timeout=120) + + +def verify_vault_list(client, other, invalid, peer, binding, saved, root, directory, expect_error): + vaults = client.beta.agents.vaults + original, foreign = saved + created = [vaults.create(name=f"Discovery {index}", metadata={"sequence": str(index)}) + for index in range(105)] + expected = original + created + archived = created[::3] + archived_ids = {value.id for value in archived} + active = [value for value in expected if value.id not in archived_ids] + seed_archived_fixture(root, directory, binding["tenant_id"], archived) + descending = list(reversed(expected)) + + def sdk_page(values, has_more, query, **request): + response = vaults.with_raw_response.list(**request) + assert parse_qs(urlsplit(str(response.http_response.request.url)).query, keep_blank_values=True) == query + verify_page(response.http_response, values, has_more) + assert response.parse().data == values + + sdk_page(descending[:20], True, {}) + sdk_page(descending[:20], True, {}, limit=None) + sdk_page(descending[:20], True, {}, status=[]) + for limit, size in ((0, 1), (-7, 1), (101, 100)): + sdk_page(descending[:size], True, {"limit": [str(limit)]}, limit=limit) + sdk_page(list(reversed(archived))[:20], True, {"status": ["archived"]}, status="archived") + sdk_page(active[:20], True, {"status[]": ["active"], "order": ["asc"]}, + status=["active"], order="asc") + sdk_page(descending[:20], True, {"status[]": ["active", "archived"]}, + status=["active", "archived"]) + sdk_page(expected[7:14], True, {"after": [expected[6].id], "limit": ["7"], "order": ["asc"]}, + after=expected[6].id, limit=7, order="asc") + + pages = list(vaults.list(limit=100, order="asc").iter_pages()) + assert [len(page.data) for page in pages] == [100, len(expected) - 100] + assert [value for page in pages for value in page.data] == expected + assert pages[0].has_next_page() and not pages[-1].has_next_page() + assert list(vaults.list(limit=17)) == descending + assert list(vaults.list(status="archived", limit=7, order="asc")) == archived + assert list(vaults.list(status=["active"], limit=19, order="asc")) == active + assert list(peer.beta.agents.vaults.list(order="asc")) == expected + assert list(other.beta.agents.vaults.list()) == [foreign] + assert other.beta.agents.vaults.list(status="archived").data == [] + assert vaults.retrieve(archived[0].id) == archived[0] + + endpoint = str(client.base_url).rstrip("/") + "/vaults" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + verify_page(raw.get(endpoint, headers=headers), descending[:20], True) + for limit, size in (("0", 1), ("-7", 1), ("101", 100)): + verify_page(raw.get(endpoint, headers=headers, params={"limit": limit}), + descending[:size], True) + for params, values in (({"status": "active"}, list(reversed(active))), + ([("status[]", "archived")], list(reversed(archived))), + ([("status[]", "active"), ("status[]", "archived")], descending)): + verify_page(raw.get(endpoint, headers=headers, params=params), values[:20], True) + for order, last in (("asc", expected[-1]), ("desc", expected[0])): + params = {"after": last.id, "order": order} + verify_page(raw.get(endpoint, headers=headers, params=params), [], False) + assert list(vaults.list(**params)) == [] + first = raw.get(endpoint, headers=headers, params={"order": "asc", "limit": "100"}) + verify_page(first, expected[:100], True) + verify_page(raw.get(endpoint, headers=headers, + params={"order": "asc", "limit": "100", "after": first.json()["last_id"]}), + expected[100:], False) + foreign_headers = headers | {"Authorization": f"Bearer {other.api_key}"} + verify_page(raw.get(endpoint, headers=foreign_headers, params={"status": "archived"}), [], False) + response = raw.get(endpoint, headers=headers, params={"after": foreign.id}) + assert response.status_code == 404 and response.json()["error"]["code"] == "not_found" + assert foreign.id not in response.text + for params in ({"after": "invalid-vault"}, {"status": "unknown"}, {"limit": "null"}): + response = raw.get(endpoint, headers=headers, params=params) + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + assert raw.get(endpoint).status_code == 401 + for beta in (None, "agents=v2"): + auth = {"Authorization": headers["Authorization"]} + if beta is not None: + auth["OpenAI-Beta"] = beta + response = raw.get(endpoint, headers=auth) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): + assert raw.get(endpoint, headers=headers | scope).status_code == 401 + expect_error(AuthenticationError, lambda: vaults.list(extra_headers=scope)) + expect_error(NotFoundError, lambda: vaults.list(after=foreign.id)) + expect_error(NotFoundError, lambda: other.beta.agents.vaults.list(after=expected[0].id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.list()) + expect_error(BadRequestError, lambda: vaults.list(extra_headers={"OpenAI-Beta": ""})) + print("Vault list: fixed SDK query serialization, >100-row auto-pagination, exact HTTP envelopes, clamped limits, stored status filtering and project isolation passed; archived rows use a private SQL fixture.") + return expected, archived + + +def verify_vault_list_recovery(client, other, peer, saved): + expected, archived = saved + known = {value.id for value in expected} + for caller in (client, peer): + discovered = list(caller.beta.agents.vaults.list(limit=100, order="asc")) + assert [value for value in discovered if value.id in known] == expected + assert list(caller.beta.agents.vaults.list(status=["archived"], limit=7, order="asc")) == archived + for value in (expected[0], archived[0], expected[-1]): + assert caller.beta.agents.vaults.retrieve(value.id) == value + assert known.isdisjoint(value.id for value in other.beta.agents.vaults.list()) + endpoint = str(client.base_url).rstrip("/") + "/vaults" + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + with httpx2.Client(trust_env=False, timeout=10) as raw: + verify_page(raw.get(endpoint, headers=headers, + params={"status[]": "archived", "order": "asc", "limit": "100"}), + archived, False) + print("Vault list: public creation/discovery/retrieval and synthetic stored status reads survived the API restart.") diff --git a/services/agents-api/tests/official_vaults.py b/services/agents-api/tests/official_vaults.py new file mode 100644 index 000000000..13c9a4ff8 --- /dev/null +++ b/services/agents-api/tests/official_vaults.py @@ -0,0 +1,121 @@ +"""Vault create/retrieve through the pinned SDK and the built HTTP service.""" + +import time +import uuid + +import httpx2 +from openai import AuthenticationError, BadRequestError, NotFoundError + + +def verify_vault(body, name, metadata): + assert set(body) == {"id", "object", "created_at", "name", "metadata"} + assert uuid.UUID(body["id"]).int != 0 and body["object"] == "vault" + assert type(body["created_at"]) is int and body["created_at"] > 0 + assert body["name"] == name and body["metadata"] == metadata + assert all(isinstance(key, str) and isinstance(value, str) for key, value in body["metadata"].items()) + + +def verify_vaults(client, other, invalid, peer, binding, expect_error): + vaults = client.beta.agents.vaults + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + base = str(client.base_url).rstrip("/") + "/vaults" + sessions_before = [list(api.beta.agents.sessions.list()) for api in (client, other)] + saved = [] + # Vault metadata does not inherit the Session count/key/value limits. + metadata = {str(index): "value" for index in range(17)} | {"k" * 65: "值" * 513, "empty": ""} + cases = [({}, None, {}), ({"metadata": None}, None, {}), + ({"name": " \tVault 資源\u3000", "metadata": metadata}, "Vault 資源", metadata), + ({"name": " \t" + "🧪" * 64 + "\n"}, "🧪" * 64, {})] + with httpx2.Client(trust_env=False, timeout=10) as raw: + for request, name, expected_metadata in cases: + response = vaults.with_raw_response.create(**request) + body, value = response.http_response.json(), response.parse() + assert response.status_code == 200 + verify_vault(body, name, expected_metadata) + assert value.to_dict() == body and abs(value.created_at - time.time()) < 10 + assert vaults.retrieve(value.id) == value + assert peer.beta.agents.vaults.retrieve(value.id) == value + saved.append(value) + + response = raw.post(base, headers=headers, json={"name": "\nRaw Vault\t", "metadata": {}}) + assert response.status_code == 200 + verify_vault(response.json(), "Raw Vault", {}) + saved.append(vaults.retrieve(response.json()["id"])) + assert saved[-1].to_dict() == response.json() + + # A user and a service account in one project share resource ownership. + user_vault = peer.beta.agents.vaults.create(name="Created by project user") + assert vaults.retrieve(user_vault.id) == user_vault + saved.append(user_vault) + foreign = other.beta.agents.vaults.create(name="Foreign project") + assert other.beta.agents.vaults.retrieve(foreign.id) == foreign + expect_error(NotFoundError, lambda: vaults.retrieve(foreign.id)) + expect_error(NotFoundError, lambda: other.beta.agents.vaults.retrieve(saved[0].id)) + + for value in saved: + response = raw.get(base + "/" + value.id, headers=headers) + assert response.status_code == 200 and response.json() == value.to_dict() + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + + invalid_requests = [ + {"name": None}, {"name": 3}, {"name": " \t\n"}, + {"name": " " + "🧪" * 64 + "a "}, + {"metadata": {"bad": None}}, {"metadata": {"bad": 1}}, + {"metadata": []}, {"metadata": "invalid"}, + {"metadata": {"large": "x" * (64 * 1024)}}, + {"tenant_id": str(uuid.uuid4())}, {"status": "active"}, + ] + for request in invalid_requests: + response = raw.post(base, headers=headers, json=request) + assert response.status_code == 400 + assert response.json()["error"]["type"] == "invalid_request_error" + expect_error(BadRequestError, lambda: vaults.create(extra_body=request)) + for content in ("null", "[]", "{} {}"): + assert raw.post(base, headers=headers, content=content).status_code == 400 + + for resource_id in (str(uuid.uuid4()), "invalid-vault", str(uuid.UUID(int=0)), foreign.id): + response = raw.get(base + "/" + resource_id, headers=headers) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" + assert resource_id not in response.text + expect_error(NotFoundError, lambda: vaults.retrieve(resource_id)) + for scope in ({"OpenAI-Organization": "wrong-org"}, {"OpenAI-Project": "wrong-project"}): + expect_error(AuthenticationError, lambda: vaults.retrieve(saved[0].id, extra_headers=scope)) + expect_error(AuthenticationError, lambda: vaults.create(extra_headers=scope)) + scope = {"OpenAI-Organization": binding["organization_id"], "OpenAI-Project": binding["project_id"]} + assert vaults.retrieve(saved[0].id, extra_headers=scope) == saved[0] + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.create()) + expect_error(AuthenticationError, lambda: invalid.beta.agents.vaults.retrieve(saved[0].id)) + for suffix, method in (("", "POST"), ("/" + saved[0].id, "GET")): + assert raw.request(method, base + suffix, json={} if method == "POST" else None).status_code == 401 + for beta in (None, "agents=v2"): + request_headers = {"Authorization": headers["Authorization"]} + if beta is not None: + request_headers["OpenAI-Beta"] = beta + response = raw.request(method, base + suffix, headers=request_headers, + json={} if method == "POST" else None) + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_beta_header" + assert raw.post(base, headers=headers, params={"tenant_id": "other"}, json={}).status_code == 400 + assert raw.get(base + "/" + saved[0].id, headers=headers, params={"include": "credentials"}).status_code == 400 + alias = str(client.base_url).rstrip("/") + "/agents/vaults/" + saved[0].id + assert raw.get(alias, headers=headers).status_code == 404 + + assert [list(api.beta.agents.sessions.list()) for api in (client, other)] == sessions_before + # Rejected-request no-write evidence belongs to the real PostgreSQL tests. + print("Vault resources: fixed SDK/raw create/retrieve, exact fields, UTF-8 name boundary, metadata, project user/service access and validation passed; full lifecycle remains a gap.") + return saved, foreign + + +def verify_vault_recovery(client, other, peer, saved): + values, foreign = saved + headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} + base = str(client.base_url).rstrip("/") + "/vaults" + with httpx2.Client(trust_env=False, timeout=10) as raw: + for value in values: + assert client.beta.agents.vaults.retrieve(value.id) == value + assert peer.beta.agents.vaults.retrieve(value.id) == value + response = raw.get(base + "/" + value.id, headers=headers) + assert response.status_code == 200 and response.json() == value.to_dict() + assert other.beta.agents.vaults.retrieve(foreign.id) == foreign + print("Vault resources: exact SDK/raw responses and shared-project reads survived the server restart.") diff --git a/services/agents-api/tests/requirements.txt b/services/agents-api/tests/requirements.txt new file mode 100644 index 000000000..d37efa74c --- /dev/null +++ b/services/agents-api/tests/requirements.txt @@ -0,0 +1,2 @@ +jsonschema==4.25.1 +PyYAML==6.0.2 From 419ca3c5a318b117fe42793fbd08e516f1ed7a0c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 21 Sep 2026 11:53:36 +0800 Subject: [PATCH 2/2] Record standalone release archive verification --- provenance/verification.md | 1 + 1 file changed, 1 insertion(+) diff --git a/provenance/verification.md b/provenance/verification.md index 8c5912cb9..8d12dac63 100644 --- a/provenance/verification.md +++ b/provenance/verification.md @@ -24,6 +24,7 @@ from `contracts/agents-api/upstream.json` (commit | `python3 scripts/verify-source-copy.py` | Passed: all 1,287 imported paths present, 1,282 byte-identical; only the five documented packaging adaptations differ | | `make check` | Passed, exit 0: sqlc regeneration, daemon/shared/API/client Go tests, real PostgreSQL tests, standalone API build, Claude SDK tests/package, MiniMax companion checks, Rust format/tests/Clippy and Codex Harness packaging checks | | `make build-daemon` | Passed using a task-local absolute output directory | +| `make build-agents-api-release` | Passed from a clean committed standalone checkout (`3ba5c435b5ac9f9cece9bc9c21d979a1741796b5`); archive checksum, every member checksum and new-repository source links verified | | `go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12` | Passed, exit 0 | | `AGENTS_API_SERVER_BIN=.../agents-api python services/agents-api/tests/official_client.py` | Passed, exit 0, using the pinned SDK and dedicated database | | `AGENTS_API_IMAGE=parsar-core-import:72ab4d37 PARSAR_OFFICIAL_SDK_PYTHON=.../sdk/bin/python make check-agents-api-container` | Passed, exit 0: build standalone image and repeat the official-client suite inside read-only containers |